467 lines
No EOL
17 KiB
TypeScript
467 lines
No EOL
17 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { View, ScrollView, TouchableOpacity, Alert, Image, RefreshControl } from 'react-native';
|
|
import { AppContainer, MyText, tw, MyTouchableOpacity } from 'common-ui';
|
|
import { trpc } from '../../../src/trpc-client';
|
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
import { useRouter } from 'expo-router';
|
|
|
|
interface Banner {
|
|
id: number;
|
|
name: string;
|
|
imageUrl: string;
|
|
description: string | null;
|
|
productIds: number[] | null;
|
|
redirectUrl: string | null;
|
|
serialNum: number | null;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
lastUpdated: string;
|
|
}
|
|
|
|
export default function DashboardBanners() {
|
|
const router = useRouter();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
// Edit mode state
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
|
|
const [slotAssignments, setSlotAssignments] = useState<{[slot: number]: number | null}>({});
|
|
const [originalAssignments, setOriginalAssignments] = useState<{[slot: number]: number | null}>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Real API calls
|
|
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery();
|
|
|
|
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation();
|
|
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation();
|
|
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation();
|
|
|
|
const banners = bannersData?.banners || [];
|
|
|
|
// Initialize slot assignments when banners load
|
|
React.useEffect(() => {
|
|
const assignments: {[slot: number]: number | null} = {1: null, 2: null, 3: null, 4: null};
|
|
banners.forEach(banner => {
|
|
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
|
|
assignments[banner.serialNum] = banner.id;
|
|
}
|
|
});
|
|
setSlotAssignments(assignments);
|
|
setOriginalAssignments({...assignments});
|
|
}, [bannersData]);
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await refetch();
|
|
setRefreshing(false);
|
|
};
|
|
|
|
// Slot and edit mode handlers
|
|
const handleSlotClick = (slotNumber: number) => {
|
|
setSelectedSlot(slotNumber);
|
|
setEditMode(true);
|
|
};
|
|
|
|
const handleBannerSelect = (bannerId: number) => {
|
|
if (!editMode || selectedSlot === null) return;
|
|
|
|
// Remove banner from any existing slot
|
|
const newAssignments = {...slotAssignments};
|
|
Object.keys(newAssignments).forEach(slot => {
|
|
if (newAssignments[parseInt(slot)] === bannerId) {
|
|
newAssignments[parseInt(slot)] = null;
|
|
}
|
|
});
|
|
|
|
// Assign banner to selected slot
|
|
newAssignments[selectedSlot] = bannerId;
|
|
setSlotAssignments(newAssignments);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
// Get banners that need to be updated
|
|
const bannersToUpdate: {id: number, serialNum: number | null}[] = [];
|
|
|
|
// Clear serial numbers for banners no longer in slots
|
|
banners.forEach(banner => {
|
|
const currentSlot = banner.serialNum;
|
|
const assignedSlot = Object.keys(slotAssignments).find(slot =>
|
|
slotAssignments[parseInt(slot)] === banner.id
|
|
);
|
|
|
|
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
|
|
bannersToUpdate.push({
|
|
id: banner.id,
|
|
serialNum: assignedSlot ? parseInt(assignedSlot) : null
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update banners that gained slots
|
|
Object.keys(slotAssignments).forEach(slot => {
|
|
const slotNum = parseInt(slot);
|
|
const bannerId = slotAssignments[slotNum];
|
|
if (bannerId) {
|
|
const banner = banners.find(b => b.id === bannerId);
|
|
if (banner && banner.serialNum !== slotNum) {
|
|
bannersToUpdate.push({
|
|
id: bannerId,
|
|
serialNum: slotNum
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Execute updates
|
|
await Promise.all(
|
|
bannersToUpdate.map(({id, serialNum}) =>
|
|
updateBannerMutation.mutateAsync({ id, serialNum })
|
|
)
|
|
);
|
|
|
|
setOriginalAssignments({...slotAssignments});
|
|
setEditMode(false);
|
|
setSelectedSlot(null);
|
|
await refetch();
|
|
Alert.alert('Success', 'Slot assignments saved successfully');
|
|
} catch (error) {
|
|
Alert.alert('Error', 'Failed to save slot assignments');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setSlotAssignments({...originalAssignments});
|
|
setEditMode(false);
|
|
setSelectedSlot(null);
|
|
};
|
|
|
|
const handleEmptySlot = async () => {
|
|
if (!selectedSlot || !slotAssignments[selectedSlot]) return;
|
|
|
|
const bannerId = slotAssignments[selectedSlot];
|
|
const banner = banners.find(b => b.id === bannerId);
|
|
|
|
if (!banner) return;
|
|
|
|
try {
|
|
// Update banner's serialNum to null to empty the slot
|
|
await emptySlotMutation.mutateAsync({
|
|
id: banner.id,
|
|
serialNum: null
|
|
});
|
|
|
|
// Update local state
|
|
setSlotAssignments(prev => ({
|
|
...prev,
|
|
[selectedSlot]: null
|
|
}));
|
|
|
|
// Update original assignments for cancel functionality
|
|
setOriginalAssignments(prev => ({
|
|
...prev,
|
|
[selectedSlot]: null
|
|
}));
|
|
|
|
Alert.alert('Success', `Slot ${selectedSlot} has been emptied`);
|
|
} catch (error) {
|
|
Alert.alert('Error', 'Failed to empty slot');
|
|
}
|
|
};
|
|
|
|
// Helper function to get banner name by ID
|
|
const getBannerNameById = (id: number | null) => {
|
|
if (!id) return null;
|
|
const banner = banners.find(b => b.id === id);
|
|
return banner ? banner.name : null;
|
|
};
|
|
|
|
const handleEdit = (banner: Banner) => {
|
|
router.push(`/dashboard-banners/edit-banner/${banner.id}` as any);
|
|
};
|
|
|
|
const handleDelete = (id: number) => {
|
|
Alert.alert(
|
|
'Delete Banner',
|
|
'Are you sure you want to delete this banner?',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
try {
|
|
await deleteBannerMutation.mutateAsync({ id });
|
|
refetch();
|
|
Alert.alert('Success', 'Banner deleted');
|
|
} catch (error) {
|
|
Alert.alert('Error', 'Failed to delete banner');
|
|
}
|
|
}
|
|
}
|
|
]
|
|
);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
router.push('/(drawer)/dashboard-banners/create-banner' as any);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppContainer>
|
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
|
<MyText style={tw`text-gray-600`}>Loading banners...</MyText>
|
|
</View>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<AppContainer>
|
|
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
|
<MyText style={tw`text-red-600`}>Error loading banners</MyText>
|
|
</View>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppContainer>
|
|
<View style={tw`flex-1 bg-gray-50`}>
|
|
<ScrollView
|
|
style={tw`flex-1`}
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={tw`pb-24`}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={['#3B82F6']} // Blue color to match the theme
|
|
tintColor="#3B82F6"
|
|
/>
|
|
}
|
|
>
|
|
{/* Header */}
|
|
{/* <View style={tw`px-4 py-6`}>
|
|
<MyText style={tw`text-lg font-bold text-gray-900 mb-4`}>All Banners</MyText>
|
|
|
|
<MyTouchableOpacity
|
|
onPress={handleCreate}
|
|
style={tw`bg-blue-600 rounded-lg py-3 px-4 items-center mb-6`}
|
|
>
|
|
<View style={tw`flex-row items-center`}>
|
|
<MaterialIcons name="add" size={20} color="white" />
|
|
<MyText style={tw`text-white font-semibold ml-2`}>Add New Banner</MyText>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
</View> */}
|
|
|
|
{/* Slots Row */}
|
|
<View style={tw`px-4 mb-6`}>
|
|
<MyText style={tw`text-sm font-medium text-gray-700 mb-3`}>
|
|
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
|
|
</MyText>
|
|
<View style={tw`flex-row justify-between`}>
|
|
{[1, 2, 3, 4].map(slotNum => {
|
|
const assignedBannerId = slotAssignments[slotNum];
|
|
const bannerName = getBannerNameById(assignedBannerId);
|
|
const isSelected = editMode && selectedSlot === slotNum;
|
|
|
|
return (
|
|
<MyTouchableOpacity
|
|
key={slotNum}
|
|
onPress={() => handleSlotClick(slotNum)}
|
|
style={tw`flex-1 mx-1 p-3 rounded-lg border-2 ${
|
|
isSelected
|
|
? 'border-blue-500 bg-blue-50'
|
|
: assignedBannerId
|
|
? 'border-green-300 bg-green-50'
|
|
: 'border-gray-300 bg-white'
|
|
}`}
|
|
>
|
|
<View style={tw`items-center`}>
|
|
<MyText style={tw`text-xs text-gray-500 mb-1`}>Slot {slotNum}</MyText>
|
|
<MyText
|
|
style={tw`text-xs font-medium text-center ${
|
|
assignedBannerId ? 'text-gray-900' : 'text-gray-400'
|
|
}`}
|
|
numberOfLines={2}
|
|
>
|
|
{bannerName || 'Empty'}
|
|
</MyText>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Action buttons in edit mode */}
|
|
{editMode && (
|
|
<View style={tw`mt-4 gap-3`}>
|
|
{/* Save/Cancel buttons */}
|
|
<View style={tw`flex-row gap-3`}>
|
|
<MyTouchableOpacity
|
|
onPress={handleCancel}
|
|
disabled={saving}
|
|
style={tw`flex-1 bg-gray-500 rounded-lg py-3 px-4 items-center ${
|
|
saving ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<MyText style={tw`text-white font-semibold`}>Cancel</MyText>
|
|
</MyTouchableOpacity>
|
|
<MyTouchableOpacity
|
|
onPress={handleSave}
|
|
disabled={saving}
|
|
style={tw`flex-1 bg-blue-600 rounded-lg py-3 px-4 items-center ${
|
|
saving ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<MyText style={tw`text-white font-semibold`}>
|
|
{saving ? 'Saving...' : 'Save Changes'}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
|
|
{/* Empty Slot button */}
|
|
<MyTouchableOpacity
|
|
onPress={handleEmptySlot}
|
|
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
|
|
style={tw`bg-red-500 rounded-lg py-3 px-4 items-center ${
|
|
(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
|
|
}`}
|
|
>
|
|
<View style={tw`flex-row items-center`}>
|
|
<MaterialIcons
|
|
name={emptySlotMutation.isPending ? "hourglass-empty" : "clear"}
|
|
size={16}
|
|
color="white"
|
|
/>
|
|
<MyText style={tw`text-white font-semibold ml-2`}>
|
|
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
|
|
</MyText>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Banners List */}
|
|
<View style={tw`px-4 pb-8`}>
|
|
{editMode && (
|
|
<View style={tw`mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200`}>
|
|
<MyText style={tw`text-sm text-blue-800 text-center`}>
|
|
Tap a banner below to assign it to Slot {selectedSlot}
|
|
</MyText>
|
|
</View>
|
|
)}
|
|
|
|
{banners.length === 0 ? (
|
|
<View style={tw`flex-1 justify-center items-center py-8`}>
|
|
<View style={tw`w-24 h-24 bg-slate-50 rounded-full items-center justify-center mb-6`}>
|
|
<MaterialIcons name="image" size={48} color="#94A3B8" />
|
|
</View>
|
|
<MyText style={tw`text-slate-900 text-xl font-black tracking-tight`}>No Banners Yet</MyText>
|
|
<MyText style={tw`text-slate-500 text-center mt-2 font-medium px-8`}>
|
|
Start by creating your first banner using the button above.
|
|
</MyText>
|
|
</View>
|
|
) : (
|
|
banners.map((banner) => {
|
|
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id);
|
|
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id;
|
|
const canInteract = editMode;
|
|
|
|
return (
|
|
<MyTouchableOpacity
|
|
key={banner.id}
|
|
onPress={canInteract ? () => handleBannerSelect(banner.id) : undefined}
|
|
disabled={!canInteract}
|
|
style={tw`bg-white rounded-xl p-4 mb-3 shadow-sm border ${
|
|
canInteract && isAssignedToSelectedSlot
|
|
? 'border-blue-500 bg-blue-50'
|
|
: canInteract && isAssignedToSlot
|
|
? 'border-green-300 bg-green-50'
|
|
: canInteract
|
|
? 'border-gray-200'
|
|
: 'border-gray-100'
|
|
}`}
|
|
>
|
|
<View style={tw`flex-row items-center`}>
|
|
<Image
|
|
source={{ uri: banner.imageUrl }}
|
|
style={tw`w-16 h-16 rounded-lg mr-3`}
|
|
resizeMode="cover"
|
|
/>
|
|
<View style={tw`flex-1`}>
|
|
<View style={tw`flex-row items-center justify-between mb-1`}>
|
|
<MyText style={tw`font-semibold text-gray-900`} numberOfLines={1}>
|
|
{banner.name}
|
|
</MyText>
|
|
<View style={tw`flex-row items-center`}>
|
|
{canInteract && isAssignedToSelectedSlot && (
|
|
<View style={tw`mr-2`}>
|
|
<MaterialIcons name="check-circle" size={16} color="#3B82F6" />
|
|
</View>
|
|
)}
|
|
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
|
|
<View style={tw`mr-2`}>
|
|
<MaterialIcons name="radio-button-checked" size={16} color="#10B981" />
|
|
</View>
|
|
)}
|
|
<View style={tw`w-2 h-2 rounded-full mr-2 ${
|
|
banner.isActive ? 'bg-green-500' : 'bg-gray-400'
|
|
}`} />
|
|
{!editMode && (
|
|
<>
|
|
<TouchableOpacity onPress={() => handleEdit(banner)} style={tw`p-1 mr-1`}>
|
|
<MaterialIcons name="edit" size={16} color="#64748B" />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={() => handleDelete(banner.id)} style={tw`p-1`}>
|
|
<MaterialIcons name="delete" size={16} color="#EF4444" />
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
{banner.description && (
|
|
<MyText style={tw`text-sm text-gray-500 mt-1`} numberOfLines={2}>
|
|
{banner.description}
|
|
</MyText>
|
|
)}
|
|
<View style={tw`flex-row items-center justify-between mt-2`}>
|
|
<MyText style={tw`text-xs text-gray-400`}>
|
|
Created: {new Date(banner.createdAt).toLocaleDateString()}
|
|
</MyText>
|
|
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
|
|
<MyText style={tw`text-xs text-blue-600 font-medium`}>
|
|
Slot {banner.serialNum}
|
|
</MyText>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
);
|
|
})
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Floating Action Button */}
|
|
<MyTouchableOpacity
|
|
onPress={handleCreate}
|
|
style={tw`absolute bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 items-center justify-center shadow-lg`}
|
|
accessibilityLabel="Add new banner"
|
|
accessibilityRole="button"
|
|
>
|
|
<MaterialIcons name="add" size={24} color="white" />
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
</AppContainer>
|
|
);
|
|
} |