Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 12s
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
// src/features/shopping/ShoppingList.tsx
|
|
import React, { useState, useMemo, useCallback } from 'react';
|
|
import type { ShoppingList, ShoppingListItem, User } from '../../types';
|
|
import { UserIcon } from '../../components/icons/UserIcon';
|
|
import { ListBulletIcon } from '../../components/icons/ListBulletIcon';
|
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
|
import { TrashIcon } from '../../components/icons/TrashIcon';
|
|
import { SpeakerWaveIcon } from '../../components/icons/SpeakerWaveIcon';
|
|
import { generateSpeechFromText } from '../../services/aiApiClient';
|
|
import { decode, decodeAudioData } from '../../utils/audioUtils';
|
|
import { logger } from '../../services/logger.client';
|
|
import { Button } from '../../components/Button';
|
|
|
|
interface ShoppingListComponentProps {
|
|
user: User | null;
|
|
lists: ShoppingList[];
|
|
activeListId: number | null;
|
|
onSelectList: (listId: number) => void;
|
|
onCreateList: (name: string) => Promise<void>;
|
|
onDeleteList: (listId: number) => Promise<void>;
|
|
onAddItem: (item: { customItemName: string }) => Promise<void>;
|
|
onUpdateItem: (itemId: number, updates: Partial<ShoppingListItem>) => Promise<void>;
|
|
onRemoveItem: (itemId: number) => Promise<void>;
|
|
}
|
|
|
|
export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({
|
|
user,
|
|
lists,
|
|
activeListId,
|
|
onSelectList,
|
|
onCreateList,
|
|
onDeleteList,
|
|
onAddItem,
|
|
onUpdateItem,
|
|
onRemoveItem,
|
|
}) => {
|
|
const [isCreatingList, setIsCreatingList] = useState(false);
|
|
const [customItemName, setCustomItemName] = useState('');
|
|
const [isAddingCustom, setIsAddingCustom] = useState(false);
|
|
const [isReadingAloud, setIsReadingAloud] = useState(false);
|
|
|
|
const activeList = useMemo(
|
|
() => lists.find((list) => list.shopping_list_id === activeListId),
|
|
[lists, activeListId],
|
|
);
|
|
const { neededItems, purchasedItems } = useMemo(() => {
|
|
if (!activeList) return { neededItems: [], purchasedItems: [] };
|
|
const neededItems: ShoppingListItem[] = [];
|
|
const purchasedItems: ShoppingListItem[] = [];
|
|
activeList.items.forEach((item) => {
|
|
if (item.is_purchased) {
|
|
purchasedItems.push(item);
|
|
} else {
|
|
neededItems.push(item);
|
|
}
|
|
});
|
|
return { neededItems, purchasedItems };
|
|
}, [activeList]);
|
|
|
|
const handleCreateList = async () => {
|
|
const name = prompt('Enter a name for your new shopping list:');
|
|
if (name && name.trim()) {
|
|
setIsCreatingList(true);
|
|
await onCreateList(name.trim());
|
|
setIsCreatingList(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteList = async () => {
|
|
if (
|
|
activeList &&
|
|
window.confirm(
|
|
`Are you sure you want to delete the "${activeList.name}" list? This cannot be undone.`,
|
|
)
|
|
) {
|
|
await onDeleteList(activeList.shopping_list_id);
|
|
}
|
|
};
|
|
|
|
const handleAddCustomItem = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!customItemName.trim()) return;
|
|
setIsAddingCustom(true);
|
|
await onAddItem({ customItemName: customItemName.trim() });
|
|
setCustomItemName('');
|
|
setIsAddingCustom(false);
|
|
};
|
|
|
|
const handleReadAloud = useCallback(async () => {
|
|
if (!activeList || neededItems.length === 0) return;
|
|
|
|
setIsReadingAloud(true);
|
|
try {
|
|
const listText =
|
|
'Here is your shopping list: ' +
|
|
neededItems
|
|
.map((item) => item.custom_item_name || item.master_item?.name)
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
const response = await generateSpeechFromText(listText);
|
|
const base64Audio: string = await response.json();
|
|
|
|
// Play the audio
|
|
const audioContext = new window.AudioContext();
|
|
const audioBuffer = await decodeAudioData(decode(base64Audio), audioContext, 24000, 1);
|
|
const source = audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(audioContext.destination);
|
|
source.start();
|
|
} catch (e) {
|
|
// This is a type-safe way to handle errors. We check if the caught
|
|
// object is an instance of Error before accessing its message property.
|
|
const errorMessage =
|
|
e instanceof Error ? e.message : 'An unknown error occurred while generating audio.';
|
|
logger.error('Failed to read list aloud', { error: e });
|
|
alert(`Could not read list aloud: ${errorMessage}`);
|
|
} finally {
|
|
setIsReadingAloud(false);
|
|
}
|
|
}, [activeList, neededItems]);
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
|
<div className="flex flex-col items-center justify-center h-full min-h-[150px]">
|
|
<UserIcon className="w-10 h-10 text-gray-400 mb-3" />
|
|
<h4 className="font-semibold text-gray-700 dark:text-gray-300">Your Shopping Lists</h4>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Please log in to manage your shopping lists.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4"
|
|
data-tour="shopping-list"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex items-center">
|
|
<ListBulletIcon className="w-6 h-6 mr-2 text-brand-primary" />
|
|
Shopping List
|
|
</h3>
|
|
<button
|
|
onClick={handleReadAloud}
|
|
disabled={isReadingAloud || !activeList || neededItems.length === 0}
|
|
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700/50 text-gray-500 dark:text-gray-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
title="Read list aloud"
|
|
>
|
|
{isReadingAloud ? (
|
|
<div className="w-5 h-5">
|
|
<LoadingSpinner />
|
|
</div>
|
|
) : (
|
|
<SpeakerWaveIcon className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 mb-4">
|
|
{lists.length > 0 && (
|
|
<select
|
|
value={activeListId || ''}
|
|
onChange={(e) => onSelectList(Number(e.target.value))}
|
|
className="block w-full pl-3 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary"
|
|
>
|
|
{lists.map((list) => (
|
|
<option key={list.shopping_list_id} value={list.shopping_list_id}>
|
|
{list.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={handleCreateList}
|
|
disabled={isCreatingList}
|
|
className="flex-1"
|
|
>
|
|
New List
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={handleDeleteList}
|
|
disabled={!activeList}
|
|
className="flex-1"
|
|
>
|
|
Delete List
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeList ? (
|
|
<>
|
|
<form onSubmit={handleAddCustomItem} className="flex space-x-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={customItemName}
|
|
onChange={(e) => setCustomItemName(e.target.value)}
|
|
placeholder="Add a custom item..."
|
|
className="grow block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm sm:text-sm"
|
|
disabled={isAddingCustom}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={!customItemName.trim()}
|
|
isLoading={isAddingCustom}
|
|
>
|
|
Add
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
{neededItems.length > 0 ? (
|
|
neededItems.map((item) => (
|
|
<div
|
|
key={item.shopping_list_item_id}
|
|
className="group flex items-center space-x-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.is_purchased}
|
|
onChange={() =>
|
|
onUpdateItem(item.shopping_list_item_id, { is_purchased: !item.is_purchased })
|
|
}
|
|
className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary"
|
|
/>
|
|
<span className="grow text-gray-800 dark:text-gray-200">
|
|
{item.custom_item_name || item.master_item?.name}
|
|
</span>
|
|
<button
|
|
onClick={() => onRemoveItem(item.shopping_list_item_id)}
|
|
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1"
|
|
>
|
|
<TrashIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-gray-500 text-center py-4">This list is empty.</p>
|
|
)}
|
|
|
|
{purchasedItems.length > 0 && (
|
|
<div className="pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Purchased</h4>
|
|
{purchasedItems.map((item) => (
|
|
<div
|
|
key={item.shopping_list_item_id}
|
|
className="group flex items-center space-x-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={item.is_purchased}
|
|
onChange={() =>
|
|
onUpdateItem(item.shopping_list_item_id, {
|
|
is_purchased: !item.is_purchased,
|
|
})
|
|
}
|
|
className="h-4 w-4 rounded border-gray-300 text-brand-primary focus:ring-brand-secondary"
|
|
/>
|
|
<span className="grow text-gray-500 dark:text-gray-400 line-through">
|
|
{item.custom_item_name || item.master_item?.name}
|
|
</span>
|
|
<button
|
|
onClick={() => onRemoveItem(item.shopping_list_item_id)}
|
|
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1"
|
|
>
|
|
<TrashIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-10">
|
|
<p className="text-gray-500">No shopping lists found. Create one to get started!</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|