301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
|
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { GripVertical, Image as ImageIcon, XCircle, ChevronLeft, CircleAlert, Package } from 'lucide-react'
|
|
import { AppContainer, p as MyText } from 'web-components'
|
|
import { trpc } from '@/lib/trpc-client'
|
|
import type { StoreProductCard } from '@packages/shared'
|
|
import type { DragOrderItemProps } from '@/types/drag-order-item'
|
|
|
|
export const Route = createFileRoute('/dashboard/customize-app/ordering')({
|
|
component: AllItemsOrder,
|
|
})
|
|
|
|
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
|
|
|
|
const itemStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
height: 60,
|
|
backgroundColor: 'white',
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderStyle: 'solid',
|
|
borderColor: '#e5e7eb',
|
|
padding: 10,
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
|
marginTop: 4,
|
|
marginBottom: 4,
|
|
}
|
|
|
|
const activeItemStyle: React.CSSProperties = {
|
|
...itemStyle,
|
|
borderColor: '#3b82f6',
|
|
boxShadow: '0 4px 8px rgba(59,130,246,0.3)',
|
|
}
|
|
|
|
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
|
|
item,
|
|
isActive,
|
|
}) => {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
|
|
const active = isActive || isDragging
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
...(active ? activeItemStyle : itemStyle),
|
|
transform: CSS.Translate.toString(transform),
|
|
transition,
|
|
opacity: item.isOutOfStock ? 0.6 : 1,
|
|
zIndex: isDragging ? 50 : undefined,
|
|
}}
|
|
>
|
|
<div {...attributes} {...listeners} style={{ marginRight: 8, padding: 2, cursor: 'grab', touchAction: 'none' }}>
|
|
<GripVertical
|
|
size={24}
|
|
color={active ? '#3b82f6' : '#9ca3af'}
|
|
/>
|
|
</div>
|
|
|
|
{item.images?.[0] ? (
|
|
<img
|
|
src={item.images[0]}
|
|
style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }}
|
|
alt={item.name}
|
|
/>
|
|
) : (
|
|
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<ImageIcon size={24} color="#9ca3af" />
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<MyText style={{ fontSize: 13, color: '#111827', fontWeight: 500, flex: 1, marginRight: 4 }} numberOfLines={1}>
|
|
{item.name.length > 30 ? item.name.substring(0, 30) + '...' : item.name}
|
|
</MyText>
|
|
|
|
{item.isOutOfStock && (
|
|
<XCircle size={16} color="#dc2626" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AllItemsOrder() {
|
|
const queryClient = useQueryClient()
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
const [hasChanges, setHasChanges] = useState(false)
|
|
|
|
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
|
|
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery()
|
|
const updateConstants = trpc.admin.const.updateConstants.useMutation()
|
|
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
|
|
|
useEffect(() => {
|
|
if (allProducts?.products) {
|
|
const allItemsOrderConstant = constants?.find((c) => c.key === 'allItemsOrder')
|
|
|
|
let orderedIds: number[] = []
|
|
|
|
if (allItemsOrderConstant) {
|
|
const value = allItemsOrderConstant.value
|
|
|
|
if (Array.isArray(value)) {
|
|
orderedIds = value.map((id: any) => parseInt(id))
|
|
} else if (typeof value === 'string') {
|
|
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
|
|
}
|
|
}
|
|
|
|
const productMap = new Map(allProducts.products.map((p) => [p.id, p]))
|
|
|
|
const sortedProducts: Product[] = []
|
|
|
|
for (const id of orderedIds) {
|
|
const product = productMap.get(id)
|
|
if (product) {
|
|
sortedProducts.push({
|
|
id: product.id,
|
|
name: product.name,
|
|
images: product.images || [],
|
|
isOutOfStock: product.isOutOfStock || false,
|
|
})
|
|
productMap.delete(id)
|
|
}
|
|
}
|
|
|
|
for (const product of productMap.values()) {
|
|
sortedProducts.push({
|
|
id: product.id,
|
|
name: product.name,
|
|
images: product.images || [],
|
|
isOutOfStock: product.isOutOfStock || false,
|
|
})
|
|
}
|
|
|
|
setProducts(sortedProducts)
|
|
}
|
|
}, [constants, allProducts])
|
|
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
if (over && active.id !== over.id) {
|
|
setProducts((prev) => {
|
|
const oldIndex = prev.findIndex((p) => p.id === active.id)
|
|
const newIndex = prev.findIndex((p) => p.id === over.id)
|
|
return arrayMove(prev, oldIndex, newIndex)
|
|
})
|
|
setHasChanges(true)
|
|
}
|
|
}, [])
|
|
|
|
const handleSave = () => {
|
|
const productIds = products.map((p) => p.id)
|
|
|
|
updateConstants.mutate(
|
|
{
|
|
constants: [{
|
|
key: 'allItemsOrder',
|
|
value: productIds,
|
|
}],
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setHasChanges(false)
|
|
window.alert('All items order updated successfully!')
|
|
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
|
|
},
|
|
onError: (error) => {
|
|
window.alert('Failed to update items order. Please try again.')
|
|
console.error('Update all items order error:', error)
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
if (isLoadingConstants || isLoadingProducts) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex-1 bg-gray-50">
|
|
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="-ml-4 p-2"
|
|
>
|
|
<ChevronLeft size={24} color="#374151" />
|
|
</button>
|
|
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
|
<div className="w-16" />
|
|
</div>
|
|
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
|
<MyText className="mt-4 text-center text-gray-500">
|
|
{isLoadingConstants ? 'Loading order...' : 'Loading products...'}
|
|
</MyText>
|
|
</div>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
if (constantsError || productsError) {
|
|
return (
|
|
<AppContainer>
|
|
<div className="flex-1 bg-gray-50">
|
|
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="-ml-4 p-2"
|
|
>
|
|
<ChevronLeft size={24} color="#374151" />
|
|
</button>
|
|
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
|
<div className="w-16" />
|
|
</div>
|
|
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
|
<CircleAlert size={64} color="#ef4444" />
|
|
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
|
<MyText className="mt-2 text-center text-gray-500">
|
|
{constantsError ? 'Failed to load order' : 'Failed to load products'}
|
|
</MyText>
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
|
>
|
|
<MyText className="font-semibold text-white">Go Back</MyText>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</AppContainer>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 bg-gray-50">
|
|
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="-ml-4 p-2"
|
|
>
|
|
<ChevronLeft size={24} color="#374151" />
|
|
</button>
|
|
|
|
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
|
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={!hasChanges || updateConstants.isPending}
|
|
className={`rounded-lg px-4 py-2 ${
|
|
hasChanges && !updateConstants.isPending
|
|
? 'bg-blue-600'
|
|
: 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<MyText className={`font-semibold ${
|
|
hasChanges && !updateConstants.isPending
|
|
? 'text-white'
|
|
: 'text-gray-500'
|
|
}`}>
|
|
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
|
</MyText>
|
|
</button>
|
|
</div>
|
|
|
|
{products.length === 0 ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
|
<Package size={64} color="#e5e7eb" />
|
|
<MyText className="mt-4 text-center text-lg text-gray-500">
|
|
No products available
|
|
</MyText>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 flex-col">
|
|
<div className="mx-4 mb-2 mt-2 rounded-lg bg-blue-50 px-4 py-2">
|
|
<MyText className="text-center text-xs text-blue-700">
|
|
Long press and drag to reorder • {products.length} items
|
|
</MyText>
|
|
</div>
|
|
|
|
<div className="flex-1 px-3 pb-5">
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={products.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
|
{products.map((product) => (
|
|
<ProductItem key={product.id} item={product} drag={() => {}} isActive={false} />
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|