228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
import React, { useState, useMemo } from 'react'
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
import { Image as ImageIcon, Eye, Pencil, Package } from 'lucide-react'
|
|
import { AppContainer, p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
|
import type { AdminSku } from '@packages/shared'
|
|
|
|
export const Route = createFileRoute('/dashboard/products')({
|
|
component: Products,
|
|
})
|
|
|
|
type FilterType = 'all' | 'in-stock' | 'out-of-stock'
|
|
|
|
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
|
|
|
|
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
|
|
return product.skus?.[0] ?? null
|
|
}
|
|
|
|
function FilterButton({ filter, label, count, activeFilter, onSelect }: { filter: FilterType, label: string, count: number, activeFilter: FilterType, onSelect: (f: FilterType) => void }) {
|
|
return (
|
|
<button
|
|
onClick={() => onSelect(filter)}
|
|
className={`rounded-lg px-4 py-2 ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
|
>
|
|
<MyText className={`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
|
{label} ({count})
|
|
</MyText>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function Products() {
|
|
const navigate = useNavigate()
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all')
|
|
|
|
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery()
|
|
|
|
useManualRefresh(refetch)
|
|
|
|
useMarkDataFetchers(() => {
|
|
refetch()
|
|
})
|
|
|
|
const products = productsData?.products || []
|
|
|
|
const filteredProducts = useMemo(() => {
|
|
return products.filter((product) => {
|
|
const defaultSku = getDefaultSku(product)
|
|
|
|
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
|
|
const matchesFilter = activeFilter === 'all' ||
|
|
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
|
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock)
|
|
|
|
return matchesSearch && matchesFilter
|
|
})
|
|
}, [products, searchTerm, activeFilter])
|
|
|
|
const handleEdit = (productId: number) => {
|
|
navigate({ to: '/dashboard/products/edit', search: { id: String(productId) } })
|
|
}
|
|
|
|
const handleViewDetails = (productId: number) => {
|
|
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<MyText className="text-gray-600">Loading products...</MyText>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 flex-col items-center justify-center">
|
|
<MyText className="text-red-600">Error loading products</MyText>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
|
|
>
|
|
<MyText className="font-semibold text-white">Retry</MyText>
|
|
</button>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
const inStockCount = products.filter((p) => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length
|
|
const outOfStockCount = products.filter((p) => getDefaultSku(p)?.isOutOfStock).length
|
|
|
|
const listHeader = (
|
|
<>
|
|
<div className="mb-6 flex flex-row items-center justify-between">
|
|
<MyText className="text-2xl font-bold text-gray-800">Products</MyText>
|
|
<MyButton onClick={() => navigate({ to: '/dashboard/products/new' })}>
|
|
Add Product
|
|
</MyButton>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<SearchBar
|
|
value={searchTerm}
|
|
onChange={setSearchTerm}
|
|
placeholder="Search products..."
|
|
className="mb-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-6 flex flex-row gap-2">
|
|
<FilterButton filter="all" label="All" count={products.length} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
|
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
|
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
|
</div>
|
|
</>
|
|
)
|
|
|
|
const renderItem = (product: any) => {
|
|
const defaultSku = getDefaultSku(product)
|
|
const skuImages = defaultSku?.images ?? null
|
|
const isOut = defaultSku?.isOutOfStock ?? false
|
|
|
|
return (
|
|
<div key={product.id} className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg">
|
|
{skuImages && skuImages.length > 0 ? (
|
|
<img
|
|
src={skuImages[0]}
|
|
className="h-48 w-full object-cover"
|
|
alt={product.name}
|
|
/>
|
|
) : (
|
|
<div className="flex h-48 w-full items-center justify-center bg-gray-200">
|
|
<ImageIcon size={48} color="#9CA3AF" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4">
|
|
<div className="mb-2 flex flex-row items-start justify-between">
|
|
<MyText className="mr-2 flex-1 text-lg font-bold text-gray-800" numberOfLines={2}>
|
|
{product.name}
|
|
</MyText>
|
|
<div className="flex flex-row items-center">
|
|
<div className={`mr-1 h-3 w-3 rounded-full ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
|
<MyText className={`text-sm font-semibold ${isOut ? 'text-red-500' : 'text-green-500'}`}>
|
|
{isOut ? 'Out' : 'In'}
|
|
</MyText>
|
|
</div>
|
|
</div>
|
|
|
|
{product.shortDescription && (
|
|
<MyText className="mb-2 text-gray-600" numberOfLines={2}>
|
|
{product.shortDescription}
|
|
</MyText>
|
|
)}
|
|
|
|
<div className="mb-3 flex flex-row items-center justify-between">
|
|
<MyText className="text-sm font-medium text-gray-500">
|
|
{product.skus?.length ?? 0} Variants
|
|
</MyText>
|
|
</div>
|
|
|
|
<div className="flex flex-row gap-2">
|
|
<button
|
|
onClick={() => handleViewDetails(product.id)}
|
|
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-gray-500 p-3"
|
|
>
|
|
<Eye size={16} color="white" />
|
|
<MyText className="ml-1 font-semibold text-white">View</MyText>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleEdit(product.id)}
|
|
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-blue-500 p-3"
|
|
>
|
|
<Pencil size={16} color="white" />
|
|
<MyText className="ml-1 font-semibold text-white">Edit</MyText>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex flex-1 flex-col">
|
|
<MyFlatList
|
|
data={filteredProducts}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
ListHeaderComponent={listHeader}
|
|
ListEmptyComponent={
|
|
<div className="flex flex-1 flex-col items-center justify-center py-10">
|
|
<Package size={64} color="#D1D5DB" />
|
|
<MyText className="mt-4 text-center text-lg text-gray-500">
|
|
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
|
</MyText>
|
|
<MyText className="mt-2 text-center text-gray-400">
|
|
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
|
</MyText>
|
|
{(searchTerm || activeFilter !== 'all') && (
|
|
<button
|
|
onClick={() => {
|
|
setSearchTerm('')
|
|
setActiveFilter('all')
|
|
}}
|
|
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
|
|
>
|
|
<MyText className="font-semibold text-white">Clear Filters</MyText>
|
|
</button>
|
|
)}
|
|
</div>
|
|
}
|
|
className="pb-4"
|
|
/>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|