Files
Hotel-Booking/Frontend/src/pages/staff/LoyaltyManagementPage.tsx
Iliyan Angelov 62c1fe5951 updates
2025-12-01 06:50:10 +02:00

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;