1161 lines
50 KiB
TypeScript
1161 lines
50 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
Award,
|
|
Users,
|
|
Search,
|
|
Filter,
|
|
Gift,
|
|
Edit,
|
|
Trash2,
|
|
Plus,
|
|
Power,
|
|
PowerOff,
|
|
X,
|
|
Save
|
|
} from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../shared/components/Loading';
|
|
import EmptyState from '../../shared/components/EmptyState';
|
|
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
|
|
import loyaltyService, { LoyaltyTier, LoyaltyReward } from '../../features/loyalty/services/loyaltyService';
|
|
import Pagination from '../../shared/components/Pagination';
|
|
import { logger } from '../../shared/utils/logger';
|
|
|
|
type Tab = 'users' | 'tiers' | 'rewards';
|
|
|
|
interface UserLoyaltyData {
|
|
user_id: number;
|
|
user_name: string;
|
|
user_email: string;
|
|
tier: LoyaltyTier;
|
|
total_points: number;
|
|
lifetime_points: number;
|
|
available_points: number;
|
|
referral_count: number;
|
|
tier_started_date?: string;
|
|
}
|
|
|
|
const LoyaltyManagementPage: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<Tab>('users');
|
|
const [programEnabled, setProgramEnabled] = useState(true);
|
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
|
|
|
const [users, setUsers] = useState<UserLoyaltyData[]>([]);
|
|
const [tiers, setTiers] = useState<LoyaltyTier[]>([]);
|
|
const [rewards, setRewards] = useState<LoyaltyReward[]>([]);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedTier, setSelectedTier] = useState<number | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const itemsPerPage = 20;
|
|
|
|
// Modals state
|
|
const [showTierModal, setShowTierModal] = useState(false);
|
|
const [showRewardModal, setShowRewardModal] = useState(false);
|
|
const [editingTier, setEditingTier] = useState<LoyaltyTier | null>(null);
|
|
const [editingReward, setEditingReward] = useState<LoyaltyReward | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<{type: 'tier' | 'reward', id: number, name: string} | null>(null);
|
|
|
|
// Tier form state
|
|
const [tierForm, setTierForm] = useState({
|
|
level: 'bronze' as 'bronze' | 'silver' | 'gold' | 'platinum',
|
|
name: '',
|
|
description: '',
|
|
min_points: 0,
|
|
points_earn_rate: 1.0,
|
|
discount_percentage: 0,
|
|
benefits: '',
|
|
icon: '',
|
|
color: '',
|
|
is_active: true
|
|
});
|
|
|
|
// Reward form state
|
|
const [rewardForm, setRewardForm] = useState({
|
|
name: '',
|
|
description: '',
|
|
reward_type: 'discount' as 'discount' | 'room_upgrade' | 'amenity' | 'cashback' | 'voucher',
|
|
points_cost: 0,
|
|
discount_percentage: null as number | null,
|
|
discount_amount: null as number | null,
|
|
max_discount_amount: null as number | null,
|
|
applicable_tier_id: null as number | null,
|
|
min_booking_amount: null as number | null,
|
|
icon: '',
|
|
image: '',
|
|
is_active: true,
|
|
stock_quantity: null as number | null,
|
|
valid_from: '',
|
|
valid_until: ''
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchProgramStatus();
|
|
fetchTiers();
|
|
fetchRewards();
|
|
fetchUsers();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'users') {
|
|
setCurrentPage(1);
|
|
fetchUsers();
|
|
}
|
|
}, [search, selectedTier, activeTab]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'users') {
|
|
fetchUsers();
|
|
}
|
|
}, [currentPage]);
|
|
|
|
const fetchProgramStatus = async () => {
|
|
try {
|
|
const response = await loyaltyService.getProgramStatus();
|
|
setProgramEnabled(response.data.enabled);
|
|
} catch (error: any) {
|
|
logger.error('Failed to load program status', error);
|
|
}
|
|
};
|
|
|
|
const fetchTiers = async () => {
|
|
try {
|
|
const response = await loyaltyService.getAllTiers();
|
|
setTiers(response.data.tiers);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to load loyalty tiers');
|
|
}
|
|
};
|
|
|
|
const fetchRewards = async () => {
|
|
try {
|
|
const response = await loyaltyService.getAllRewardsAdmin();
|
|
setRewards(response.data.rewards);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to load rewards');
|
|
}
|
|
};
|
|
|
|
const fetchUsers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await loyaltyService.getUsersLoyaltyStatus(
|
|
search || undefined,
|
|
selectedTier || undefined,
|
|
currentPage,
|
|
itemsPerPage
|
|
);
|
|
setUsers(response.data.users);
|
|
setTotalPages(response.data.pagination.totalPages);
|
|
setTotalItems(response.data.pagination.total);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to load user loyalty data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleProgram = async () => {
|
|
try {
|
|
setLoadingStatus(true);
|
|
const newStatus = !programEnabled;
|
|
await loyaltyService.updateProgramStatus(newStatus);
|
|
setProgramEnabled(newStatus);
|
|
toast.success(`Loyalty program ${newStatus ? 'enabled' : 'disabled'} successfully`);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to update program status');
|
|
} finally {
|
|
setLoadingStatus(false);
|
|
}
|
|
};
|
|
|
|
const handleTierSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (editingTier) {
|
|
await loyaltyService.updateTier(editingTier.id, tierForm);
|
|
toast.success('Tier updated successfully');
|
|
} else {
|
|
await loyaltyService.createTier(tierForm);
|
|
toast.success('Tier created successfully');
|
|
}
|
|
setShowTierModal(false);
|
|
resetTierForm();
|
|
fetchTiers();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to save tier');
|
|
}
|
|
};
|
|
|
|
const handleRewardSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const rewardData = {
|
|
...rewardForm,
|
|
discount_percentage: rewardForm.discount_percentage || undefined,
|
|
discount_amount: rewardForm.discount_amount || undefined,
|
|
max_discount_amount: rewardForm.max_discount_amount || undefined,
|
|
applicable_tier_id: rewardForm.applicable_tier_id || undefined,
|
|
min_booking_amount: rewardForm.min_booking_amount || undefined,
|
|
stock_quantity: rewardForm.stock_quantity || undefined,
|
|
valid_from: rewardForm.valid_from || undefined,
|
|
valid_until: rewardForm.valid_until || undefined
|
|
};
|
|
|
|
if (editingReward) {
|
|
await loyaltyService.updateReward(editingReward.id, rewardData);
|
|
toast.success('Reward updated successfully');
|
|
} else {
|
|
await loyaltyService.createReward(rewardData);
|
|
toast.success('Reward created successfully');
|
|
}
|
|
setShowRewardModal(false);
|
|
resetRewardForm();
|
|
fetchRewards();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to save reward');
|
|
}
|
|
};
|
|
|
|
const handleEditTier = (tier: LoyaltyTier) => {
|
|
setEditingTier(tier);
|
|
setTierForm({
|
|
level: tier.level as 'bronze' | 'silver' | 'gold' | 'platinum',
|
|
name: tier.name,
|
|
description: tier.description || '',
|
|
min_points: tier.min_points,
|
|
points_earn_rate: tier.points_earn_rate,
|
|
discount_percentage: tier.discount_percentage || 0,
|
|
benefits: tier.benefits || '',
|
|
icon: tier.icon || '',
|
|
color: tier.color || '',
|
|
is_active: tier.is_active ?? true
|
|
});
|
|
setShowTierModal(true);
|
|
};
|
|
|
|
const handleEditReward = (reward: LoyaltyReward) => {
|
|
setEditingReward(reward);
|
|
setRewardForm({
|
|
name: reward.name,
|
|
description: reward.description || '',
|
|
reward_type: reward.reward_type as any,
|
|
points_cost: reward.points_cost,
|
|
discount_percentage: reward.discount_percentage || null,
|
|
discount_amount: reward.discount_amount || null,
|
|
max_discount_amount: reward.max_discount_amount || null,
|
|
applicable_tier_id: reward.applicable_tier_id || null,
|
|
min_booking_amount: reward.min_booking_amount || null,
|
|
icon: reward.icon || '',
|
|
image: reward.image || '',
|
|
is_active: reward.is_active ?? true,
|
|
stock_quantity: reward.stock_quantity || null,
|
|
valid_from: reward.valid_from ? reward.valid_from.split('T')[0] : '',
|
|
valid_until: reward.valid_until ? reward.valid_until.split('T')[0] : ''
|
|
});
|
|
setShowRewardModal(true);
|
|
};
|
|
|
|
const handleDeleteTier = async (id: number) => {
|
|
try {
|
|
await loyaltyService.deleteTier(id);
|
|
toast.success('Tier deleted successfully');
|
|
fetchTiers();
|
|
setShowDeleteConfirm(null);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to delete tier');
|
|
}
|
|
};
|
|
|
|
const handleDeleteReward = async (id: number) => {
|
|
try {
|
|
await loyaltyService.deleteReward(id);
|
|
toast.success('Reward deleted successfully');
|
|
fetchRewards();
|
|
setShowDeleteConfirm(null);
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to delete reward');
|
|
}
|
|
};
|
|
|
|
const resetTierForm = () => {
|
|
setEditingTier(null);
|
|
setTierForm({
|
|
level: 'bronze',
|
|
name: '',
|
|
description: '',
|
|
min_points: 0,
|
|
points_earn_rate: 1.0,
|
|
discount_percentage: 0,
|
|
benefits: '',
|
|
icon: '',
|
|
color: '',
|
|
is_active: true
|
|
});
|
|
};
|
|
|
|
const resetRewardForm = () => {
|
|
setEditingReward(null);
|
|
setRewardForm({
|
|
name: '',
|
|
description: '',
|
|
reward_type: 'discount',
|
|
points_cost: 0,
|
|
discount_percentage: null,
|
|
discount_amount: null,
|
|
max_discount_amount: null,
|
|
applicable_tier_id: null,
|
|
min_booking_amount: null,
|
|
icon: '',
|
|
image: '',
|
|
is_active: true,
|
|
stock_quantity: null,
|
|
valid_from: '',
|
|
valid_until: ''
|
|
});
|
|
};
|
|
|
|
const getTierColor = (level?: string) => {
|
|
switch (level) {
|
|
case 'bronze':
|
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
case 'silver':
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
case 'gold':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'platinum':
|
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-indigo-100 rounded-lg">
|
|
<Award className="w-6 h-6 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Loyalty Program Management</h1>
|
|
<p className="text-gray-600">Manage loyalty program settings, tiers, and rewards</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleToggleProgram}
|
|
disabled={loadingStatus}
|
|
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-semibold transition-colors ${
|
|
programEnabled
|
|
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
: 'bg-green-600 hover:bg-green-700 text-white'
|
|
}`}
|
|
>
|
|
{programEnabled ? (
|
|
<>
|
|
<PowerOff className="w-5 h-5" />
|
|
Disable Program
|
|
</>
|
|
) : (
|
|
<>
|
|
<Power className="w-5 h-5" />
|
|
Enable Program
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Program Status Banner */}
|
|
<div className={`rounded-lg p-4 mb-4 ${
|
|
programEnabled
|
|
? 'bg-green-50 border border-green-200'
|
|
: 'bg-red-50 border border-red-200'
|
|
}`}>
|
|
<div className="flex items-center gap-2">
|
|
{programEnabled ? (
|
|
<>
|
|
<Power className="w-5 h-5 text-green-600" />
|
|
<span className="font-semibold text-green-900">
|
|
Loyalty Program is ENABLED
|
|
</span>
|
|
<span className="text-sm text-green-700">
|
|
- Customers can earn and redeem points
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<PowerOff className="w-5 h-5 text-red-600" />
|
|
<span className="font-semibold text-red-900">
|
|
Loyalty Program is DISABLED
|
|
</span>
|
|
<span className="text-sm text-red-700">
|
|
- No points will be awarded or redeemed
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-lg shadow-sm mb-6 border border-gray-200">
|
|
<div className="border-b border-gray-200">
|
|
<nav className="flex -mb-px">
|
|
{[
|
|
{ id: 'users', label: 'Users', icon: Users },
|
|
{ id: 'tiers', label: 'Tiers', icon: Award },
|
|
{ id: 'rewards', label: 'Rewards', icon: Gift },
|
|
].map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as Tab)}
|
|
className={`flex items-center gap-2 px-6 py-4 font-medium text-sm border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-indigo-600 text-indigo-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<Icon className="w-5 h-5" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{/* Users Tab */}
|
|
{activeTab === 'users' && (
|
|
<>
|
|
{/* Filters */}
|
|
<div className="mb-6">
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or email..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="md:w-64">
|
|
<div className="relative">
|
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
<select
|
|
value={selectedTier || ''}
|
|
onChange={(e) => setSelectedTier(e.target.value ? parseInt(e.target.value) : null)}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent appearance-none bg-white"
|
|
>
|
|
<option value="">All Tiers</option>
|
|
{tiers.map((tier) => (
|
|
<option key={tier.id} value={tier.id}>
|
|
{tier.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
|
<div className="text-sm text-gray-600 mb-1">Total Members</div>
|
|
<div className="text-2xl font-bold text-gray-900">{totalItems.toLocaleString()}</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
|
<div className="text-sm text-gray-600 mb-1">Total Points Distributed</div>
|
|
<div className="text-2xl font-bold text-indigo-600">
|
|
{users.reduce((sum, user) => sum + user.lifetime_points, 0).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
|
<div className="text-sm text-gray-600 mb-1">Active Referrals</div>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{users.reduce((sum, user) => sum + user.referral_count, 0).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow-sm p-4 border border-gray-200">
|
|
<div className="text-sm text-gray-600 mb-1">Available Points</div>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{users.reduce((sum, user) => sum + user.available_points, 0).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Users Table */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
{loading ? (
|
|
<Loading text="Loading loyalty data..." />
|
|
) : users.length === 0 ? (
|
|
<EmptyState
|
|
icon={Users}
|
|
title="No users found"
|
|
description={search || selectedTier ? "Try adjusting your filters" : "No loyalty members yet"}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Customer
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Tier
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Lifetime Points
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Available Points
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Referrals
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Member Since
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{users.map((user) => (
|
|
<tr key={user.user_id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{user.user_name}</div>
|
|
<div className="text-sm text-gray-500">{user.user_email}</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-3 py-1 inline-flex items-center gap-1 text-xs font-semibold rounded-full border ${getTierColor(
|
|
user.tier?.level
|
|
)}`}
|
|
>
|
|
<Award className="w-3 h-3" />
|
|
{user.tier?.name || 'N/A'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-semibold text-gray-900">
|
|
{user.lifetime_points.toLocaleString()}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm font-medium text-indigo-600">
|
|
{user.available_points.toLocaleString()}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-1 text-sm text-gray-900">
|
|
<Users className="w-4 h-4 text-gray-400" />
|
|
{user.referral_count}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{user.tier_started_date
|
|
? new Date(user.tier_started_date).toLocaleDateString()
|
|
: 'N/A'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="px-6 py-4 border-t border-gray-200">
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Tiers Tab */}
|
|
{activeTab === 'tiers' && (
|
|
<>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900">Loyalty Tiers</h2>
|
|
<button
|
|
onClick={() => {
|
|
resetTierForm();
|
|
setShowTierModal(true);
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Tier
|
|
</button>
|
|
</div>
|
|
|
|
{tiers.length === 0 ? (
|
|
<EmptyState
|
|
icon={Award}
|
|
title="No tiers configured"
|
|
description="Create loyalty tiers to get started"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{tiers.map((tier) => (
|
|
<div
|
|
key={tier.id}
|
|
className={`border-2 rounded-lg p-4 ${getTierColor(tier.level)}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Award className="w-5 h-5" />
|
|
<h3 className="font-bold text-lg">{tier.name}</h3>
|
|
</div>
|
|
{!tier.is_active && (
|
|
<span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Inactive</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs mb-3 opacity-90 line-clamp-2">{tier.description}</p>
|
|
<div className="space-y-1 text-xs mb-4">
|
|
<div className="flex justify-between">
|
|
<span className="opacity-75">Min Points:</span>
|
|
<span className="font-semibold">{tier.min_points.toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="opacity-75">Earn Rate:</span>
|
|
<span className="font-semibold">{tier.points_earn_rate}x</span>
|
|
</div>
|
|
{tier.discount_percentage > 0 && (
|
|
<div className="flex justify-between">
|
|
<span className="opacity-75">Discount:</span>
|
|
<span className="font-semibold">{tier.discount_percentage}%</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 mt-4 pt-4 border-t border-current/20">
|
|
<button
|
|
onClick={() => handleEditTier(tier)}
|
|
className="flex-1 px-3 py-1.5 bg-white/20 hover:bg-white/30 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
|
>
|
|
<Edit className="w-3 h-3" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeleteConfirm({ type: 'tier', id: tier.id, name: tier.name })}
|
|
className="px-3 py-1.5 bg-red-100 hover:bg-red-200 text-red-800 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Rewards Tab */}
|
|
{activeTab === 'rewards' && (
|
|
<>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900">Rewards Catalog</h2>
|
|
<button
|
|
onClick={() => {
|
|
resetRewardForm();
|
|
setShowRewardModal(true);
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Reward
|
|
</button>
|
|
</div>
|
|
|
|
{rewards.length === 0 ? (
|
|
<EmptyState
|
|
icon={Gift}
|
|
title="No rewards configured"
|
|
description="Create rewards for customers to redeem"
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{rewards.map((reward) => (
|
|
<div
|
|
key={reward.id}
|
|
className="border border-gray-200 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
|
>
|
|
{reward.image && (
|
|
<img
|
|
src={reward.image}
|
|
alt={reward.name}
|
|
className="w-full h-32 object-cover rounded-lg mb-3"
|
|
/>
|
|
)}
|
|
<div className="flex items-start justify-between mb-2">
|
|
<h4 className="font-semibold text-gray-900">{reward.name}</h4>
|
|
{!reward.is_available ? (
|
|
<span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Unavailable</span>
|
|
) : (
|
|
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">Active</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{reward.description}</p>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-lg font-bold text-indigo-600">
|
|
{reward.points_cost} points
|
|
</span>
|
|
{reward.stock_quantity != null && reward.redeemed_count != null && (
|
|
<span className="text-sm text-gray-500">
|
|
{reward.stock_quantity - reward.redeemed_count} left
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleEditReward(reward)}
|
|
className="flex-1 px-3 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm font-medium flex items-center justify-center gap-1"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeleteConfirm({ type: 'reward', id: reward.id, name: reward.name })}
|
|
className="px-3 py-2 bg-red-100 text-red-800 rounded-lg hover:bg-red-200 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier Modal */}
|
|
{showTierModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
{editingTier ? 'Edit Tier' : 'Create New Tier'}
|
|
</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowTierModal(false);
|
|
resetTierForm();
|
|
}}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleTierSubmit} className="p-6 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
|
|
<select
|
|
value={tierForm.level}
|
|
onChange={(e) => setTierForm({ ...tierForm, level: e.target.value as any })}
|
|
disabled={!!editingTier}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
>
|
|
<option value="bronze">Bronze</option>
|
|
<option value="silver">Silver</option>
|
|
<option value="gold">Gold</option>
|
|
<option value="platinum">Platinum</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={tierForm.name}
|
|
onChange={(e) => setTierForm({ ...tierForm, name: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
<textarea
|
|
value={tierForm.description}
|
|
onChange={(e) => setTierForm({ ...tierForm, description: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Min Points</label>
|
|
<input
|
|
type="number"
|
|
value={tierForm.min_points}
|
|
onChange={(e) => setTierForm({ ...tierForm, min_points: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Points Earn Rate</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={tierForm.points_earn_rate}
|
|
onChange={(e) => setTierForm({ ...tierForm, points_earn_rate: parseFloat(e.target.value) || 1.0 })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Discount %</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={tierForm.discount_percentage}
|
|
onChange={(e) => setTierForm({ ...tierForm, discount_percentage: parseFloat(e.target.value) || 0 })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Benefits</label>
|
|
<textarea
|
|
value={tierForm.benefits}
|
|
onChange={(e) => setTierForm({ ...tierForm, benefits: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
rows={3}
|
|
placeholder="List of benefits for this tier..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Icon (emoji/text)</label>
|
|
<input
|
|
type="text"
|
|
value={tierForm.icon}
|
|
onChange={(e) => setTierForm({ ...tierForm, icon: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="🏆 or icon name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Color (hex)</label>
|
|
<input
|
|
type="text"
|
|
value={tierForm.color}
|
|
onChange={(e) => setTierForm({ ...tierForm, color: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="#FFD700"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="tier_active"
|
|
checked={tierForm.is_active}
|
|
onChange={(e) => setTierForm({ ...tierForm, is_active: e.target.checked })}
|
|
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
|
/>
|
|
<label htmlFor="tier_active" className="text-sm font-medium text-gray-700">
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowTierModal(false);
|
|
resetTierForm();
|
|
}}
|
|
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{editingTier ? 'Update Tier' : 'Create Tier'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reward Modal */}
|
|
{showRewardModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
{editingReward ? 'Edit Reward' : 'Create New Reward'}
|
|
</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowRewardModal(false);
|
|
resetRewardForm();
|
|
}}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleRewardSubmit} className="p-6 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Reward Name</label>
|
|
<input
|
|
type="text"
|
|
value={rewardForm.name}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, name: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Reward Type</label>
|
|
<select
|
|
value={rewardForm.reward_type}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, reward_type: e.target.value as any })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
>
|
|
<option value="discount">Discount</option>
|
|
<option value="room_upgrade">Room Upgrade</option>
|
|
<option value="amenity">Amenity</option>
|
|
<option value="cashback">Cashback</option>
|
|
<option value="voucher">Voucher</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
<textarea
|
|
value={rewardForm.description}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, description: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Points Cost</label>
|
|
<input
|
|
type="number"
|
|
value={rewardForm.points_cost}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, points_cost: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
required
|
|
/>
|
|
</div>
|
|
{rewardForm.reward_type === 'discount' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Discount %</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={rewardForm.discount_percentage || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, discount_percentage: e.target.value ? parseFloat(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Discount Amount (fixed)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={rewardForm.discount_amount || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, discount_amount: e.target.value ? parseFloat(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Max Discount Amount</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={rewardForm.max_discount_amount || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, max_discount_amount: e.target.value ? parseFloat(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Min Booking Amount</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={rewardForm.min_booking_amount || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, min_booking_amount: e.target.value ? parseFloat(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Applicable Tier</label>
|
|
<select
|
|
value={rewardForm.applicable_tier_id || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, applicable_tier_id: e.target.value ? parseInt(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
<option value="">All Tiers</option>
|
|
{tiers.map((tier) => (
|
|
<option key={tier.id} value={tier.id}>
|
|
{tier.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Stock Quantity</label>
|
|
<input
|
|
type="number"
|
|
value={rewardForm.stock_quantity || ''}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, stock_quantity: e.target.value ? parseInt(e.target.value) : null })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="Unlimited if empty"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
<select
|
|
value={rewardForm.is_active ? 'active' : 'inactive'}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, is_active: e.target.value === 'active' })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Valid From</label>
|
|
<input
|
|
type="date"
|
|
value={rewardForm.valid_from}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, valid_from: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Valid Until</label>
|
|
<input
|
|
type="date"
|
|
value={rewardForm.valid_until}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, valid_until: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Icon</label>
|
|
<input
|
|
type="text"
|
|
value={rewardForm.icon}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, icon: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="Icon name or emoji"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Image URL</label>
|
|
<input
|
|
type="text"
|
|
value={rewardForm.image}
|
|
onChange={(e) => setRewardForm({ ...rewardForm, image: e.target.value })}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
|
placeholder="Image URL"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowRewardModal(false);
|
|
resetRewardForm();
|
|
}}
|
|
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{editingReward ? 'Update Reward' : 'Create Reward'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
{showDeleteConfirm && (
|
|
<ConfirmationDialog
|
|
isOpen={true}
|
|
title={`Delete ${showDeleteConfirm.type === 'tier' ? 'Tier' : 'Reward'}`}
|
|
message={`Are you sure you want to delete "${showDeleteConfirm.name}"? This action cannot be undone.`}
|
|
confirmText="Delete"
|
|
cancelText="Cancel"
|
|
onConfirm={() => {
|
|
if (showDeleteConfirm.type === 'tier') {
|
|
handleDeleteTier(showDeleteConfirm.id);
|
|
} else {
|
|
handleDeleteReward(showDeleteConfirm.id);
|
|
}
|
|
}}
|
|
onClose={() => setShowDeleteConfirm(null)}
|
|
variant="danger"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LoyaltyManagementPage;
|