Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
|
|
@ -2,8 +2,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
|
||||
const { mockUseSWR } = vi.hoisted(() => ({ mockUseSWR: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
|
||||
vi.mock('swr', () => ({ default: mockUseSWR }));
|
||||
|
||||
vi.mock('@/services/cabinet', () => ({
|
||||
getCabinetSummary: vi.fn(),
|
||||
listCabinetItems: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/purchases', () => ({
|
||||
listPurchases: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/refills', () => ({
|
||||
getRefillAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/cabinet-events', () => ({
|
||||
listCabinetEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
|
|
@ -14,6 +34,7 @@ import DashboardPage from '../page';
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
|
||||
mockUseSWR.mockReturnValue({ data: undefined });
|
||||
});
|
||||
|
||||
describe(DashboardPage.name, () => {
|
||||
|
|
@ -27,13 +48,274 @@ describe(DashboardPage.name, () => {
|
|||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders page when household loaded', () => {
|
||||
it('renders greeting with user name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Alice' },
|
||||
profile: { displayName: 'John Doe' },
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/John/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders generic greeting without profile', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, profile: null });
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText(/there/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cabinet items', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const summaryData = { data: [{ _id: '1' }, { _id: '2' }] };
|
||||
const cabinetData = {
|
||||
data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: summaryData };
|
||||
if (key.includes('cabinet-items')) return { data: cabinetData };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
expect(screen.getByText('50 tablets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty states when no data', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pending orders.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows refill alerts with days left', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const refillData = {
|
||||
data: [{ medicineId: 'm1', medicineName: 'Vitamin C', daysUntilEmpty: 5 }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: refillData };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Vitamin C')).toBeInTheDocument();
|
||||
expect(screen.getByText('5d')).toBeInTheDocument();
|
||||
expect(screen.getByText(/critically low/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending purchases', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const purchaseData = {
|
||||
data: [
|
||||
{
|
||||
_id: 'p1',
|
||||
storeName: 'Pharmacy Plus',
|
||||
items: [{ name: 'A' }, { name: 'B' }],
|
||||
status: 'ordered',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Pharmacy Plus')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 items')).toBeInTheDocument();
|
||||
expect(screen.getByText('ordered')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows recent events', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const eventData = {
|
||||
data: [
|
||||
{
|
||||
_id: 'e1',
|
||||
eventType: 'consumed',
|
||||
medicineName: 'Aspirin',
|
||||
createdAt: '2026-01-15T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: eventData };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('consumed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "good shape" message when no critical alerts', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Your cabinet is in good shape.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stat badges with summary data', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary'))
|
||||
return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts'))
|
||||
return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Total medicines')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Running low')).toHaveLength(2);
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles store without name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const purchaseData = {
|
||||
data: [{ _id: 'p1', storeName: undefined, items: [{ name: 'A' }], status: 'ordered' }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: { data: [] } };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: purchaseData };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Unknown store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles cabinet item with no name', () => {
|
||||
mockUseApi.mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
isLoading: false,
|
||||
profile: { displayName: 'Jane' },
|
||||
});
|
||||
|
||||
const cabinetData = {
|
||||
data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }],
|
||||
};
|
||||
|
||||
mockUseSWR.mockImplementation((key: string) => {
|
||||
if (!key) return { data: undefined };
|
||||
if (key.includes('cabinet-summary')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-items')) return { data: cabinetData };
|
||||
if (key.includes('refill-alerts')) return { data: { data: [] } };
|
||||
if (key.includes('purchases')) return { data: { data: [] } };
|
||||
if (key.includes('cabinet-events')) return { data: { data: [] } };
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListRegimens } = vi.hoisted(() => ({
|
||||
mockListRegimens: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/regimens', () => ({
|
||||
listRegimens: mockListRegimens,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import SchedulePage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const makeRegimen = (overrides = {}) => ({
|
||||
_id: 'reg1',
|
||||
householdId: 'hh1',
|
||||
name: 'Daily Vitamins',
|
||||
isActive: true,
|
||||
startDate: '2026-01-01',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Vitamin D',
|
||||
medicineStrength: '1000',
|
||||
medicineStrengthUnit: 'IU',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'morning',
|
||||
},
|
||||
],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SchedulePage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no household message', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no regimens', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No active regimens found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows set up link in empty state', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Set up a regimen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders medication in morning slot', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Morning')).toBeInTheDocument();
|
||||
expect(screen.getByText('1000 IU')).toBeInTheDocument();
|
||||
expect(screen.getByText('Daily Vitamins')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows frequency label', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Once daily/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows custom frequency', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Custom Med',
|
||||
medicineStrength: '50',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'custom',
|
||||
customFrequencyPerDay: 4,
|
||||
timeOfDay: 'morning',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/4x daily/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows instructions when present', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm1',
|
||||
medicineName: 'Med A',
|
||||
medicineStrength: '10',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'pill',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'evening',
|
||||
instructions: 'Take with food',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Take with food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Evening')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows dose count', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1 dose')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error for non-Error failures', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockRejectedValue('unexpected');
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load regimens')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('groups into "any" slot when timeOfDay is missing', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [
|
||||
makeRegimen({
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm2',
|
||||
medicineName: 'Aspirin',
|
||||
medicineStrength: '100',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'as_needed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Aspirin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Any time')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('paginates through regimens', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens
|
||||
.mockResolvedValueOnce({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: 'next', hasMore: true },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
makeRegimen({
|
||||
_id: 'reg2',
|
||||
name: 'Second Regimen',
|
||||
medications: [
|
||||
{
|
||||
medicineId: 'm2',
|
||||
medicineName: 'Iron',
|
||||
medicineStrength: '65',
|
||||
medicineStrengthUnit: 'mg',
|
||||
dosage: 1,
|
||||
dosageUnit: 'tablet',
|
||||
frequency: 'daily',
|
||||
timeOfDay: 'afternoon',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vitamin D')).toBeInTheDocument();
|
||||
expect(screen.getByText('Iron')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockListRegimens).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows regimen and dose summary', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRegimens.mockResolvedValue({
|
||||
data: [makeRegimen()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<SchedulePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 active regimen/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 dose per day/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
339
packages/web/src/app/(dashboard)/pantry/PantryList.tsx
Normal file
339
packages/web/src/app/(dashboard)/pantry/PantryList.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { listPantryItems, transitionPantryItem, deletePantryItem } from '@/services/pantry';
|
||||
import { StorageLocation, ItemStatus, FreshnessUrgency } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { PantryItemResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type PantryItem = z.infer<typeof PantryItemResponseSchema>;
|
||||
|
||||
const STORAGE_TABS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: StorageLocation.FRIDGE, label: 'Fridge' },
|
||||
{ value: StorageLocation.FREEZER, label: 'Freezer' },
|
||||
{ value: StorageLocation.PANTRY, label: 'Pantry' },
|
||||
{ value: StorageLocation.COUNTER, label: 'Counter' },
|
||||
] as const;
|
||||
|
||||
const URGENCY_COLORS: Record<string, { bg: string; color: string; label: string }> = {
|
||||
[FreshnessUrgency.FRESH]: {
|
||||
bg: 'var(--success-soft, #d4edda)',
|
||||
color: 'var(--success, #28a745)',
|
||||
label: 'Fresh',
|
||||
},
|
||||
[FreshnessUrgency.USE_SOON]: {
|
||||
bg: 'var(--warning-soft, #fff3cd)',
|
||||
color: 'var(--warning, #856404)',
|
||||
label: 'Use soon',
|
||||
},
|
||||
[FreshnessUrgency.URGENT]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Urgent' },
|
||||
[FreshnessUrgency.CHECK]: { bg: 'var(--danger-soft)', color: 'var(--danger)', label: 'Check' },
|
||||
[FreshnessUrgency.EXPIRED]: {
|
||||
bg: 'var(--danger-soft)',
|
||||
color: 'var(--danger)',
|
||||
label: 'Expired',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
[ItemStatus.SEALED]: 'Sealed',
|
||||
[ItemStatus.OPENED]: 'Opened',
|
||||
[ItemStatus.PREPARED]: 'Prepared',
|
||||
[ItemStatus.CONSUMED]: 'Consumed',
|
||||
[ItemStatus.DISCARDED]: 'Discarded',
|
||||
[ItemStatus.EXPIRED]: 'Expired',
|
||||
};
|
||||
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[ItemStatus.SEALED]: [ItemStatus.OPENED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||
[ItemStatus.OPENED]: [ItemStatus.PREPARED, ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||
[ItemStatus.PREPARED]: [ItemStatus.CONSUMED, ItemStatus.DISCARDED],
|
||||
};
|
||||
|
||||
const TRANSITION_LABELS: Record<string, string> = {
|
||||
[ItemStatus.OPENED]: 'Open',
|
||||
[ItemStatus.PREPARED]: 'Prepare',
|
||||
[ItemStatus.CONSUMED]: 'Consume',
|
||||
[ItemStatus.DISCARDED]: 'Discard',
|
||||
};
|
||||
|
||||
export function PantryList({ householdId }: { householdId: string }) {
|
||||
const [items, setItems] = useState<PantryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [storageFilter, setStorageFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listPantryItems(householdId, {
|
||||
storageLocation: storageFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setItems(result.data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, storageFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
async function handleTransition(id: string, status: string) {
|
||||
try {
|
||||
const updated = await transitionPantryItem(householdId, id, { status } as never);
|
||||
setItems((prev) => prev.map((item) => (item._id === id ? updated : item)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Transition failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}"?`)) return;
|
||||
try {
|
||||
await deletePantryItem(householdId, id);
|
||||
setItems((prev) => prev.filter((item) => item._id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||
{/* Storage tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
marginBottom: 16,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{STORAGE_TABS.map((tab) => {
|
||||
const isActive = storageFilter === tab.value;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setStorageFilter(tab.value)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? 'var(--brand)' : 'var(--ink-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: isActive ? '2px solid var(--brand)' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, alignItems: 'center' }}>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="sealed">Sealed</option>
|
||||
<option value="opened">Opened</option>
|
||||
<option value="prepared">Prepared</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 140,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{storageFilter || statusFilter
|
||||
? 'No items match your filters.'
|
||||
: 'No pantry items yet. Add your first item.'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PantryCard
|
||||
key={item._id}
|
||||
item={item}
|
||||
onTransition={handleTransition}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PantryCard({
|
||||
item,
|
||||
onTransition,
|
||||
onDelete,
|
||||
}: {
|
||||
item: PantryItem;
|
||||
onTransition: (id: string, status: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
}) {
|
||||
const urgency =
|
||||
URGENCY_COLORS[item.freshnessEstimate.urgency] ?? URGENCY_COLORS[FreshnessUrgency.FRESH];
|
||||
const transitions = VALID_TRANSITIONS[item.status] ?? [];
|
||||
const daysText =
|
||||
item.freshnessEstimate.daysRemaining >= 0
|
||||
? `${item.freshnessEstimate.daysRemaining}d left`
|
||||
: `${Math.abs(item.freshnessEstimate.daysRemaining)}d overdue`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: 'var(--ink)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.productName}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>
|
||||
{item.quantity} {item.unit}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: urgency.bg,
|
||||
color: urgency.color,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{urgency.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', gap: 12 }}>
|
||||
<span>{STATUS_LABELS[item.status] ?? item.status}</span>
|
||||
<span>{daysText}</span>
|
||||
<span style={{ textTransform: 'capitalize' }}>{item.storageLocation}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{transitions.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 'auto' }}>
|
||||
{transitions.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => onTransition(item._id, status)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: status === ItemStatus.CONSUMED ? 'var(--brand-soft)' : 'var(--bg)',
|
||||
color: status === ItemStatus.CONSUMED ? 'var(--brand)' : 'var(--ink-muted)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: status === ItemStatus.CONSUMED ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{TRANSITION_LABELS[status] ?? status}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onDelete(item._id, item.productName)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--danger)',
|
||||
cursor: 'pointer',
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
317
packages/web/src/app/(dashboard)/pantry/__tests__/page.test.tsx
Normal file
317
packages/web/src/app/(dashboard)/pantry/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListPantryItems, mockTransitionPantryItem, mockDeletePantryItem } = vi.hoisted(() => ({
|
||||
mockListPantryItems: vi.fn(),
|
||||
mockTransitionPantryItem: vi.fn(),
|
||||
mockDeletePantryItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/pantry', () => ({
|
||||
listPantryItems: mockListPantryItems,
|
||||
transitionPantryItem: mockTransitionPantryItem,
|
||||
deletePantryItem: mockDeletePantryItem,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import PantryPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_ITEM = {
|
||||
_id: 'pi-1',
|
||||
householdId: 'hh1',
|
||||
productId: 'p1',
|
||||
productName: 'Whole Milk',
|
||||
storageLocation: 'fridge',
|
||||
quantity: 1,
|
||||
unit: 'liter',
|
||||
purchaseDate: '2026-05-01T00:00:00.000Z',
|
||||
status: 'sealed',
|
||||
freshnessEstimate: {
|
||||
estimatedExpiryDate: '2026-05-15T00:00:00.000Z',
|
||||
daysRemaining: 11,
|
||||
urgency: 'fresh',
|
||||
source: 'rule',
|
||||
},
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('PantryPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pantry items when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows freshness badge', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('11d left')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows overdue text for negative days', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
...SAMPLE_ITEM,
|
||||
_id: 'pi-2',
|
||||
freshnessEstimate: {
|
||||
...SAMPLE_ITEM.freshnessEstimate,
|
||||
daysRemaining: -2,
|
||||
urgency: 'expired',
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2d overdue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows transition buttons for active items', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
expect(screen.getByText('Consume')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles transition click', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockTransitionPantryItem.mockResolvedValue({
|
||||
...SAMPLE_ITEM,
|
||||
status: 'opened',
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Open'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTransitionPantryItem).toHaveBeenCalledWith('hh1', 'pi-1', { status: 'opened' });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete with confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeletePantryItem.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeletePantryItem).toHaveBeenCalledWith('hh1', 'pi-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(mockDeletePantryItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by storage tab', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Fridge'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ storageLocation: 'fridge' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by status select', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'opened' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListPantryItems).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ status: 'opened' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when transition fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [SAMPLE_ITEM],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockTransitionPantryItem.mockRejectedValue(new Error('Transition failed'));
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Open'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Transition failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filter empty state message', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListPantryItems.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<PantryPage />);
|
||||
|
||||
// Click a filter tab to trigger filter state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Fridge'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No items match your filters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/web/src/app/(dashboard)/pantry/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/pantry/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { PantryList } from './PantryList';
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 80,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
marginBottom: 12,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHousehold() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
You need to create or join a household before managing your pantry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PantryPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Pantry" subtitle="Track your food inventory" />
|
||||
<PantryList householdId={householdId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
packages/web/src/app/(dashboard)/products/ImportDialog.tsx
Normal file
235
packages/web/src/app/(dashboard)/products/ImportDialog.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { importProducts } from '@/services/products';
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
householdId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
errors: { row: number; message: string }[];
|
||||
}
|
||||
|
||||
export function ImportDialog({ open, onClose, householdId, onSuccess }: ImportDialogProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
setFile(selected);
|
||||
setError('');
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) {
|
||||
setError('Please select a file');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await importProducts(householdId, file);
|
||||
setResult(res);
|
||||
if (res.imported > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setFile(null);
|
||||
setError('');
|
||||
setResult(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-base, #fff)',
|
||||
borderRadius: 'var(--r-lg, 12px)',
|
||||
border: '1px solid var(--border)',
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Import Products
|
||||
</h2>
|
||||
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
{result ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14, margin: '0 0 8px', color: 'var(--ink)' }}>
|
||||
Import complete
|
||||
</p>
|
||||
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
|
||||
Imported: <strong>{result.imported}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: 13, margin: 0, color: 'var(--ink-muted)' }}>
|
||||
Skipped (duplicates): <strong>{result.skippedDuplicates}</strong>
|
||||
</p>
|
||||
{result.errors.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)', margin: 0 }}>
|
||||
Errors ({result.errors.length}):
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
paddingLeft: 16,
|
||||
fontSize: 12,
|
||||
color: 'var(--danger)',
|
||||
maxHeight: 120,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i}>
|
||||
Row {err.row}: {err.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: 'none',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
...inputStyle,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
color: file ? 'var(--ink)' : 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
{file ? file.name : 'Choose .csv or .json file...'}
|
||||
</button>
|
||||
{file && (
|
||||
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: '4px 0 0' }}>
|
||||
Size: {(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'transparent',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: 'none',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: !file || uploading ? 'not-allowed' : 'pointer',
|
||||
opacity: !file || uploading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
packages/web/src/app/(dashboard)/products/ProductList.tsx
Normal file
349
packages/web/src/app/(dashboard)/products/ProductList.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listProducts, deleteProduct, createProduct, updateProduct } from '@/services/products';
|
||||
import { ProductCategory, type ServingUnit } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { ProductResponseSchema, CreateProductInput } from '@meshitrack/shared';
|
||||
import { ProductModal } from './ProductModal';
|
||||
import { ImportDialog } from './ImportDialog';
|
||||
|
||||
type Product = z.infer<typeof ProductResponseSchema>;
|
||||
|
||||
const CATEGORY_OPTIONS = Object.values(ProductCategory);
|
||||
|
||||
const SERVING_UNIT_LABELS: Record<string, string> = {
|
||||
g: 'g',
|
||||
ml: 'ml',
|
||||
piece: 'pc',
|
||||
slice: 'sl',
|
||||
};
|
||||
|
||||
export function ProductList({ householdId }: { householdId: string }) {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listProducts(householdId, {
|
||||
q: debouncedSearch || undefined,
|
||||
category: category || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setProducts(result.data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, debouncedSearch, category]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}"?`)) return;
|
||||
try {
|
||||
await deleteProduct(householdId, id);
|
||||
setProducts((prev) => prev.filter((p) => p._id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateProductInput) {
|
||||
await createProduct(householdId, data);
|
||||
await fetchProducts();
|
||||
}
|
||||
|
||||
async function handleEdit(data: CreateProductInput) {
|
||||
if (!editingProduct) return;
|
||||
await updateProduct(householdId, editingProduct._id, data);
|
||||
await fetchProducts();
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||
{/* Filters */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
minWidth: 240,
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{CATEGORY_OPTIONS.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: 'none',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 120,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<p>No products yet. Add your first product to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product._id}
|
||||
product={product}
|
||||
onDelete={() => handleDelete(product._id, product.name)}
|
||||
onEdit={() => setEditingProduct(product)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductModal
|
||||
open={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
onSave={handleCreate}
|
||||
householdId={householdId}
|
||||
title="Add Product"
|
||||
/>
|
||||
|
||||
<ProductModal
|
||||
open={editingProduct !== null}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
onSave={handleEdit}
|
||||
householdId={householdId}
|
||||
initial={
|
||||
editingProduct
|
||||
? {
|
||||
name: editingProduct.name,
|
||||
brand: editingProduct.brand,
|
||||
barcode: editingProduct.barcode,
|
||||
category: editingProduct.category as ProductCategory,
|
||||
servingSize: editingProduct.servingSize,
|
||||
servingUnit: editingProduct.servingUnit as ServingUnit,
|
||||
densityGPerMl: editingProduct.densityGPerMl,
|
||||
nutrition: editingProduct.nutrition,
|
||||
tags: editingProduct.tags,
|
||||
imageUrl: editingProduct.imageUrl,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title="Edit Product"
|
||||
/>
|
||||
|
||||
<ImportDialog
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
householdId={householdId}
|
||||
onSuccess={fetchProducts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
product,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
product: Product;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, fontSize: 14, margin: 0, color: 'var(--ink)' }}>
|
||||
{product.name}
|
||||
</p>
|
||||
{product.brand && (
|
||||
<p style={{ fontSize: 12, color: 'var(--ink-muted)', margin: 0 }}>{product.brand}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--brand-soft, #e8f0fe)',
|
||||
color: 'var(--brand)',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{product.category.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
|
||||
<span>{product.nutrition.calories} kcal</span>
|
||||
<span>
|
||||
{product.servingSize}
|
||||
{SERVING_UNIT_LABELS[product.servingUnit] ?? product.servingUnit}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: 'var(--ink-dim)' }}>
|
||||
<span>P: {product.nutrition.protein}g</span>
|
||||
<span>C: {product.nutrition.carbs}g</span>
|
||||
<span>F: {product.nutrition.fat}g</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
aria-label="Edit product"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'transparent',
|
||||
color: 'var(--ink-muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
aria-label="Delete product"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--danger)',
|
||||
background: 'transparent',
|
||||
color: 'var(--danger)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
packages/web/src/app/(dashboard)/products/ProductModal.tsx
Normal file
533
packages/web/src/app/(dashboard)/products/ProductModal.tsx
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
|
||||
import type { CreateProductInput } from '@meshitrack/shared';
|
||||
import { lookupBarcode } from '@/services/products';
|
||||
|
||||
export interface ProductModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: CreateProductInput) => Promise<void>;
|
||||
householdId: string;
|
||||
initial?: Partial<CreateProductInput>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = Object.values(ProductCategory);
|
||||
const SERVING_UNIT_OPTIONS = Object.values(ServingUnit);
|
||||
|
||||
const SERVING_UNIT_LABELS: Record<string, string> = {
|
||||
g: 'Grams (g)',
|
||||
ml: 'Milliliters (ml)',
|
||||
piece: 'Piece',
|
||||
slice: 'Slice',
|
||||
};
|
||||
|
||||
export function ProductModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
householdId,
|
||||
initial,
|
||||
title,
|
||||
}: ProductModalProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lookingUp, setLookingUp] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [brand, setBrand] = useState('');
|
||||
const [barcode, setBarcode] = useState('');
|
||||
const [category, setCategory] = useState<ProductCategory>(ProductCategory.OTHER);
|
||||
const [servingSize, setServingSize] = useState<number | ''>('');
|
||||
const [servingUnit, setServingUnit] = useState<ServingUnit>(ServingUnit.GRAMS);
|
||||
const [densityGPerMl, setDensityGPerMl] = useState<number | ''>('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
|
||||
// Nutrition
|
||||
const [calories, setCalories] = useState<number | ''>('');
|
||||
const [protein, setProtein] = useState<number | ''>('');
|
||||
const [carbs, setCarbs] = useState<number | ''>('');
|
||||
const [fat, setFat] = useState<number | ''>('');
|
||||
const [fiber, setFiber] = useState<number | ''>('');
|
||||
const [sugar, setSugar] = useState<number | ''>('');
|
||||
const [sodium, setSodium] = useState<number | ''>('');
|
||||
const [saturatedFat, setSaturatedFat] = useState<number | ''>('');
|
||||
const [cholesterol, setCholesterol] = useState<number | ''>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? '');
|
||||
setBrand(initial?.brand ?? '');
|
||||
setBarcode(initial?.barcode ?? '');
|
||||
setCategory(initial?.category ?? ProductCategory.OTHER);
|
||||
setServingSize(initial?.servingSize ?? '');
|
||||
setServingUnit(initial?.servingUnit ?? ServingUnit.GRAMS);
|
||||
setDensityGPerMl(initial?.densityGPerMl ?? '');
|
||||
setTags(initial?.tags?.join(', ') ?? '');
|
||||
setImageUrl(initial?.imageUrl ?? '');
|
||||
setCalories(initial?.nutrition?.calories ?? '');
|
||||
setProtein(initial?.nutrition?.protein ?? '');
|
||||
setCarbs(initial?.nutrition?.carbs ?? '');
|
||||
setFat(initial?.nutrition?.fat ?? '');
|
||||
setFiber(initial?.nutrition?.fiber ?? '');
|
||||
setSugar(initial?.nutrition?.sugar ?? '');
|
||||
setSodium(initial?.nutrition?.sodium ?? '');
|
||||
setSaturatedFat(initial?.nutrition?.saturatedFat ?? '');
|
||||
setCholesterol(initial?.nutrition?.cholesterol ?? '');
|
||||
setError('');
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
if (servingSize === '' || servingSize <= 0) {
|
||||
setError('Serving size must be a positive number');
|
||||
return;
|
||||
}
|
||||
if (calories === '' || protein === '' || carbs === '' || fat === '') {
|
||||
setError('Calories, protein, carbs, and fat are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateProductInput = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
servingSize: Number(servingSize),
|
||||
servingUnit,
|
||||
nutrition: {
|
||||
calories: Number(calories),
|
||||
protein: Number(protein),
|
||||
carbs: Number(carbs),
|
||||
fat: Number(fat),
|
||||
...(fiber !== '' && { fiber: Number(fiber) }),
|
||||
...(sugar !== '' && { sugar: Number(sugar) }),
|
||||
...(sodium !== '' && { sodium: Number(sodium) }),
|
||||
...(saturatedFat !== '' && { saturatedFat: Number(saturatedFat) }),
|
||||
...(cholesterol !== '' && { cholesterol: Number(cholesterol) }),
|
||||
},
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
source: ProductSource.MANUAL,
|
||||
...(brand.trim() && { brand: brand.trim() }),
|
||||
...(barcode.trim() && { barcode: barcode.trim() }),
|
||||
...(densityGPerMl !== '' && { densityGPerMl: Number(densityGPerMl) }),
|
||||
...(imageUrl.trim() && { imageUrl: imageUrl.trim() }),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(data);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-base, #fff)',
|
||||
borderRadius: 'var(--r-lg, 12px)',
|
||||
border: '1px solid var(--border)',
|
||||
width: '100%',
|
||||
maxWidth: 560,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 20px', fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
{title ?? (initial ? 'Edit Product' : 'Add Product')}
|
||||
</h2>
|
||||
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Basic info */}
|
||||
<div>
|
||||
<label style={labelStyle}>Name *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Product name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Brand</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Barcode</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
placeholder="8-14 digits"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lookingUp || !barcode.trim()}
|
||||
onClick={async () => {
|
||||
if (!barcode.trim()) return;
|
||||
setLookingUp(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await lookupBarcode(householdId, barcode.trim());
|
||||
if ('found' in result) {
|
||||
setError('Product not found for this barcode');
|
||||
} else {
|
||||
setName(result.name ?? '');
|
||||
setBrand(result.brand ?? '');
|
||||
setCategory((result.category as ProductCategory) ?? ProductCategory.OTHER);
|
||||
setServingSize(result.servingSize ?? '');
|
||||
setServingUnit((result.servingUnit as ServingUnit) ?? ServingUnit.GRAMS);
|
||||
setDensityGPerMl(result.densityGPerMl ?? '');
|
||||
setTags(result.tags?.join(', ') ?? '');
|
||||
setImageUrl(result.imageUrl ?? '');
|
||||
setCalories(result.nutrition?.calories ?? '');
|
||||
setProtein(result.nutrition?.protein ?? '');
|
||||
setCarbs(result.nutrition?.carbs ?? '');
|
||||
setFat(result.nutrition?.fat ?? '');
|
||||
setFiber(result.nutrition?.fiber ?? '');
|
||||
setSugar(result.nutrition?.sugar ?? '');
|
||||
setSodium(result.nutrition?.sodium ?? '');
|
||||
setSaturatedFat(result.nutrition?.saturatedFat ?? '');
|
||||
setCholesterol(result.nutrition?.cholesterol ?? '');
|
||||
}
|
||||
} catch {
|
||||
setError('Barcode lookup failed');
|
||||
} finally {
|
||||
setLookingUp(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 12,
|
||||
cursor: lookingUp || !barcode.trim() ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: lookingUp || !barcode.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{lookingUp ? 'Looking up...' : 'Lookup'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Category *</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as ProductCategory)}
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Serving Size *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={servingSize}
|
||||
onChange={(e) =>
|
||||
setServingSize(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
placeholder="e.g. 100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Serving Unit *</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={servingUnit}
|
||||
onChange={(e) => setServingUnit(e.target.value as ServingUnit)}
|
||||
>
|
||||
{SERVING_UNIT_OPTIONS.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{SERVING_UNIT_LABELS[unit] ?? unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Density (g/ml)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={densityGPerMl}
|
||||
onChange={(e) =>
|
||||
setDensityGPerMl(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Image URL</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Tags (comma-separated)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="e.g. organic, gluten-free"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nutrition */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 16,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)', margin: '0 0 12px' }}>
|
||||
Nutrition (per serving)
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Calories *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={calories}
|
||||
onChange={(e) => setCalories(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Protein (g) *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={protein}
|
||||
onChange={(e) => setProtein(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Carbs (g) *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={carbs}
|
||||
onChange={(e) => setCarbs(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Fat (g) *</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={fat}
|
||||
onChange={(e) => setFat(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={labelStyle}>Fiber (g)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={fiber}
|
||||
onChange={(e) => setFiber(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Sugar (g)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={sugar}
|
||||
onChange={(e) => setSugar(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Sodium (mg)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={sodium}
|
||||
onChange={(e) => setSodium(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Sat. Fat (g)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={saturatedFat}
|
||||
onChange={(e) =>
|
||||
setSaturatedFat(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Cholesterol (mg)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={cholesterol}
|
||||
onChange={(e) =>
|
||||
setCholesterol(e.target.value === '' ? '' : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'transparent',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: 'none',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
opacity: saving ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockImportProducts } = vi.hoisted(() => ({
|
||||
mockImportProducts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
importProducts: mockImportProducts,
|
||||
}));
|
||||
|
||||
import { ImportDialog } from '../ImportDialog';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
householdId: 'hh1',
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ImportDialog', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ImportDialog {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders Import Products title when open', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
expect(screen.getByText('Import Products')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file chooser button', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when upload clicked without file', async () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
expect(screen.getByText('Please select a file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file name after selection', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['name,category\nTest,other'], 'products.csv', {
|
||||
type: 'text/csv',
|
||||
});
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(screen.getByText('products.csv')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uploads file and shows success result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 5,
|
||||
skippedDuplicates: 1,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(defaultProps.onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows errors in result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
errors: [{ row: 2, message: 'Missing name' }],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Row 2: Missing name/)).toBeInTheDocument();
|
||||
expect(defaultProps.onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error on import failure', async () => {
|
||||
mockImportProducts.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error on non-Error rejection', async () => {
|
||||
mockImportProducts.mockRejectedValue('unknown');
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dialog via Cancel button', () => {
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes dialog via Close button after result', async () => {
|
||||
mockImportProducts.mockResolvedValue({
|
||||
imported: 1,
|
||||
skippedDuplicates: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
render(<ImportDialog {...defaultProps} />);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByText('Upload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Import complete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Close'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockLookupBarcode } = vi.hoisted(() => ({
|
||||
mockLookupBarcode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
lookupBarcode: mockLookupBarcode,
|
||||
}));
|
||||
|
||||
import { ProductModal } from '../ProductModal';
|
||||
import { ProductCategory, ServingUnit } from '@meshitrack/shared';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue(undefined),
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
describe('ProductModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ProductModal {...defaultProps} open={false} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders Add Product title by default', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
expect(screen.getByText('Add Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom title when provided', () => {
|
||||
render(<ProductModal {...defaultProps} title="Custom Title" />);
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Edit Product title when initial is provided', () => {
|
||||
render(<ProductModal {...defaultProps} initial={{ name: 'Test' }} />);
|
||||
expect(screen.getByText('Edit Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills form fields from initial', () => {
|
||||
render(
|
||||
<ProductModal
|
||||
{...defaultProps}
|
||||
initial={{
|
||||
name: 'Chicken',
|
||||
brand: 'Tyson',
|
||||
barcode: '12345678',
|
||||
category: ProductCategory.MEAT,
|
||||
servingSize: 100,
|
||||
servingUnit: ServingUnit.GRAMS,
|
||||
tags: ['organic', 'protein'],
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue('Chicken')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Tyson')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('12345678')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('organic, protein')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('165')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when name is empty and form submitted', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
expect(defaultProps.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when serving size is empty', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(screen.getByText('Serving size must be a positive number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when nutrition fields are missing', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(
|
||||
screen.getByText('Calories, protein, carbs, and fat are required'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave with correct data on valid submit', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Chicken Breast' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
|
||||
// Fill nutrition
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
// serving size is index 0, calories=1, protein=2, carbs=3, fat=4
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '165' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '31' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '0' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Chicken Breast',
|
||||
servingSize: 100,
|
||||
nutrition: expect.objectContaining({
|
||||
calories: 165,
|
||||
protein: 31,
|
||||
carbs: 0,
|
||||
fat: 3.6,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal after successful save', async () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when onSave rejects', async () => {
|
||||
const saveFn = vi.fn().mockRejectedValue(new Error('Server error'));
|
||||
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error when onSave throws non-Error', async () => {
|
||||
const saveFn = vi.fn().mockRejectedValue('unknown');
|
||||
render(<ProductModal {...defaultProps} onSave={saveFn} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
|
||||
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
fireEvent.change(numberInputs[1]!, { target: { value: '10' } });
|
||||
fireEvent.change(numberInputs[2]!, { target: { value: '5' } });
|
||||
fireEvent.change(numberInputs[3]!, { target: { value: '2' } });
|
||||
fireEvent.change(numberInputs[4]!, { target: { value: '1' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes when Cancel is clicked', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes when backdrop is clicked', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
// Click the backdrop (outermost div with onClick=onClose)
|
||||
const backdrop = screen.getByText('Add Product').closest('div')!.parentElement!;
|
||||
fireEvent.click(backdrop);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Lookup button is disabled when barcode is empty', () => {
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
const lookupBtn = screen.getByText('Lookup');
|
||||
expect(lookupBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('barcode lookup fills form on success', async () => {
|
||||
mockLookupBarcode.mockResolvedValue({
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Nutella',
|
||||
brand: 'Ferrero',
|
||||
category: 'snacks',
|
||||
servingSize: 15,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 80, protein: 1, carbs: 8.5, fat: 4.7 },
|
||||
tags: ['spread'],
|
||||
source: 'barcode_lookup',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '3017620422003' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Nutella')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByDisplayValue('Ferrero')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('80')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('barcode lookup shows not found', async () => {
|
||||
mockLookupBarcode.mockResolvedValue({ found: false });
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '0000000000000' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Product not found for this barcode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('barcode lookup shows error on failure', async () => {
|
||||
mockLookupBarcode.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ProductModal {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('8-14 digits'), {
|
||||
target: { value: '1234567890123' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Lookup'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Barcode lookup failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListProducts, mockDeleteProduct } = vi.hoisted(() => ({
|
||||
mockListProducts: vi.fn(),
|
||||
mockDeleteProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/products', () => ({
|
||||
listProducts: mockListProducts,
|
||||
deleteProduct: mockDeleteProduct,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { default: (props: any) => props.children };
|
||||
});
|
||||
|
||||
import ProductsPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_PRODUCT = {
|
||||
_id: 'p1',
|
||||
householdId: 'hh1',
|
||||
name: 'Chicken Breast',
|
||||
brand: 'Tyson',
|
||||
category: 'meat',
|
||||
servingSize: 100,
|
||||
servingUnit: 'g',
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: 'manual',
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('ProductsPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
expect(screen.getByText('Product Library')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All categories')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders products when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Tyson')).toBeInTheDocument();
|
||||
expect(screen.getByText('165 kcal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no products', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No products yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on fetch failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by search input with debounce', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search products...');
|
||||
fireEvent.change(searchInput, { target: { value: 'chicken' } });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockListProducts).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ q: 'chicken' }),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by category dropdown', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(mockListProducts).toHaveBeenCalledTimes(1));
|
||||
|
||||
const categorySelect = screen.getByRole('combobox');
|
||||
fireEvent.change(categorySelect, { target: { value: 'meat' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListProducts).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ category: 'meat' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes product on confirm', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteProduct.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteProduct).toHaveBeenCalledWith('hh1', 'p1');
|
||||
});
|
||||
expect(screen.queryByText('Chicken Breast')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
expect(mockDeleteProduct).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Chicken Breast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when delete fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteProduct.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
const deleteBtn = screen.getByRole('button', { name: /delete product/i });
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows macro breakdown in product card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListProducts.mockResolvedValue({
|
||||
data: [SAMPLE_PRODUCT],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<ProductsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Chicken Breast')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByText('P: 31g')).toBeInTheDocument();
|
||||
expect(screen.getByText('C: 0g')).toBeInTheDocument();
|
||||
expect(screen.getByText('F: 3.6g')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
72
packages/web/src/app/(dashboard)/products/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/products/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { ProductList } from './ProductList';
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 80,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
marginBottom: 12,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHousehold() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
You need to create or join a household before managing your product library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Product Library" subtitle="Manage your food product catalog" />
|
||||
<ProductList householdId={householdId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
589
packages/web/src/app/(dashboard)/recipes/RecipeEditor.tsx
Normal file
589
packages/web/src/app/(dashboard)/recipes/RecipeEditor.tsx
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createRecipe, updateRecipe } from '@/services/recipes';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RecipeResponseSchema, CreateRecipeInput } from '@meshitrack/shared';
|
||||
|
||||
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||
type IngredientUnit = CreateRecipeInput['ingredients'][number]['unit'];
|
||||
|
||||
type IngredientInput = {
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unit: IngredientUnit;
|
||||
preparation: string;
|
||||
isOptional: boolean;
|
||||
};
|
||||
|
||||
type StepInput = {
|
||||
order: number;
|
||||
instruction: string;
|
||||
duration: string;
|
||||
tip: string;
|
||||
};
|
||||
|
||||
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||
[NutritionWarning.HIGH_CALORIES]: 'High calories',
|
||||
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
|
||||
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
|
||||
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
|
||||
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
|
||||
[NutritionWarning.LOW_FIBER]: 'Low fiber',
|
||||
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol',
|
||||
};
|
||||
|
||||
const UNIT_OPTIONS = ['g', 'ml', 'piece', 'slice', 'oz', 'lb', 'cup', 'tbsp', 'tsp', 'fl_oz'];
|
||||
|
||||
function emptyIngredient(): IngredientInput {
|
||||
return {
|
||||
productId: '',
|
||||
productName: '',
|
||||
quantity: 100,
|
||||
unit: 'g',
|
||||
preparation: '',
|
||||
isOptional: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStep(order: number): StepInput {
|
||||
return { order, instruction: '', duration: '', tip: '' };
|
||||
}
|
||||
|
||||
function ingredientFromRecipe(ing: Recipe['ingredients'][0]): IngredientInput {
|
||||
return {
|
||||
productId: ing.productId,
|
||||
productName: ing.productName,
|
||||
quantity: ing.originalQuantity ?? ing.quantity,
|
||||
unit: ing.originalUnit ?? ing.unit,
|
||||
preparation: ing.preparation ?? '',
|
||||
isOptional: ing.isOptional,
|
||||
};
|
||||
}
|
||||
|
||||
function stepFromRecipe(step: Recipe['steps'][0]): StepInput {
|
||||
return {
|
||||
order: step.order,
|
||||
instruction: step.instruction,
|
||||
duration: step.duration ? String(step.duration) : '',
|
||||
tip: step.tip ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function NutritionDisplay({
|
||||
nutrition,
|
||||
warnings,
|
||||
}: {
|
||||
nutrition: Recipe['perServingNutrition'] | null;
|
||||
warnings: string[];
|
||||
}) {
|
||||
if (!nutrition) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
Per serving (estimated)
|
||||
</h3>
|
||||
<div
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 13 }}
|
||||
>
|
||||
<span style={{ color: 'var(--ink-muted)' }}>Calories</span>
|
||||
<span style={{ fontWeight: 600 }}>{Math.round(nutrition.calories)} kcal</span>
|
||||
<span style={{ color: 'var(--ink-muted)' }}>Protein</span>
|
||||
<span>{nutrition.protein.toFixed(1)}g</span>
|
||||
<span style={{ color: 'var(--ink-muted)' }}>Carbs</span>
|
||||
<span>{nutrition.carbs.toFixed(1)}g</span>
|
||||
<span style={{ color: 'var(--ink-muted)' }}>Fat</span>
|
||||
<span>{nutrition.fat.toFixed(1)}g</span>
|
||||
</div>
|
||||
{warnings.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{warnings.map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--danger)',
|
||||
padding: '2px 0',
|
||||
}}
|
||||
>
|
||||
{WARNING_LABELS[w] ?? w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecipeEditorProps {
|
||||
householdId: string;
|
||||
existing?: Recipe;
|
||||
}
|
||||
|
||||
export function RecipeEditor({ householdId, existing }: RecipeEditorProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState(existing?.name ?? '');
|
||||
const [description, setDescription] = useState(existing?.description ?? '');
|
||||
const [servings, setServings] = useState(existing?.servings ?? 4);
|
||||
const [prepTime, setPrepTime] = useState(existing?.prepTime ? String(existing.prepTime) : '');
|
||||
const [cookTime, setCookTime] = useState(existing?.cookTime ? String(existing.cookTime) : '');
|
||||
const [cuisine, setCuisine] = useState(existing?.cuisine ?? '');
|
||||
const [tags, setTags] = useState(existing?.tags.join(', ') ?? '');
|
||||
const [isFavorite, setIsFavorite] = useState(existing?.isFavorite ?? false);
|
||||
const [ingredients, setIngredients] = useState<IngredientInput[]>(
|
||||
existing ? existing.ingredients.map(ingredientFromRecipe) : [emptyIngredient()],
|
||||
);
|
||||
const [steps, setSteps] = useState<StepInput[]>(
|
||||
existing ? existing.steps.map(stepFromRecipe) : [emptyStep(1)],
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function updateIngredient(
|
||||
i: number,
|
||||
field: keyof IngredientInput,
|
||||
value: IngredientInput[keyof IngredientInput],
|
||||
) {
|
||||
setIngredients((prev) =>
|
||||
prev.map((ing, idx) => (idx === i ? { ...ing, [field]: value } : ing)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeIngredient(i: number) {
|
||||
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
setIngredients((prev) => [...prev, emptyIngredient()]);
|
||||
}
|
||||
|
||||
function updateStep(i: number, field: keyof StepInput, value: string) {
|
||||
setSteps((prev) => prev.map((s, idx) => (idx === i ? { ...s, [field]: value } : s)));
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
setSteps((prev) => [...prev, emptyStep(prev.length + 1)]);
|
||||
}
|
||||
|
||||
function removeStep(i: number) {
|
||||
setSteps((prev) =>
|
||||
prev.filter((_, idx) => idx !== i).map((s, idx) => ({ ...s, order: idx + 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSaving(true);
|
||||
|
||||
const tagList = tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
servings,
|
||||
prepTime: prepTime ? Number(prepTime) : undefined,
|
||||
cookTime: cookTime ? Number(cookTime) : undefined,
|
||||
cuisine: cuisine.trim() || undefined,
|
||||
tags: tagList,
|
||||
isFavorite,
|
||||
ingredients: ingredients.map((ing) => ({
|
||||
productId: ing.productId.trim(),
|
||||
productName: ing.productName.trim(),
|
||||
quantity: Number(ing.quantity),
|
||||
unit: ing.unit,
|
||||
preparation: ing.preparation.trim() || undefined,
|
||||
isOptional: ing.isOptional,
|
||||
})),
|
||||
steps: steps
|
||||
.filter((s) => s.instruction.trim())
|
||||
.map((s, i) => ({
|
||||
order: i + 1,
|
||||
instruction: s.instruction.trim(),
|
||||
duration: s.duration ? Number(s.duration) : undefined,
|
||||
tip: s.tip.trim() || undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
await updateRecipe(householdId, existing._id, payload);
|
||||
router.push(`/recipes/${existing._id}`);
|
||||
} else {
|
||||
const created = await createRecipe(householdId, payload);
|
||||
router.push(`/recipes/${created._id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save recipe');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 32, maxWidth: 1100 }}
|
||||
>
|
||||
{/* Main form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 14, margin: 0 }}>{error}</p>}
|
||||
|
||||
{/* Basics */}
|
||||
<div>
|
||||
<label style={labelStyle}>Name *</label>
|
||||
<input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Recipe name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
placeholder="Brief description..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Servings *</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={servings}
|
||||
onChange={(e) => setServings(Number(e.target.value))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Prep time (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={prepTime}
|
||||
onChange={(e) => setPrepTime(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Cook time (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cookTime}
|
||||
onChange={(e) => setCookTime(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Cuisine</label>
|
||||
<input
|
||||
value={cuisine}
|
||||
onChange={(e) => setCuisine(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Italian, Japanese..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Tags (comma-separated)</label>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="vegetarian, quick..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFavorite}
|
||||
onChange={(e) => setIsFavorite(e.target.checked)}
|
||||
/>
|
||||
Mark as favorite
|
||||
</label>
|
||||
|
||||
{/* Ingredients */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{ingredients.map((ing, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 80px 90px 1fr auto',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Product name"
|
||||
value={ing.productName}
|
||||
onChange={(e) => {
|
||||
updateIngredient(i, 'productName', e.target.value);
|
||||
updateIngredient(
|
||||
i,
|
||||
'productId',
|
||||
e.target.value.toLowerCase().replace(/\s+/g, '-'),
|
||||
);
|
||||
}}
|
||||
style={{ ...inputStyle, fontSize: 13 }}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Qty"
|
||||
value={ing.quantity}
|
||||
min={0}
|
||||
onChange={(e) => updateIngredient(i, 'quantity', e.target.value)}
|
||||
style={{ ...inputStyle, fontSize: 13 }}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={ing.unit}
|
||||
onChange={(e) => updateIngredient(i, 'unit', e.target.value as IngredientUnit)}
|
||||
style={{ ...inputStyle, fontSize: 13 }}
|
||||
>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
placeholder="Preparation (optional)"
|
||||
value={ing.preparation}
|
||||
onChange={(e) => updateIngredient(i, 'preparation', e.target.value)}
|
||||
style={{ ...inputStyle, fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIngredient(i)}
|
||||
disabled={ingredients.length === 1}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--ink-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addIngredient}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontSize: 13,
|
||||
color: 'var(--brand)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
+ Add ingredient
|
||||
</button>
|
||||
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 6 }}>
|
||||
Volume/imperial units (cup, tbsp, etc.) are automatically converted to metric before
|
||||
saving. A product must be linked for nutrition to calculate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{steps.map((step, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
minWidth: 20,
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
{i + 1}.
|
||||
</span>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<textarea
|
||||
value={step.instruction}
|
||||
onChange={(e) => updateStep(i, 'instruction', e.target.value)}
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical', fontSize: 13 }}
|
||||
placeholder="Instruction..."
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Duration (min)"
|
||||
value={step.duration}
|
||||
min={0}
|
||||
onChange={(e) => updateStep(i, 'duration', e.target.value)}
|
||||
style={{ ...inputStyle, fontSize: 12 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="Tip (optional)"
|
||||
value={step.tip}
|
||||
onChange={(e) => updateStep(i, 'tip', e.target.value)}
|
||||
style={{ ...inputStyle, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(i)}
|
||||
disabled={steps.length === 1}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--ink-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 18,
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStep}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontSize: 13,
|
||||
color: 'var(--brand)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
+ Add step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div style={{ display: 'flex', gap: 12, paddingTop: 8 }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--r-md)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
opacity: saving ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : existing ? 'Save changes' : 'Create recipe'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: 'none',
|
||||
color: 'var(--ink-muted)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nutrition sidebar */}
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<NutritionDisplay
|
||||
nutrition={existing?.perServingNutrition ?? null}
|
||||
warnings={existing?.warnings ?? []}
|
||||
/>
|
||||
<p style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 10, lineHeight: 1.5 }}>
|
||||
Nutrition is calculated server-side when you save. Link ingredients to products in the
|
||||
product library for accurate data.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
331
packages/web/src/app/(dashboard)/recipes/RecipeList.tsx
Normal file
331
packages/web/src/app/(dashboard)/recipes/RecipeList.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { listRecipes, deleteRecipe } from '@/services/recipes';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||
|
||||
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||
[NutritionWarning.HIGH_CALORIES]: 'High cal',
|
||||
[NutritionWarning.HIGH_SODIUM]: 'High sodium',
|
||||
[NutritionWarning.HIGH_SUGAR]: 'High sugar',
|
||||
[NutritionWarning.HIGH_SATURATED_FAT]: 'High sat fat',
|
||||
[NutritionWarning.LOW_PROTEIN]: 'Low protein',
|
||||
[NutritionWarning.LOW_FIBER]: 'Low fiber',
|
||||
[NutritionWarning.HIGH_CHOLESTEROL]: 'High chol',
|
||||
};
|
||||
|
||||
function formatTime(minutes?: number): string {
|
||||
if (!minutes) return '';
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function RecipeList({ householdId }: { householdId: string }) {
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterCuisine, setFilterCuisine] = useState('');
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchRecipes = useCallback(async () => {
|
||||
if (!householdId) return;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listRecipes(householdId, {
|
||||
q: debouncedSearch || undefined,
|
||||
cuisine: filterCuisine || undefined,
|
||||
isFavorite: favoritesOnly || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
setRecipes(result.data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId, debouncedSearch, filterCuisine, favoritesOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecipes();
|
||||
}, [fetchRecipes]);
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}"?`)) return;
|
||||
try {
|
||||
await deleteRecipe(householdId, id);
|
||||
setRecipes((prev) => prev.filter((r) => r._id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete recipe');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1200 }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search recipes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 240px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cuisine..."
|
||||
value={filterCuisine}
|
||||
onChange={(e) => setFilterCuisine(e.target.value)}
|
||||
style={{
|
||||
width: 160,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={favoritesOnly}
|
||||
onChange={(e) => setFavoritesOnly(e.target.checked)}
|
||||
/>
|
||||
Favorites
|
||||
</label>
|
||||
<Link
|
||||
href="/recipes/new"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
borderRadius: 'var(--r-md)',
|
||||
fontSize: 14,
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
+ New Recipe
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 160,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : recipes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{search || filterCuisine || favoritesOnly
|
||||
? 'No recipes match your filters.'
|
||||
: 'No recipes yet. Create your first recipe.'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{recipes.map((recipe) => (
|
||||
<RecipeCard key={recipe._id} recipe={recipe} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipeCard({
|
||||
recipe,
|
||||
onDelete,
|
||||
}: {
|
||||
recipe: Recipe;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
}) {
|
||||
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Link
|
||||
href={`/recipes/${recipe._id}`}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink)',
|
||||
textDecoration: 'none',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{recipe.isFavorite && <span style={{ marginRight: 4 }}>★</span>}
|
||||
{recipe.name}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recipe.cuisine && (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-muted)' }}>{recipe.cuisine}</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{recipe.servings} serving{recipe.servings !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{time > 0 && <span>{formatTime(time)}</span>}
|
||||
<span style={{ color: 'var(--ink)', fontWeight: 500 }}>
|
||||
{Math.round(recipe.perServingNutrition.calories)} kcal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{recipe.warnings.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{recipe.warnings.slice(0, 3).map((w) => (
|
||||
<span
|
||||
key={w}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--danger-soft, #fee)',
|
||||
color: 'var(--danger)',
|
||||
}}
|
||||
>
|
||||
{WARNING_LABELS[w] ?? w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{recipe.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'var(--bg-subtle, var(--bg))',
|
||||
color: 'var(--ink-muted)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Link
|
||||
href={`/recipes/${recipe._id}/edit`}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--brand)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(recipe._id, recipe.name)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetRecipe, mockScaleRecipe } = vi.hoisted(() => ({
|
||||
mockGetRecipe: vi.fn(),
|
||||
mockScaleRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
getRecipe: mockGetRecipe,
|
||||
scaleRecipe: mockScaleRecipe,
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import RecipeDetailPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
description: 'Classic Italian pasta',
|
||||
servings: 4,
|
||||
prepTime: 15,
|
||||
cookTime: 30,
|
||||
totalTime: 45,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 400,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
{
|
||||
productId: 'p2',
|
||||
productName: 'Parmesan',
|
||||
quantity: 50,
|
||||
unit: 'g',
|
||||
isOptional: true,
|
||||
preparation: 'grated',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ order: 1, instruction: 'Boil water', duration: 10 },
|
||||
{ order: 2, instruction: 'Cook pasta', tip: 'Al dente' },
|
||||
],
|
||||
perServingNutrition: {
|
||||
calories: 450,
|
||||
protein: 25,
|
||||
carbs: 55,
|
||||
fat: 12,
|
||||
fiber: 3,
|
||||
sugar: 5,
|
||||
sodium: 400,
|
||||
saturatedFat: 4,
|
||||
cholesterol: 50,
|
||||
},
|
||||
totalNutrition: {
|
||||
calories: 1800,
|
||||
protein: 100,
|
||||
carbs: 220,
|
||||
fat: 48,
|
||||
},
|
||||
warnings: ['high_calories'],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RecipeDetailPage', () => {
|
||||
it('shows loading skeleton', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
expect(screen.getByText('Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows recipe not found fallback', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
// Resolve with null-like to trigger error path
|
||||
mockGetRecipe.mockRejectedValue(new Error('Recipe not found'));
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recipe not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders recipe details', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Classic Italian pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starred')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows ingredients with optional indicator', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parmesan')).toBeInTheDocument();
|
||||
expect(screen.getByText('(optional)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows steps with duration and tips', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Boil water')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cook pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tip: Al dente')).toBeInTheDocument();
|
||||
expect(screen.getByText('10 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutritional warnings', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nutritional alerts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutrition panel', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||
expect(screen.getByText('25.0g')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error fetch failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue('string error');
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders recipe without description', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue({
|
||||
...SAMPLE_RECIPE,
|
||||
description: undefined,
|
||||
warnings: [],
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
render(<RecipeDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Starred')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetRecipe } = vi.hoisted(() => ({
|
||||
mockGetRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
getRecipe: mockGetRecipe,
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import EditRecipePage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
name: 'Pasta',
|
||||
description: '',
|
||||
servings: 4,
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
ingredients: [
|
||||
{ productId: 'p1', productName: 'Flour', quantity: 200, unit: 'g', isOptional: false },
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Mix' }],
|
||||
perServingNutrition: { calories: 200, protein: 5, carbs: 30, fat: 4 },
|
||||
totalNutrition: { calories: 800, protein: 20, carbs: 120, fat: 16 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
describe('EditRecipePage', () => {
|
||||
it('shows loading state', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
expect(screen.getByText('Edit Recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders editor when recipe loads', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockResolvedValue(SAMPLE_RECIPE);
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback when recipe is not found', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockGetRecipe.mockRejectedValue('unexpected');
|
||||
|
||||
render(<EditRecipePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
61
packages/web/src/app/(dashboard)/recipes/[id]/edit/page.tsx
Normal file
61
packages/web/src/app/(dashboard)/recipes/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { RecipeEditor } from '../../RecipeEditor';
|
||||
import { getRecipe } from '@/services/recipes';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||
|
||||
export default function EditRecipePage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!householdId || !params.id) return;
|
||||
getRecipe(householdId, params.id)
|
||||
.then(setRecipe)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [householdId, params.id]);
|
||||
|
||||
const title = recipe?.name ? `Edit: ${recipe.name}` : 'Edit Recipe';
|
||||
|
||||
if (sessionLoading || loading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
Loading...
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Edit Recipe" crumbs={['Recipes', 'Edit']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
|
||||
{error || 'Recipe not found'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title={title} crumbs={['Recipes', recipe.name, 'Edit']} />
|
||||
<div style={{ padding: '28px 32px 56px' }}>
|
||||
<RecipeEditor householdId={householdId!} existing={recipe} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
420
packages/web/src/app/(dashboard)/recipes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { getRecipe, scaleRecipe } from '@/services/recipes';
|
||||
import { NutritionWarning } from '@meshitrack/shared';
|
||||
import type { z } from 'zod/v4';
|
||||
import type { RecipeResponseSchema } from '@meshitrack/shared';
|
||||
|
||||
type Recipe = z.infer<typeof RecipeResponseSchema>;
|
||||
|
||||
const WARNING_LABELS: Partial<Record<string, string>> = {
|
||||
[NutritionWarning.HIGH_CALORIES]: 'High calories (>800 kcal/serving)',
|
||||
[NutritionWarning.HIGH_SODIUM]: 'High sodium (>1500mg/serving)',
|
||||
[NutritionWarning.HIGH_SUGAR]: 'High sugar (>25g/serving)',
|
||||
[NutritionWarning.HIGH_SATURATED_FAT]: 'High saturated fat (>13g/serving)',
|
||||
[NutritionWarning.LOW_PROTEIN]: 'Low protein (<10g/serving)',
|
||||
[NutritionWarning.LOW_FIBER]: 'Low fiber (<3g/serving)',
|
||||
[NutritionWarning.HIGH_CHOLESTEROL]: 'High cholesterol (>200mg/serving)',
|
||||
};
|
||||
|
||||
function formatTime(minutes?: number): string {
|
||||
if (!minutes) return '';
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
|
||||
function NutritionPanel({
|
||||
nutrition,
|
||||
label,
|
||||
}: {
|
||||
nutrition: Recipe['perServingNutrition'];
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px' }}>
|
||||
<NutrRow label="Calories" value={`${Math.round(nutrition.calories)} kcal`} bold />
|
||||
<NutrRow label="Protein" value={`${nutrition.protein.toFixed(1)}g`} />
|
||||
<NutrRow label="Carbs" value={`${nutrition.carbs.toFixed(1)}g`} />
|
||||
<NutrRow label="Fat" value={`${nutrition.fat.toFixed(1)}g`} />
|
||||
{nutrition.fiber !== undefined && (
|
||||
<NutrRow label="Fiber" value={`${nutrition.fiber.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.sugar !== undefined && (
|
||||
<NutrRow label="Sugar" value={`${nutrition.sugar.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.sodium !== undefined && (
|
||||
<NutrRow label="Sodium" value={`${Math.round(nutrition.sodium)}mg`} />
|
||||
)}
|
||||
{nutrition.saturatedFat !== undefined && (
|
||||
<NutrRow label="Sat. fat" value={`${nutrition.saturatedFat.toFixed(1)}g`} />
|
||||
)}
|
||||
{nutrition.cholesterol !== undefined && (
|
||||
<NutrRow label="Cholesterol" value={`${Math.round(nutrition.cholesterol)}mg`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NutrRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>{label}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink)', fontWeight: bold ? 600 : 400 }}>
|
||||
{value}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipeDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { householdId, isLoading: sessionLoading } = useApi();
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [scaledServings, setScaledServings] = useState<number | null>(null);
|
||||
const [scaledRecipe, setScaledRecipe] = useState<Recipe | null>(null);
|
||||
const [scaling, setScaling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!householdId || !params.id) return;
|
||||
setLoading(true);
|
||||
getRecipe(householdId, params.id)
|
||||
.then((r) => {
|
||||
setRecipe(r);
|
||||
setScaledServings(r.servings);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load recipe'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [householdId, params.id]);
|
||||
|
||||
async function handleScale() {
|
||||
if (!householdId || !recipe || !scaledServings) return;
|
||||
if (scaledServings === recipe.servings) {
|
||||
setScaledRecipe(null);
|
||||
return;
|
||||
}
|
||||
setScaling(true);
|
||||
try {
|
||||
const result = await scaleRecipe(householdId, recipe._id, { targetServings: scaledServings });
|
||||
setScaledRecipe(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to scale recipe');
|
||||
} finally {
|
||||
setScaling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionLoading || loading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
height: 40,
|
||||
width: 200,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 8,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipe" crumbs={['Recipes']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--danger)', fontSize: 14 }}>
|
||||
{error || 'Recipe not found'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const displayRecipe = scaledRecipe ?? recipe;
|
||||
const time = recipe.totalTime ?? (recipe.prepTime ?? 0) + (recipe.cookTime ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader
|
||||
title={recipe.name}
|
||||
subtitle={recipe.cuisine ?? undefined}
|
||||
crumbs={['Recipes', recipe.name]}
|
||||
/>
|
||||
<div style={{ padding: '28px 32px 56px', maxWidth: 1100 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 32 }}>
|
||||
{/* Main content */}
|
||||
<div>
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>
|
||||
{recipe.servings} servings
|
||||
</span>
|
||||
{time > 0 && (
|
||||
<span style={{ fontSize: 14, color: 'var(--ink-muted)' }}>{formatTime(time)}</span>
|
||||
)}
|
||||
{recipe.isFavorite && (
|
||||
<span style={{ fontSize: 14, color: 'var(--brand)' }}>Starred</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/recipes/${recipe._id}/edit`}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 13,
|
||||
color: 'var(--brand)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recipe.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 24,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{recipe.warnings.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--danger-soft, #fee)',
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: '12px 16px',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--danger)' }}
|
||||
>
|
||||
Nutritional alerts
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{recipe.warnings.map((w) => (
|
||||
<li key={w} style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 2 }}>
|
||||
{WARNING_LABELS[w] ?? w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Ingredients</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{displayRecipe.ingredients.map((ing, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{ing.isOptional && (
|
||||
<span style={{ color: 'var(--ink-muted)', fontSize: 12 }}>(optional) </span>
|
||||
)}
|
||||
{ing.productName}
|
||||
{ing.preparation && (
|
||||
<span style={{ color: 'var(--ink-muted)' }}>, {ing.preparation}</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--ink-muted)', marginLeft: 16 }}>
|
||||
{ing.originalQuantity != null && ing.originalUnit
|
||||
? `${ing.originalQuantity} ${ing.originalUnit}`
|
||||
: `${ing.quantity} ${ing.unit}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Steps */}
|
||||
{recipe.steps.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Instructions</h2>
|
||||
<ol style={{ paddingLeft: 20, margin: 0 }}>
|
||||
{recipe.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => (
|
||||
<li key={step.order} style={{ marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
||||
{step.instruction}
|
||||
</p>
|
||||
{step.duration && (
|
||||
<p style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 4 }}>
|
||||
{formatTime(step.duration)}
|
||||
</p>
|
||||
)}
|
||||
{step.tip && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--brand)',
|
||||
marginTop: 4,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Tip: {step.tip}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div>
|
||||
{/* Scale */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>Scale Recipe</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={scaledServings ?? recipe.servings}
|
||||
onChange={(e) => setScaledServings(Number(e.target.value))}
|
||||
style={{
|
||||
width: 70,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--ink-muted)' }}>servings</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScale}
|
||||
disabled={scaling}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'var(--brand)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
opacity: scaling ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{scaling ? '...' : 'Scale'}
|
||||
</button>
|
||||
</div>
|
||||
{scaledRecipe && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScaledRecipe(null);
|
||||
setScaledServings(recipe.servings);
|
||||
}}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: 'var(--ink-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Reset to original
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nutrition */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<NutritionPanel
|
||||
nutrition={displayRecipe.perServingNutrition}
|
||||
label={`Per serving (${displayRecipe.servings} total)`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<NutritionPanel
|
||||
nutrition={displayRecipe.totalNutrition}
|
||||
label="Total (all servings)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockCreateRecipe, mockUpdateRecipe } = vi.hoisted(() => ({
|
||||
mockCreateRecipe: vi.fn(),
|
||||
mockUpdateRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockPush, mockBack } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockBack: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
createRecipe: mockCreateRecipe,
|
||||
updateRecipe: mockUpdateRecipe,
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush, back: mockBack }),
|
||||
useParams: () => ({ id: 'r1' }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import { RecipeEditor } from '../RecipeEditor';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('RecipeEditor', () => {
|
||||
it('renders create form by default', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ingredients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Instructions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit form when existing recipe provided', () => {
|
||||
const existing = {
|
||||
_id: 'r1',
|
||||
name: 'Pasta',
|
||||
description: 'Good pasta',
|
||||
servings: 2,
|
||||
prepTime: 10,
|
||||
cookTime: 20,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta', 'quick'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 200,
|
||||
unit: 'g',
|
||||
originalQuantity: 200,
|
||||
originalUnit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Boil water', duration: 5, tip: 'Use salted water' }],
|
||||
perServingNutrition: { calories: 300, protein: 10, carbs: 40, fat: 8 },
|
||||
totalNutrition: { calories: 600, protein: 20, carbs: 80, fat: 16 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Good pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits create form', async () => {
|
||||
mockCreateRecipe.mockResolvedValue({ _id: 'new1' });
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'New Recipe' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Flour' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRecipe).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ name: 'New Recipe' }),
|
||||
);
|
||||
expect(mockPush).toHaveBeenCalledWith('/recipes/new1');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits update form', async () => {
|
||||
mockUpdateRecipe.mockResolvedValue({ _id: 'r1' });
|
||||
const existing = {
|
||||
_id: 'r1',
|
||||
name: 'Old Name',
|
||||
description: '',
|
||||
servings: 4,
|
||||
tags: [],
|
||||
isFavorite: false,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Test',
|
||||
quantity: 100,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Do thing' }],
|
||||
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 3 },
|
||||
totalNutrition: { calories: 400, protein: 20, carbs: 40, fat: 12 },
|
||||
warnings: [],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
householdId: 'hh1',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<RecipeEditor householdId="hh1" existing={existing as any} />);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Old Name'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Save changes'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRecipe).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
'r1',
|
||||
expect.objectContaining({ name: 'New Name' }),
|
||||
);
|
||||
expect(mockPush).toHaveBeenCalledWith('/recipes/r1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error when create fails', async () => {
|
||||
mockCreateRecipe.mockRejectedValue(new Error('Server error'));
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Item' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows fallback error for non-Error failure', async () => {
|
||||
mockCreateRecipe.mockRejectedValue('unexpected');
|
||||
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Recipe name'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Product name'), {
|
||||
target: { value: 'Item' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Create recipe'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to save recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can add and remove ingredients', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add ingredient'));
|
||||
|
||||
const nameInputs = screen.getAllByPlaceholderText('Product name');
|
||||
expect(nameInputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('can add and remove steps', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add step'));
|
||||
|
||||
const stepInputs = screen.getAllByPlaceholderText('Instruction...');
|
||||
expect(stepInputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('cancel button calls router.back', () => {
|
||||
render(<RecipeEditor householdId="hh1" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(mockBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
423
packages/web/src/app/(dashboard)/recipes/__tests__/page.test.tsx
Normal file
423
packages/web/src/app/(dashboard)/recipes/__tests__/page.test.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockListRecipes, mockDeleteRecipe } = vi.hoisted(() => ({
|
||||
mockListRecipes: vi.fn(),
|
||||
mockDeleteRecipe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
listRecipes: mockListRecipes,
|
||||
deleteRecipe: mockDeleteRecipe,
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import RecipesPage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const SAMPLE_RECIPE = {
|
||||
_id: 'r1',
|
||||
householdId: 'hh1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
description: 'Classic Italian pasta',
|
||||
servings: 4,
|
||||
prepTime: 15,
|
||||
cookTime: 30,
|
||||
totalTime: 45,
|
||||
cuisine: 'Italian',
|
||||
tags: ['pasta', 'comfort'],
|
||||
isFavorite: true,
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'p1',
|
||||
productName: 'Spaghetti',
|
||||
quantity: 400,
|
||||
unit: 'g',
|
||||
isOptional: false,
|
||||
},
|
||||
],
|
||||
steps: [{ order: 1, instruction: 'Boil water' }],
|
||||
perServingNutrition: {
|
||||
calories: 450,
|
||||
protein: 25,
|
||||
carbs: 55,
|
||||
fat: 12,
|
||||
fiber: 3,
|
||||
sugar: 5,
|
||||
sodium: 400,
|
||||
},
|
||||
totalNutrition: {
|
||||
calories: 1800,
|
||||
protein: 100,
|
||||
carbs: 220,
|
||||
fat: 48,
|
||||
},
|
||||
warnings: ['high_calories'],
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('RecipesPage', () => {
|
||||
it('shows loading skeleton when session is loading', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Search recipes...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders recipe list when household exists', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no recipes', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No recipes yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows nutrition info on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('450 kcal')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 servings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows time on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warning labels', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('High cal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tags on recipe card', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('comfort')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows favorite star', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Star character is rendered for favorites
|
||||
expect(screen.getByText('Spaghetti Bolognese').closest('a')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete with confirmation', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockResolvedValue(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteRecipe).toHaveBeenCalledWith('hh1', 'r1');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete when confirm is cancelled', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(mockDeleteRecipe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when fetch fails', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filter empty state message', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Cuisine...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Cuisine...'), {
|
||||
target: { value: 'Thai' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No recipes match your filters/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows recipe without totalTime using prep+cook', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: undefined }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('formats hours correctly', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: 90 }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1h 30m')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('formats exact hours', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, totalTime: 120 }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2h')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete error', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockRejectedValue(new Error('Delete failed'));
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-Error delete failure', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [SAMPLE_RECIPE],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
mockDeleteRecipe.mockRejectedValue('string error');
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to delete recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows + New Recipe link', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('recipe with no warnings renders without warning badges', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [{ ...SAMPLE_RECIPE, warnings: [], tags: [] }],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.queryByText('High cal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles favorites checkbox', async () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
mockListRecipes.mockResolvedValue({
|
||||
data: [],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
render(<RecipesPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Favorites')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Favorites'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListRecipes).toHaveBeenCalledWith(
|
||||
'hh1',
|
||||
expect.objectContaining({ isFavorite: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
vi.mock('@/services/recipes', () => ({
|
||||
createRecipe: vi.fn(),
|
||||
updateRecipe: vi.fn(),
|
||||
listRecipes: vi.fn(),
|
||||
deleteRecipe: vi.fn(),
|
||||
getRecipe: vi.fn(),
|
||||
scaleRecipe: vi.fn(),
|
||||
importRecipeFromText: vi.fn(),
|
||||
importRecipeFromUrl: vi.fn(),
|
||||
listRecipesByProduct: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
import NewRecipePage from '../page';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NewRecipePage', () => {
|
||||
it('shows loading state', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByText('New Recipe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows household prompt when no householdId', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders editor when household exists', () => {
|
||||
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
||||
|
||||
render(<NewRecipePage />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recipe name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create recipe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
40
packages/web/src/app/(dashboard)/recipes/new/page.tsx
Normal file
40
packages/web/src/app/(dashboard)/recipes/new/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { RecipeEditor } from '../RecipeEditor';
|
||||
|
||||
export default function NewRecipePage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
Loading...
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||
<div style={{ padding: '28px 32px', color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
You need to create or join a household first.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="New Recipe" crumbs={['Recipes', 'New']} />
|
||||
<div style={{ padding: '28px 32px 56px' }}>
|
||||
<RecipeEditor householdId={householdId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
packages/web/src/app/(dashboard)/recipes/page.tsx
Normal file
72
packages/web/src/app/(dashboard)/recipes/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { RecipeList } from './RecipeList';
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 80,
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
marginBottom: 12,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHousehold() {
|
||||
return (
|
||||
<div style={{ padding: '28px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
|
||||
You need to create or join a household before managing recipes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipesPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||
<PageSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!householdId) {
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||
<NoHousehold />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Recipes" subtitle="Your recipe collection" />
|
||||
<RecipeList householdId={householdId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
84
packages/web/src/components/__tests__/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const { mockUsePathname } = vi.hoisted(() => ({
|
||||
mockUsePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockUseApi } = vi.hoisted(() => ({
|
||||
mockUseApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: mockUsePathname,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any) => <a href={props.href}>{props.children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
||||
|
||||
import { Sidebar } from '../layout/Sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/dashboard');
|
||||
mockUseApi.mockReturnValue({ profile: { displayName: 'Alice' } });
|
||||
});
|
||||
|
||||
it('renders brand name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('MeshiTrack')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation sections', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medicines')).toBeInTheDocument();
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders medicines nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Cabinet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule & Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regimens')).toBeInTheDocument();
|
||||
expect(screen.getByText('Library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders food nav items', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights active route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet');
|
||||
render(<Sidebar />);
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toHaveAttribute('href', '/medicines/cabinet');
|
||||
});
|
||||
|
||||
it('shows user avatar with display name', () => {
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fallback name when no profile', () => {
|
||||
mockUseApi.mockReturnValue({ profile: null });
|
||||
render(<Sidebar />);
|
||||
expect(screen.getByLabelText('User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights nested route', () => {
|
||||
mockUsePathname.mockReturnValue('/medicines/cabinet/some-id');
|
||||
render(<Sidebar />);
|
||||
// Cabinet link should still match via startsWith
|
||||
const cabinetLink = screen.getByText('Cabinet').closest('a');
|
||||
expect(cabinetLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
125
packages/web/src/components/__tests__/ThemeProvider.test.tsx
Normal file
125
packages/web/src/components/__tests__/ThemeProvider.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ThemeProvider, useTheme } from '../ThemeProvider';
|
||||
import { Providers } from '../Providers';
|
||||
|
||||
// Mock next-auth SessionProvider
|
||||
vi.mock('next-auth/react', () => ({
|
||||
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
function ThemeConsumer() {
|
||||
const { theme, accent, setTheme, setAccent, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="accent">{accent}</span>
|
||||
<button onClick={() => setTheme('dark')}>Set dark</button>
|
||||
<button onClick={() => setAccent('cobalt')}>Set cobalt</button>
|
||||
<button onClick={toggleTheme}>Toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.cssText = '';
|
||||
});
|
||||
|
||||
it('provides default light/sage theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('sage');
|
||||
});
|
||||
|
||||
it('hydrates from localStorage', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
localStorage.setItem('mt-accent', 'terracotta');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('accent').textContent).toBe('terracotta');
|
||||
});
|
||||
|
||||
it('setTheme updates theme and persists', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set dark')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(localStorage.getItem('mt-theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('setAccent updates accent and applies CSS vars', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Set cobalt')));
|
||||
|
||||
expect(screen.getByTestId('accent').textContent).toBe('cobalt');
|
||||
expect(localStorage.getItem('mt-accent')).toBe('cobalt');
|
||||
expect(document.documentElement.style.getPropertyValue('--brand')).toBe('#2e5aa8');
|
||||
});
|
||||
|
||||
it('toggleTheme flips light to dark', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('toggleTheme flips dark to light', () => {
|
||||
localStorage.setItem('mt-theme', 'dark');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeConsumer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
act(() => fireEvent.click(screen.getByText('Toggle')));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('useTheme throws outside provider', () => {
|
||||
expect(() => render(<ThemeConsumer />)).toThrow('useTheme must be used inside ThemeProvider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Providers', () => {
|
||||
it('renders children with session and theme providers', () => {
|
||||
render(
|
||||
<Providers>
|
||||
<span>child content</span>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
packages/web/src/components/__tests__/TopBar.test.tsx
Normal file
42
packages/web/src/components/__tests__/TopBar.test.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/components/ThemeProvider', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/PageHeaderContext', () => ({
|
||||
usePageHeader: () => ({
|
||||
header: {
|
||||
title: 'Test Page',
|
||||
subtitle: 'Subtitle here',
|
||||
crumbs: ['Home', 'Test Page'],
|
||||
actions: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { TopBar } from '../layout/TopBar';
|
||||
|
||||
describe('TopBar', () => {
|
||||
it('renders title and subtitle', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Page');
|
||||
expect(screen.getByText('Subtitle here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByPlaceholderText(/Search medicines/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button', () => {
|
||||
render(<TopBar />);
|
||||
expect(screen.getByLabelText('Toggle theme')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
84
packages/web/src/components/__tests__/ui.test.tsx
Normal file
84
packages/web/src/components/__tests__/ui.test.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { Ring } from '../ui/Ring';
|
||||
import { SparkBars } from '../ui/SparkBars';
|
||||
import { SupplyBar } from '../ui/SupplyBar';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initial from name', () => {
|
||||
render(<Avatar name="Alice" />);
|
||||
expect(screen.getByLabelText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses custom size', () => {
|
||||
render(<Avatar name="Bob" size={48} />);
|
||||
const el = screen.getByLabelText('Bob');
|
||||
expect(el).toHaveStyle({ width: '48px', height: '48px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders with label and icon', () => {
|
||||
render(<IconButton icon="settings" label="Settings" />);
|
||||
expect(screen.getByLabelText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification dot when dot=true', () => {
|
||||
render(<IconButton icon="bell" label="Notifications" dot />);
|
||||
// dot is a span inside the button
|
||||
const btn = screen.getByLabelText('Notifications');
|
||||
expect(btn.querySelectorAll('span').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ring', () => {
|
||||
it('renders value and total', () => {
|
||||
render(<Ring value={5} total={10} />);
|
||||
expect(screen.getByLabelText('5 of 10 doses taken')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles zero total', () => {
|
||||
render(<Ring value={0} total={0} />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SparkBars', () => {
|
||||
it('renders bars with labels', () => {
|
||||
const data = [
|
||||
{ label: 'Jan', amount: 100 },
|
||||
{ label: 'Feb', amount: 200 },
|
||||
];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('Jan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders formatted amounts', () => {
|
||||
const data = [{ label: 'Mar', amount: 5000 }];
|
||||
render(<SparkBars data={data} />);
|
||||
expect(screen.getByText('5k')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SupplyBar', () => {
|
||||
it('renders days value', () => {
|
||||
render(<SupplyBar days={30} />);
|
||||
expect(screen.getByText('30')).toBeInTheDocument();
|
||||
expect(screen.getByText('d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for critical days', () => {
|
||||
render(<SupplyBar days={5} />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders for medium days', () => {
|
||||
render(<SupplyBar days={10} />);
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -71,6 +71,27 @@ const NAV: NavItem[] = [
|
|||
section: 'Medicines',
|
||||
},
|
||||
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
|
||||
{
|
||||
id: 'recipes',
|
||||
label: 'Recipes',
|
||||
href: '/recipes',
|
||||
icon: 'list',
|
||||
section: 'Food',
|
||||
},
|
||||
{
|
||||
id: 'pantry',
|
||||
label: 'Pantry',
|
||||
href: '/pantry',
|
||||
icon: 'fridge',
|
||||
section: 'Food',
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Product Library',
|
||||
href: '/products',
|
||||
icon: 'list',
|
||||
section: 'Food',
|
||||
},
|
||||
];
|
||||
|
||||
function groupNav(items: NavItem[]): [string, NavItem[]][] {
|
||||
|
|
|
|||
124
packages/web/src/services/__tests__/pantry.test.ts
Normal file
124
packages/web/src/services/__tests__/pantry.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listPantryItems,
|
||||
getPantryItem,
|
||||
createPantryItem,
|
||||
updatePantryItem,
|
||||
transitionPantryItem,
|
||||
batchTransitionPantryItems,
|
||||
getExpiringSoon,
|
||||
getWasteStats,
|
||||
deletePantryItem,
|
||||
} from '../pantry';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('pantry service', () => {
|
||||
it('listPantryItems with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry');
|
||||
});
|
||||
|
||||
it('listPantryItems builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', {
|
||||
storageLocation: 'fridge',
|
||||
status: 'sealed',
|
||||
urgency: 'urgent',
|
||||
productId: 'p1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('storageLocation=fridge');
|
||||
expect(url).toContain('status=sealed');
|
||||
expect(url).toContain('urgency=urgent');
|
||||
expect(url).toContain('productId=p1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('listPantryItems with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('getPantryItem calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'item1' });
|
||||
await getPantryItem('hh1', 'item1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
|
||||
it('createPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
const data = { productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece' };
|
||||
await createPantryItem('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry', data);
|
||||
});
|
||||
|
||||
it('updatePantryItem calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'item1' });
|
||||
await updatePantryItem('hh1', 'item1', { quantity: 2 });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/pantry/item1', { quantity: 2 });
|
||||
});
|
||||
|
||||
it('transitionPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
await transitionPantryItem('hh1', 'item1', { status: 'opened' as never });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/item1/transition', {
|
||||
status: 'opened',
|
||||
});
|
||||
});
|
||||
|
||||
it('batchTransitionPantryItems calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ transitioned: 2, failed: 0 });
|
||||
const data = { itemIds: ['a', 'b'], status: 'consumed' as const };
|
||||
await batchTransitionPantryItems('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/batch-transition', data);
|
||||
});
|
||||
|
||||
it('getExpiringSoon with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/expiring-soon');
|
||||
});
|
||||
|
||||
it('getExpiringSoon builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1', { days: 3, cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('days=3');
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
|
||||
it('getWasteStats with no period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats');
|
||||
});
|
||||
|
||||
it('getWasteStats with period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1', 'week');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats?period=week');
|
||||
});
|
||||
|
||||
it('deletePantryItem calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deletePantryItem('hh1', 'item1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
});
|
||||
163
packages/web/src/services/__tests__/products.test.ts
Normal file
163
packages/web/src/services/__tests__/products.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listProducts,
|
||||
getProduct,
|
||||
lookupBarcode,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
smartAddProduct,
|
||||
importProducts,
|
||||
} from '../products';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('products service', () => {
|
||||
it('listProducts with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products');
|
||||
});
|
||||
|
||||
it('listProducts builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1', {
|
||||
q: 'chicken',
|
||||
category: 'meat',
|
||||
tags: 'organic',
|
||||
barcode: '1234',
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=chicken');
|
||||
expect(url).toContain('category=meat');
|
||||
expect(url).toContain('tags=organic');
|
||||
expect(url).toContain('barcode=1234');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getProduct calls GET with correct path', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await getProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('lookupBarcode calls GET barcode endpoint', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await lookupBarcode('hh1', '1234567890');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/barcode/1234567890');
|
||||
});
|
||||
|
||||
it('createProduct calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'p1' });
|
||||
const data = {
|
||||
name: 'Chicken',
|
||||
category: 'meat' as never,
|
||||
servingSize: 100,
|
||||
servingUnit: 'g' as never,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: 'manual' as never,
|
||||
};
|
||||
await createProduct('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products', data);
|
||||
});
|
||||
|
||||
it('updateProduct calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'p1' });
|
||||
await updateProduct('hh1', 'p1', { name: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/products/p1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteProduct calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteProduct('hh1', 'p1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('smartAddProduct calls POST smart-add', async () => {
|
||||
mockPost.mockResolvedValue({ available: false, message: 'LLM not configured' });
|
||||
const result = await smartAddProduct('hh1', 'chicken breast');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products/smart-add', {
|
||||
text: 'chicken breast',
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
describe('importProducts', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
it('uploads file via fetch and returns result', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ imported: 3, skippedDuplicates: 1, errors: [] }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
const result = await importProducts('hh1', file);
|
||||
|
||||
expect(result.imported).toBe(3);
|
||||
expect(result.skippedDuplicates).toBe(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/households/hh1/products/import'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response with JSON body', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: () => Promise.resolve({ message: 'File too large' }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('File too large');
|
||||
});
|
||||
|
||||
it('throws with status on non-ok response without JSON', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow(
|
||||
'Import failed: 500 Internal Server Error',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with status when JSON body has no message', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('Import failed: 422');
|
||||
});
|
||||
});
|
||||
});
|
||||
118
packages/web/src/services/__tests__/recipes.test.ts
Normal file
118
packages/web/src/services/__tests__/recipes.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listRecipes,
|
||||
getRecipe,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
scaleRecipe,
|
||||
importRecipeFromText,
|
||||
importRecipeFromUrl,
|
||||
listRecipesByProduct,
|
||||
} from '../recipes';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('recipes service', () => {
|
||||
it('listRecipes with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes');
|
||||
});
|
||||
|
||||
it('listRecipes builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1', {
|
||||
q: 'pasta',
|
||||
tags: 'italian',
|
||||
cuisine: 'Italian',
|
||||
maxCalories: 500,
|
||||
isFavorite: true,
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=pasta');
|
||||
expect(url).toContain('tags=italian');
|
||||
expect(url).toContain('cuisine=Italian');
|
||||
expect(url).toContain('maxCalories=500');
|
||||
expect(url).toContain('isFavorite=true');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getRecipe calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'r1' });
|
||||
await getRecipe('hh1', 'r1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('createRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
const data = { name: 'Pasta' };
|
||||
await createRecipe('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes', data);
|
||||
});
|
||||
|
||||
it('updateRecipe calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'r1' });
|
||||
await updateRecipe('hh1', 'r1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/recipes/r1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteRecipe calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteRecipe('hh1', 'r1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('scaleRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
await scaleRecipe('hh1', 'r1', { targetServings: 8 });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/r1/scale', {
|
||||
targetServings: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromText calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromText('hh1', { text: 'recipe text' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-text', {
|
||||
text: 'recipe text',
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromUrl calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromUrl('hh1', { url: 'http://example.com' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-url', {
|
||||
url: 'http://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/by-product/p1');
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1', { cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
});
|
||||
113
packages/web/src/services/pantry.ts
Normal file
113
packages/web/src/services/pantry.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
PantryItemResponseSchema,
|
||||
PantryItemListResponseSchema,
|
||||
WasteStatsResponseSchema,
|
||||
BatchTransitionResponseSchema,
|
||||
CreatePantryItemInput,
|
||||
UpdatePantryItemInput,
|
||||
TransitionPantryItemInput,
|
||||
BatchTransitionInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type PantryItemResponse = z.infer<typeof PantryItemResponseSchema>;
|
||||
type PantryItemListResponse = z.infer<typeof PantryItemListResponseSchema>;
|
||||
type WasteStatsResponse = z.infer<typeof WasteStatsResponseSchema>;
|
||||
type BatchTransitionResponse = z.infer<typeof BatchTransitionResponseSchema>;
|
||||
|
||||
export interface PantryQuery {
|
||||
storageLocation?: string;
|
||||
status?: string;
|
||||
urgency?: string;
|
||||
productId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listPantryItems(
|
||||
householdId: string,
|
||||
query?: PantryQuery,
|
||||
): Promise<PantryItemListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.storageLocation) params.set('storageLocation', query.storageLocation);
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.urgency) params.set('urgency', query.urgency);
|
||||
if (query?.productId) params.set('productId', query.productId);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PantryItemListResponse>(
|
||||
`/households/${householdId}/pantry${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPantryItem(householdId: string, id: string): Promise<PantryItemResponse> {
|
||||
return apiClient.get<PantryItemResponse>(`/households/${householdId}/pantry/${id}`);
|
||||
}
|
||||
|
||||
export async function createPantryItem(
|
||||
householdId: string,
|
||||
data: CreatePantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.post<PantryItemResponse>(`/households/${householdId}/pantry`, data);
|
||||
}
|
||||
|
||||
export async function updatePantryItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdatePantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.patch<PantryItemResponse>(`/households/${householdId}/pantry/${id}`, data);
|
||||
}
|
||||
|
||||
export async function transitionPantryItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: TransitionPantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.post<PantryItemResponse>(
|
||||
`/households/${householdId}/pantry/${id}/transition`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function batchTransitionPantryItems(
|
||||
householdId: string,
|
||||
data: BatchTransitionInput,
|
||||
): Promise<BatchTransitionResponse> {
|
||||
return apiClient.post<BatchTransitionResponse>(
|
||||
`/households/${householdId}/pantry/batch-transition`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExpiringSoon(
|
||||
householdId: string,
|
||||
query?: { days?: number; cursor?: string; limit?: number },
|
||||
): Promise<PantryItemListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.days !== undefined) params.set('days', String(query.days));
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PantryItemListResponse>(
|
||||
`/households/${householdId}/pantry/expiring-soon${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWasteStats(
|
||||
householdId: string,
|
||||
period?: string,
|
||||
): Promise<WasteStatsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (period) params.set('period', period);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<WasteStatsResponse>(
|
||||
`/households/${householdId}/pantry/stats${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePantryItem(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/pantry/${id}`);
|
||||
}
|
||||
107
packages/web/src/services/products.ts
Normal file
107
packages/web/src/services/products.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
ProductResponseSchema,
|
||||
ProductListResponseSchema,
|
||||
CreateProductInput,
|
||||
UpdateProductInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
||||
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
||||
|
||||
export interface ProductQuery {
|
||||
q?: string;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
barcode?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listProducts(
|
||||
householdId: string,
|
||||
query?: ProductQuery,
|
||||
): Promise<ProductListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) params.set('q', query.q);
|
||||
if (query?.category) params.set('category', query.category);
|
||||
if (query?.tags) params.set('tags', query.tags);
|
||||
if (query?.barcode) params.set('barcode', query.barcode);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<ProductListResponse>(
|
||||
`/households/${householdId}/products${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProduct(householdId: string, id: string): Promise<ProductResponse> {
|
||||
return apiClient.get<ProductResponse>(`/households/${householdId}/products/${id}`);
|
||||
}
|
||||
|
||||
export async function lookupBarcode(
|
||||
householdId: string,
|
||||
barcode: string,
|
||||
): Promise<ProductResponse | { found: false }> {
|
||||
return apiClient.get<ProductResponse | { found: false }>(
|
||||
`/households/${householdId}/products/barcode/${barcode}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createProduct(
|
||||
householdId: string,
|
||||
data: CreateProductInput,
|
||||
): Promise<ProductResponse> {
|
||||
return apiClient.post<ProductResponse>(`/households/${householdId}/products`, data);
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateProductInput,
|
||||
): Promise<ProductResponse> {
|
||||
return apiClient.patch<ProductResponse>(`/households/${householdId}/products/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteProduct(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/products/${id}`);
|
||||
}
|
||||
|
||||
export async function smartAddProduct(
|
||||
householdId: string,
|
||||
text: string,
|
||||
): Promise<{ available: false; message: string }> {
|
||||
return apiClient.post<{ available: false; message: string }>(
|
||||
`/households/${householdId}/products/smart-add`,
|
||||
{ text },
|
||||
);
|
||||
}
|
||||
|
||||
export async function importProducts(
|
||||
householdId: string,
|
||||
file: File,
|
||||
): Promise<{
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
errors: { row: number; message: string }[];
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
const res = await fetch(`${baseUrl}/households/${householdId}/products/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = await res.json();
|
||||
message = body.message || `Import failed: ${res.status}`;
|
||||
} catch {
|
||||
message = `Import failed: ${res.status} ${res.statusText}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
101
packages/web/src/services/recipes.ts
Normal file
101
packages/web/src/services/recipes.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
RecipeResponseSchema,
|
||||
RecipeListResponseSchema,
|
||||
CreateRecipeInput,
|
||||
UpdateRecipeInput,
|
||||
ScaleRecipeInput,
|
||||
ImportRecipeTextInput,
|
||||
ImportRecipeUrlInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type RecipeResponse = z.infer<typeof RecipeResponseSchema>;
|
||||
type RecipeListResponse = z.infer<typeof RecipeListResponseSchema>;
|
||||
|
||||
export interface RecipeQuery {
|
||||
q?: string;
|
||||
tags?: string;
|
||||
cuisine?: string;
|
||||
maxCalories?: number;
|
||||
isFavorite?: boolean;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listRecipes(
|
||||
householdId: string,
|
||||
query?: RecipeQuery,
|
||||
): Promise<RecipeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) params.set('q', query.q);
|
||||
if (query?.tags) params.set('tags', query.tags);
|
||||
if (query?.cuisine) params.set('cuisine', query.cuisine);
|
||||
if (query?.maxCalories !== undefined) params.set('maxCalories', String(query.maxCalories));
|
||||
if (query?.isFavorite !== undefined) params.set('isFavorite', String(query.isFavorite));
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RecipeListResponse>(
|
||||
`/households/${householdId}/recipes${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecipe(householdId: string, id: string): Promise<RecipeResponse> {
|
||||
return apiClient.get<RecipeResponse>(`/households/${householdId}/recipes/${id}`);
|
||||
}
|
||||
|
||||
export async function createRecipe(
|
||||
householdId: string,
|
||||
data: CreateRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes`, data);
|
||||
}
|
||||
|
||||
export async function updateRecipe(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.patch<RecipeResponse>(`/households/${householdId}/recipes/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRecipe(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/recipes/${id}`);
|
||||
}
|
||||
|
||||
export async function scaleRecipe(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: ScaleRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes/${id}/scale`, data);
|
||||
}
|
||||
|
||||
export async function importRecipeFromText(
|
||||
householdId: string,
|
||||
data: ImportRecipeTextInput,
|
||||
): Promise<{ available: boolean; draft?: unknown }> {
|
||||
return apiClient.post(`/households/${householdId}/recipes/import-text`, data);
|
||||
}
|
||||
|
||||
export async function importRecipeFromUrl(
|
||||
householdId: string,
|
||||
data: ImportRecipeUrlInput,
|
||||
): Promise<{ available: boolean; draft?: unknown }> {
|
||||
return apiClient.post(`/households/${householdId}/recipes/import-url`, data);
|
||||
}
|
||||
|
||||
export async function listRecipesByProduct(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
query?: { cursor?: string; limit?: number },
|
||||
): Promise<RecipeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RecipeListResponse>(
|
||||
`/households/${householdId}/recipes/by-product/${productId}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -20,27 +20,27 @@ export default defineConfig({
|
|||
coverage: {
|
||||
provider: 'v8',
|
||||
enabled: false,
|
||||
// @ts-expect-error -- v8 provider supports `all` but types lag behind
|
||||
all: true,
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/test-utils.tsx',
|
||||
'src/mocks/**',
|
||||
'src/app/layout.tsx',
|
||||
'src/app/api/**',
|
||||
'src/lib/auth.ts',
|
||||
'src/proxy.ts',
|
||||
'src/**/*.d.ts',
|
||||
'src/app/(dashboard)/layout.tsx',
|
||||
'src/components/**',
|
||||
'src/app/login/**',
|
||||
],
|
||||
reporter: ['text', 'lcov', 'json-summary', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
thresholds: {
|
||||
lines: 24,
|
||||
functions: 18,
|
||||
branches: 18,
|
||||
statements: 24,
|
||||
lines: 90,
|
||||
functions: 85,
|
||||
branches: 75,
|
||||
statements: 85,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue