Chương Trình Loyalty (Loyalty Programs)¶
Trang Chương Trình Loyalty (Loyalty Programs) cho phép quản lý các chương trình khách hàng thân thiết của khách sạn, bao gồm tạo mới, chỉnh sửa, xem chi tiết, quản lý tiers, theo dõi transactions và test point calculation.
Tổng Quan¶
📸 Chụp từ: http://localhost:8082/customer-management/loyalty.html
Trang tổng quan hiển thị:
- Page Header: Title với GiftOutlined icon
- Statistics Cards: 4 KPI cards (Total Programs, Active Programs, Total Members, Redemption Rate)
- Programs Table: Danh sách loyalty programs với actions dropdown
- Create Program Button: Tạo chương trình mới
- Action Menu: View, Edit, Manage Tiers, View Transactions, Test Calculation, Delete
Key Components¶
graph TD
A[Loyalty Programs Page] --> B[Statistics Row]
A --> C[Programs Table]
A --> D[Action Modals]
B --> B1[Total Programs]
B --> B2[Active Programs]
B --> B3[Total Members]
B --> B4[Redemption Rate]
C --> C1[Program Column]
C --> C2[Status Column]
C --> C3[Rates Column]
C --> C4[Members/Usage Column]
C --> C5[Actions Dropdown]
D --> D1[Create Program Modal]
D --> D2[Edit Program Modal]
D --> D3[Point Calculation Modal]
D --> D4[Delete Confirmation]
Statistics Cards¶
📸 Chụp từ: http://localhost:8082/customer-management/loyalty.html
4 KPI Cards¶
Card 1: Total Programs
{
title: "Total Programs" | "Total" (mobile)
value: stats.totalPrograms
prefix: GiftOutlined icon
valueStyle: { fontSize: isMobile ? '18px' : undefined }
}
Card 2: Active Programs
{
title: "Active Programs" | "Active" (mobile)
value: stats.activePrograms
prefix: StarOutlined icon
valueStyle: {
color: '#3f8600',
fontSize: isMobile ? '18px' : undefined
}
}
Card 3: Total Members
{
title: "Total Members" | "Members" (mobile)
value: stats.totalMembers
prefix: UserOutlined icon
valueStyle: {
color: '#1890ff',
fontSize: isMobile ? '18px' : undefined
}
}
Card 4: Redemption Rate
{
title: "Redemption Rate" | "Redeem Rate" (mobile)
value: stats.redemptionRate
precision: 1
suffix: "%"
prefix: TrophyOutlined icon
valueStyle: {
color: '#722ed1',
fontSize: isMobile ? '18px' : undefined
}
}
Layout: - Desktop: Row với 4 columns (Col xs={12} sm={6}) - Mobile: 2x2 grid - Gutter: 16px (desktop), 8px (mobile)
Programs Table¶
📸 Chụp từ: http://localhost:8082/customer-management/loyalty.html
Table Columns¶
Column 1: Program
Desktop:
render: (record) => (
<Space>
<Avatar
icon={<GiftOutlined />}
size="default"
style={{ backgroundColor: '#1890ff' }}
/>
<div>
<Text strong>{record.name}</Text>
<br />
<Text type="secondary">{record.description}</Text>
</div>
</Space>
)
Mobile:
render: (record) => (
<Space>
<Avatar
icon={<GiftOutlined />}
size="small"
style={{ backgroundColor: '#1890ff' }}
/>
<div>
<Text strong style={{ fontSize: '12px' }}>
{record.name.length > 15
? `${record.name.substring(0, 15)}...`
: record.name
}
</Text>
<br />
<Tag color={record.isActive ? 'green' : 'red'} size="small">
{record.isActive ? 'Active' : 'Inactive'}
</Tag>
</div>
</Space>
)
Column 2: Status (Desktop only)
render: (record) => (
<Space direction="vertical" size="small">
<Tag color={record.isActive ? 'green' : 'red'}>
{record.isActive ? 'Active' : 'Inactive'}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.endDate
? `Ends on ${dayjs(record.endDate).format('MMM DD, YYYY')}`
: 'No end date'
}
</Text>
</Space>
)
Column 3: Rates
Desktop:
render: (record) => {
const earnRate = record.earningRules?.earnRate || record.earnRate;
const redeemRate = record.redemptionRules?.redeemRate || record.redeemRate;
const minSpend = record.earningRules?.minSpendForEarning || record.minSpendForEarning;
const cashback = record.redemptionRules?.cashbackPercentage || record.cashbackPercentage;
return (
<Space direction="vertical" size="small">
<div>
<Text strong>Earn: </Text>
<Text>{earnRate} points/{parseCurrencyFromAPI(minSpend).toVND()}</Text>
</div>
<div>
<Text strong>Redeem: </Text>
<Text>{redeemRate} points per VND</Text>
</div>
{cashback && (
<div>
<Text strong>Cashback: </Text>
<Text>{cashback}%</Text>
</div>
)}
</Space>
);
}
Mobile (shortened):
<Space direction="vertical" size="small">
<div>
<Text strong style={{ fontSize: '10px' }}>Earn: </Text>
<Text style={{ fontSize: '10px' }}>{earnRate} pts</Text>
</div>
<div>
<Text strong style={{ fontSize: '10px' }}>Redeem: </Text>
<Text style={{ fontSize: '10px' }}>{redeemRate} pts</Text>
</div>
{cashback && (
<div>
<Text strong style={{ fontSize: '10px' }}>CB: </Text>
<Text style={{ fontSize: '10px' }}>{cashback}%</Text>
</div>
)}
</Space>
Column 4: Members & Usage (Desktop only)
render: (record) => (
<Space direction="vertical" size="small">
<div>
<Text strong>{record.totalMembers?.toLocaleString()}</Text>
<Text type="secondary"> members</Text>
</div>
<div>
<Text type="secondary">
{record.totalPointsIssued?.toLocaleString()} issued,
{record.totalPointsRedeemed?.toLocaleString()} redeemed
</Text>
</div>
</Space>
)
Column 5: Actions
Dropdown menu với các actions:
| Action | Icon | Target | Description |
|---|---|---|---|
| View Details | EyeOutlined | /dashboard/loyalty/${id} |
Xem chi tiết program |
| Edit Program | EditOutlined | Modal | Chỉnh sửa program |
| Manage Tiers | TrophyOutlined | /dashboard/loyalty/${id}/tiers |
Quản lý tiers |
| View Transactions | StarOutlined | /dashboard/loyalty/${id}/transactions |
Xem transactions |
| Test Calculation | PercentageOutlined | Modal | Test point calculation |
| Delete Program | DeleteOutlined | Confirm | Xóa program (danger) |
Table Pagination: - Desktop: 20 items per page, showSizeChanger, showQuickJumper, showTotal - Mobile: 10 items per page, simple pagination - Scroll: x={400} on mobile
Create Program Modal¶
📸 Chụp từ: Click "Create Program" button
Modal Configuration:
<Modal
title="Create Loyalty Program"
open={isCreateModalVisible}
onOk={handleCreateProgram}
onCancel={() => {
setIsCreateModalVisible(false);
form.resetFields();
}}
confirmLoading={createProgramMutation.isPending}
width={isMobile ? '95vw' : 800}
styles={{ body: { padding: isMobile ? '8px' : undefined } }}
>
Form Fields¶
Section 1: Basic Information
📸 Chụp từ: Create Program Modal → Basic section
Field 1: Program Name (Required)
<Form.Item
name="name"
label="Program Name"
rules={[{ required: true, message: 'Please enter program name' }]}
>
<Input
placeholder="e.g., Gold Member Program"
size={isMobile ? 'small' : 'middle'}
/>
</Form.Item>
Field 2: Description (Required)
<Form.Item
name="description"
label="Description"
rules={[{ required: true, message: 'Please enter description' }]}
>
<TextArea
rows={isMobile ? 2 : 3}
placeholder="Describe the loyalty program..."
size={isMobile ? 'small' : 'middle'}
/>
</Form.Item>
Section 2: Dates
Row 1: Start Date & End Date
<Row gutter={isMobile ? [8, 4] : [16, 16]}>
<Col xs={24} sm={12}>
<Form.Item
name="startDate"
label="Start Date"
rules={[{ required: true, message: 'Please select start date' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="endDate" label="End Date (Optional)">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
Section 3: Earning & Redemption Rules
📸 Chụp từ: Create Program Modal → Rates section
Row 2: Earn Rate, Redeem Rate, Min Spend
<Row gutter={isMobile ? [8, 4] : [16, 16]}>
<Col xs={24} sm={8}>
<Form.Item
name="earnRate"
label="Earn Rate (points)"
rules={[{ required: true, message: 'Please enter earn rate' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item
name="redeemRate"
label="Redeem Rate (points per VND)"
rules={[{ required: true, message: 'Please enter redeem rate' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item
name="minSpendForEarning"
label="Min Spend (VND)"
rules={[{ required: true, message: 'Please enter minimum spend' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
Row 3: Max Points, Tier Threshold, Cashback
<Row gutter={isMobile ? [8, 4] : [16, 16]}>
<Col xs={24} sm={8}>
<Form.Item name="maxPointsPerTransaction" label="Max Points Per Transaction">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name="tierUpgradeSpendThreshold" label="Tier Upgrade Spend (VND)">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name="cashbackPercentage" label="Cashback %">
<InputNumber min={0} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
Section 4: Program Status
<Form.Item name="isActive" valuePropName="checked" initialValue={true}>
<Switch
checkedChildren="Active"
unCheckedChildren="Inactive"
size={isMobile ? 'small' : 'default'}
/>
<Text style={{ marginLeft: 8 }}>Program Status</Text>
</Form.Item>
Submit Handler:
const handleCreateProgram = async () => {
const values = await form.validateFields();
const programData = {
tenantId: user?.tenantId,
hotelId: user?.hotelId,
name: values.name,
description: values.description,
isActive: values.isActive !== false,
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
earningRules: {
earnRate: values.earnRate,
minSpendForEarning: parseFloat(values.minSpendForEarning?.toString() || '0'),
maxPointsPerTransaction: values.maxPointsPerTransaction,
},
redemptionRules: {
redeemRate: values.redeemRate,
cashbackPercentage: values.cashbackPercentage,
},
pointsValidityPeriod: 12, // Default 12 months
};
createProgramMutation.mutate(programData);
};
Edit Program Modal¶
📸 Chụp từ: Actions dropdown → Click "Edit Program"
Modal Configuration:
<Modal
title="Edit Loyalty Program"
open={isEditModalVisible}
onOk={handleUpdateProgram}
onCancel={() => {
setIsEditModalVisible(false);
setEditingProgram(null);
setSwitchValue(true);
form.resetFields();
}}
confirmLoading={updateProgramMutation.isPending}
width={isMobile ? '95vw' : 800}
key={`edit-${editingProgram?.id || 'new'}`}
>
Form Structure: Same as Create Modal
Key Differences:
-
Pre-filled Values:
const handleEditProgram = (program: LoyaltyProgram) => { setEditingProgram(program); const earningRules = program.earningRules || {}; const redemptionRules = program.redemptionRules || {}; const isActiveValue = Boolean(program.isActive); setSwitchValue(isActiveValue); setIsEditModalVisible(true); setTimeout(() => { form.resetFields(); form.setFieldsValue({ name: program.name, description: program.description, type: program.type, isActive: isActiveValue, startDate: program.startDate ? dayjs(program.startDate) : null, endDate: program.endDate ? dayjs(program.endDate) : null, earnRate: earningRules.earnRate || program.earnRate, minSpendForEarning: earningRules.minSpendForEarning || (program.minSpendForEarning ? parseCurrencyFromAPI(program.minSpendForEarning).toNumber() : null), maxPointsPerTransaction: earningRules.maxPointsPerTransaction || program.maxPointsPerTransaction, redeemRate: redemptionRules.redeemRate || program.redeemRate, cashbackPercentage: redemptionRules.cashbackPercentage || program.cashbackPercentage, pointsValidityPeriod: program.pointsValidityPeriod || 12, }); }, 200); }; -
Controlled Switch:
-
Update Handler:
const handleUpdateProgram = async () => { const values = await form.validateFields(); const programData = { name: values.name, description: values.description, isActive: values.isActive, startDate: values.startDate?.format('YYYY-MM-DD'), endDate: values.endDate?.format('YYYY-MM-DD'), earningRules: { earnRate: values.earnRate, minSpendForEarning: parseFloat(values.minSpendForEarning?.toString() || '0'), maxPointsPerTransaction: values.maxPointsPerTransaction, }, redemptionRules: { redeemRate: values.redeemRate, cashbackPercentage: values.cashbackPercentage, }, }; updateProgramMutation.mutate(programData); };
Point Calculation Modal¶
📸 Chụp từ: Actions dropdown → Click "Test Calculation"
Component:
<PointCalculationModal
open={isPointCalculationModalVisible}
onCancel={() => {
setIsPointCalculationModalVisible(false);
setSelectedProgramForCalculation(null);
}}
programId={selectedProgramForCalculation || ''}
/>
Usage: - Allows testing point calculation for a program - Input: Booking amount (VND) - Output: Points earned based on program's earning rules - Useful for verifying program configuration
Example Calculation:
Program: Gold Member Program
Earn Rate: 10 points
Min Spend: 100,000 VND
Booking Amount: 2,500,000 VND
Calculation:
Eligible amount = 2,500,000 VND
Points = (2,500,000 / 100,000) * 10
Result = 250 points earned
Delete Program Confirmation¶
📸 Chụp từ: Actions dropdown → Click "Delete Program"
Confirm Modal:
const handleDeleteProgram = (programId: string) => {
Modal.confirm({
title: 'Delete Loyalty Program',
content: 'Are you sure you want to delete this loyalty program? This action cannot be undone.',
onOk: () => deleteProgramMutation.mutate(programId),
});
};
Delete Mutation:
const deleteProgramMutation = useMutation({
mutationFn: async (programId: string) => {
await apiClient.instance.delete(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs/${programId}`
);
},
onSuccess: () => {
message.success('Program deleted successfully');
queryClient.invalidateQueries({ queryKey: ['loyalty-programs'] });
queryClient.invalidateQueries({ queryKey: ['loyalty-stats'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to delete program');
},
});
Mobile Optimization¶
📸 Chụp từ: http://localhost:8082/customer-management/loyalty.html (mobile device)
Mobile-Specific Features¶
Responsive Breakpoints:
Size Adjustments:
| Element | Desktop | Mobile |
|---|---|---|
| Page Padding | 0 | 4px |
| Title Level | 2 | 4 (18px) |
| Card Size | default | small |
| Button Size | default | small |
| Avatar Size | default | small |
| Stats Font | default | 18px |
| Table Size | default | small |
| Table Scroll | undefined | x={400} |
| Pagination | Full | Simple |
| Page Size | 20 | 10 |
| Modal Width | 800px | 95vw |
| Gutter | [16, 16] | [8, 8] |
Layout Changes:
- Statistics Cards
- Desktop: 4 columns (Col xs={12} sm={6})
- Mobile: 2x2 grid
-
Abbreviated titles
-
Table Columns
- Desktop: 5 columns (Program, Status, Rates, Members/Usage, Actions)
- Mobile: 3 columns (Program, Rates, Actions)
- Status hidden on mobile (moved into Program column as tag)
-
Members/Usage hidden on mobile
-
Program Name
- Desktop: Full name
-
Mobile: Truncated to 15 chars with "..."
-
Rates Display
- Desktop: Full text ("Earn: 10 points/100,000 ₫")
-
Mobile: Abbreviated ("Earn: 10 pts", font-size: 10px)
-
Buttons
- Desktop: "Create Program"
-
Mobile: "Create"
-
Form Inputs
- Desktop: size="middle"
-
Mobile: size="small"
-
TextArea
- Desktop: 3 rows
-
Mobile: 2 rows
-
Modal Body
- Desktop: default padding
- Mobile: padding: 8px
API Endpoints¶
Fetch Loyalty Programs¶
Endpoint:
Response:
interface LoyaltyProgramsListResponse {
data: LoyaltyProgram[]
total: number
page?: number
limit?: number
totalPages?: number
}
interface LoyaltyProgram {
id: string
tenantId: string
hotelId: string
name: string
description: string
isActive: boolean
startDate: string
endDate?: string
earningRules: {
earnRate: number
minSpendForEarning: number
maxPointsPerTransaction?: number
}
redemptionRules: {
redeemRate: number
cashbackPercentage?: number
}
pointsValidityPeriod: number
totalMembers?: number
totalPointsIssued?: number
totalPointsRedeemed?: number
createdAt: string
updatedAt: string
}
Fetch Loyalty Statistics¶
Endpoint:
Response:
interface LoyaltyProgramStatsResponse {
totalPrograms: number
activePrograms: number
totalMembers: number
redemptionRate: number // Percentage
}
Create Loyalty Program¶
Endpoint:
Request Body:
interface CreateLoyaltyProgramRequest {
tenantId: string
hotelId: string
name: string
description: string
isActive: boolean
startDate: string // YYYY-MM-DD
endDate?: string // YYYY-MM-DD
earningRules: {
earnRate: number
minSpendForEarning: number
maxPointsPerTransaction?: number
}
redemptionRules: {
redeemRate: number
cashbackPercentage?: number
}
pointsValidityPeriod: number // Months
}
Success Response:
Update Loyalty Program¶
Endpoint:
Request Body:
interface UpdateLoyaltyProgramRequest {
name?: string
description?: string
isActive?: boolean
startDate?: string
endDate?: string
earningRules?: {
earnRate?: number
minSpendForEarning?: number
maxPointsPerTransaction?: number
}
redemptionRules?: {
redeemRate?: number
cashbackPercentage?: number
}
}
Success Response:
Delete Loyalty Program¶
Endpoint:
Success Response:
Data Flow¶
sequenceDiagram
participant U as User
participant C as Component
participant API as API Gateway
participant CRM as CRM Service
participant DB as Database
U->>C: Open loyalty programs page
C->>API: GET /api/crm/tenants/{tenantId}/loyalty-programs
API->>CRM: Forward request
CRM->>DB: Query programs
DB-->>CRM: Return programs
CRM-->>API: Return programs
API-->>C: Return programs
C->>API: GET /api/crm/tenants/{tenantId}/loyalty-programs/stats
API->>CRM: Forward request
CRM->>DB: Query stats
DB-->>CRM: Return stats
CRM-->>API: Return stats
API-->>C: Return stats
C-->>U: Display programs table & stats
U->>C: Click "Create Program"
C-->>U: Show create modal
U->>C: Fill form & submit
C->>API: POST /api/crm/tenants/{tenantId}/loyalty-programs
API->>CRM: Forward create request
CRM->>DB: Insert program
DB-->>CRM: Confirm insert
CRM-->>API: Return created program
API-->>C: Return created program
C->>C: Invalidate queries
C->>API: Refetch programs & stats
API-->>C: Return fresh data
C-->>U: Show success & updated list
Query & State Management¶
React Query Hooks¶
Programs Query:
const { data: programsData, isLoading } = useQuery<LoyaltyProgramsListResponse>({
queryKey: ['loyalty-programs', user?.tenantId],
queryFn: async () => {
const response = await apiClient.instance.get<LoyaltyProgramsListResponse>(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs`
);
return response.data;
},
enabled: !!user?.tenantId,
});
Stats Query:
const { data: stats } = useQuery<LoyaltyProgramStatsResponse>({
queryKey: ['loyalty-stats', user?.tenantId],
queryFn: async () => {
const response = await apiClient.instance.get<LoyaltyProgramStatsResponse>(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs/stats`
);
return response.data;
},
enabled: !!user?.tenantId,
});
Mutations¶
Create Program Mutation:
const createProgramMutation = useMutation({
mutationFn: async (programData: CreateLoyaltyProgramRequest) => {
const response = await apiClient.instance.post<BaseLoyaltyProgram>(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs`,
programData
);
return response.data;
},
onSuccess: () => {
message.success('Program created successfully');
setIsCreateModalVisible(false);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['loyalty-programs'] });
queryClient.invalidateQueries({ queryKey: ['loyalty-stats'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to create program');
},
});
Update Program Mutation:
const updateProgramMutation = useMutation({
mutationFn: async (programData: UpdateLoyaltyProgramRequest) => {
const response = await apiClient.instance.patch<BaseLoyaltyProgram>(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs/${editingProgram?.id}`,
programData
);
return response.data;
},
onSuccess: () => {
message.success('Program updated successfully');
setIsEditModalVisible(false);
setEditingProgram(null);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['loyalty-programs'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to update program');
},
});
Delete Program Mutation:
const deleteProgramMutation = useMutation({
mutationFn: async (programId: string) => {
await apiClient.instance.delete(
`/api/crm/tenants/${user?.tenantId}/loyalty-programs/${programId}`
);
},
onSuccess: () => {
message.success('Program deleted successfully');
queryClient.invalidateQueries({ queryKey: ['loyalty-programs'] });
queryClient.invalidateQueries({ queryKey: ['loyalty-stats'] });
},
onError: (error: any) => {
message.error(error.response?.data?.message || 'Failed to delete program');
},
});
Helper Functions¶
Type Icons & Colors¶
const getTypeIcon = (type: string) => {
switch (type) {
case 'POINTS_BASED': return <StarOutlined />;
case 'TIER_BASED': return <CrownOutlined />;
case 'CASHBACK': return <PercentageOutlined />;
default: return <GiftOutlined />;
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'POINTS_BASED': return 'blue';
case 'TIER_BASED': return 'purple';
case 'CASHBACK': return 'green';
default: return 'default';
}
};
Earning Rules Extraction¶
// Extract from earningRules object or fallback to direct properties
const earningRules = record.earningRules || {};
const earnRate = earningRules.earnRate || record.earnRate;
const minSpend = earningRules.minSpendForEarning || record.minSpendForEarning;
const maxPoints = earningRules.maxPointsPerTransaction || record.maxPointsPerTransaction;
Redemption Rules Extraction¶
const redemptionRules = record.redemptionRules || {};
const redeemRate = redemptionRules.redeemRate || record.redeemRate;
const cashback = redemptionRules.cashbackPercentage || record.cashbackPercentage;
Navigation¶
Action Menu Routes:
| Action | Route Pattern |
|---|---|
| View Details | /dashboard/loyalty/${programId} |
| Manage Tiers | /dashboard/loyalty/${programId}/tiers |
| View Transactions | /dashboard/loyalty/${programId}/transactions |
Navigation Usage:
const navigate = useNavigate();
// View program details
navigate(`/dashboard/loyalty/${record.id}`);
// Manage tiers
navigate(`/dashboard/loyalty/${record.id}/tiers`);
// View transactions
navigate(`/dashboard/loyalty/${record.id}/transactions`);
Tips & Best Practices¶
💡 Tip 1: Program Configuration - Set realistic earn rates that encourage engagement without eroding profitability - Min spend threshold prevents gaming the system with small transactions - Max points per transaction caps exposure on high-value bookings
💡 Tip 2: Redemption Strategy - Higher redemption rates = faster point burn = more repeat bookings - Lower redemption rates = higher perceived value but slower engagement - Balance redemption rate với profit margins
💡 Tip 3: Cashback Programs - Cashback percentage should be sustainable (typically 1-5%) - Consider cashback as deferred revenue, not immediate discount - Track cashback liability in financial reports
💡 Tip 4: Program Lifecycle - Set end dates for promotional programs - Monitor redemption rate vs industry benchmarks (20-40% typical) - Deactivate underperforming programs instead of deleting (preserve history)
💡 Tip 5: Testing Point Calculation - Always use "Test Calculation" before activating new programs - Verify calculations with edge cases (min spend, max points) - Document calculation examples for staff training
💡 Tip 6: Member Engagement - Active programs attract more members - Communicate program benefits clearly in description - Review totalMembers and totalPointsIssued weekly to gauge success
FAQs¶
1. Làm thế nào để tính earn rate và redeem rate hợp lý?¶
Câu trả lời:
Earn Rate Calculation:
Step 1: Determine Target Reward % - Industry standard: 2-5% of booking value - Luxury hotels: 5-10% - Budget hotels: 1-3%
Step 2: Calculate Earn Rate
Example:
Target reward = 5% of booking value
Min spend = 100,000 VND
Desired points for min spend = 100,000 * 0.05 = 5,000 VND value
If redeemRate = 100 points per 1,000 VND:
Points needed for 5,000 VND = 500 points
Therefore: earnRate = 500 points per 100,000 VND spend
Redeem Rate Calculation:
Step 1: Set Point Value - Common: 100 points = 1,000 VND discount (1%) - Generous: 100 points = 2,000 VND discount (2%) - Conservative: 100 points = 500 VND discount (0.5%)
Step 2: Calculate Redeem Rate
If 100 points = 1,000 VND:
redeemRate = 100 points per VND (need 100 pts to get 1 VND discount)
Simpler way:
redeemRate = pointsNeeded / discountAmount
redeemRate = 100 / 1000 = 0.1 points per VND
Balance Example:
Guest spends: 2,000,000 VND
earnRate: 10 points per 100,000 VND
→ Earns: (2,000,000 / 100,000) * 10 = 200 points
Guest redeems: 200 points
redeemRate: 100 points = 10,000 VND
→ Gets: (200 / 100) * 10,000 = 20,000 VND discount
→ Effective reward: 20,000 / 2,000,000 = 1% cashback
Best Practice: - Earn rate should give 3-5% equivalent value - Redeem rate should require 50-100 bookings for free night - Test with Point Calculation Modal before launch
2. Nên set End Date cho loyalty program không?¶
Câu trả lời:
Set End Date khi:
✅ Promotional Programs (3-6 months) - Seasonal campaigns (Summer Bonus Points) - Limited-time offers (Double Points Weekend) - New hotel grand opening promotions
✅ Pilot Programs (6-12 months) - Testing new loyalty concept - Trial run before permanent program - A/B testing different reward structures
✅ Event-Based Programs - Conference season loyalty - Holiday season specials - Annual campaign refreshes
NO End Date khi:
✅ Core Loyalty Programs - Main hotel loyalty program (Gold/Silver/Bronze) - Permanent guest rewards - Long-term customer retention strategy
Why? - Members expect stability in core programs - Builds trust and long-term relationship - Simplifies communications (no expiry anxiety)
Recommendation:
Structure:
1. Core Program: NO end date (e.g., "Elite Member Program")
2. Bonus Campaigns: WITH end date (e.g., "Summer 2x Points - ends Aug 31")
Frontend Display:
- No end date: Shows "No end date" (green = permanent)
- Has end date: Shows "Ends on MMM DD, YYYY" (yellow = time-limited)
3. Cashback và Points-based program khác nhau như thế nào?¶
Câu trả lời:
Points-Based Program:
Mechanics: - Guest earns points on spending - Points accumulate in account - Redeem points for discounts/rewards
Pros: - Gamification (members love accumulating) - Encourages repeat bookings to reach redemption thresholds - Flexibility in reward catalog (rooms, F&B, spa) - Perceived value often > actual cost
Cons: - Requires point tracking infrastructure - Complex for guests to understand value - Liability tracking for outstanding points
Best For: - Hotels wanting long-term engagement - Luxury/upscale properties - Multi-property chains (transfer points)
Example:
Guest books: 3,000,000 VND room
earnRate: 10 points / 100,000 VND
→ Earns 300 points
After 5 bookings: 1,500 points
redeemRate: 100 points = 10,000 VND
→ Can redeem for 150,000 VND discount
Cashback Program:
Mechanics: - Guest earns % cashback on spending - Cashback applied as immediate credit OR accumulated for next booking - Simple % calculation
Pros: - Simple to understand (5% cashback = clear value) - Immediate gratification - Easy to communicate in marketing - Lower infrastructure complexity
Cons: - Less gamification appeal - Harder to differentiate from competitors - Direct cost impact (5% cashback = 5% margin hit)
Best For: - Budget/mid-range hotels - Price-sensitive markets - Simple loyalty without complexity - Competing with OTA cashback programs
Example:
Guest books: 3,000,000 VND room
cashbackPercentage: 5%
→ Earns 150,000 VND cashback credit
Next booking: 2,500,000 VND
→ Pays only 2,350,000 VND (after cashback)
Hybrid Approach:
Many hotels combine both:
earningRules: {
earnRate: 10, // Earn points
minSpendForEarning: 100000,
}
redemptionRules: {
redeemRate: 100, // Redeem points
cashbackPercentage: 2, // PLUS 2% cashback
}
This gives: - Points for gamification - Cashback for immediate value - Best of both worlds
4. Max Points Per Transaction có tác dụng gì?¶
Câu trả lời:
Purpose:
Caps exposure on high-value bookings to prevent: - Excessive point liability - Gaming the system (booking suites to farm points) - Unsustainable reward costs
Example Without Cap:
VIP books Presidential Suite: 50,000,000 VND
earnRate: 10 points / 100,000 VND
→ Earns 5,000 points
If redeemRate = 100 pts = 10,000 VND:
5,000 pts = 500,000 VND discount
→ 1% effective reward (ok)
But if same guest books 10 times:
50,000 pts = 5,000,000 VND liability
→ Hotel owes free suite worth 10M+ VND
Example With Cap:
maxPointsPerTransaction: 1000
VIP books Presidential Suite: 50,000,000 VND
earnRate: 10 points / 100,000 VND
Calculated: 5,000 points
→ Capped at: 1,000 points (max)
Effective reward: 100,000 VND (0.2% - controlled)
When to Use:
✅ High-value properties - Suites > 10M VND/night - Extended stays (monthly bookings) - Corporate group bookings
✅ Aggressive earn rates - earnRate > 10 pts / 100K VND - Promotional double/triple points
❌ Not needed for: - Budget hotels (rooms < 2M VND) - Conservative earn rates (< 5 pts)
Recommended Settings:
| Hotel Type | Room Range | Max Points |
|---|---|---|
| Budget | 500K-1M | No cap needed |
| Mid-range | 1M-3M | 500 points |
| Upscale | 3M-10M | 1,000 points |
| Luxury | 10M+ | 2,000 points |
Communication:
In program description, be transparent:
This sets expectations and avoids disappointment.
5. Làm thế nào để migrate existing members sang program mới?¶
Câu trả lời:
Scenario:
Hotel muốn launch program mới với better rewards, cần migrate existing members từ old program.
Migration Strategy:
Option 1: Hard Cutover (Not Recommended)
❌ Deactivate old program ❌ Force all members to new program ❌ Convert points with fixed ratio
Problem: - Member confusion - Point value changes (angry members) - Lost goodwill
Option 2: Gradual Migration (Recommended)
✅ Step 1: Create New Program - Leave old program ACTIVE - Launch new program as separate entity - Set attractive earn/redeem rates
✅ Step 2: Offer Migration Incentive
Email campaign:
"Upgrade to our NEW Gold Elite Program!
- 2x earn rate vs current program
- Lower redemption thresholds
- Bonus: Transfer points with 10% bonus!"
✅ Step 3: Point Transfer API
POST /api/crm/loyalty-programs/transfer-points
{
fromProgramId: "old-program-id",
toProgramId: "new-program-id",
memberId: "member-id",
bonusPercentage: 10 // 10% bonus on transfer
}
✅ Step 4: Freeze Old Program - After 6 months, stop accepting new members - Set endDate = 12 months from now - Notify members: "Program retiring in 1 year"
✅ Step 5: Final Migration - Auto-transfer remaining members - Preserve point balances (with generous conversion) - Send thank you email with new program benefits
Data Migration Code Example:
// Backend migration script
async function migrateMembersToNewProgram(
oldProgramId: string,
newProgramId: string,
bonusPercentage: number = 10
) {
const members = await LoyaltyMember.find({ programId: oldProgramId });
for (const member of members) {
const bonusPoints = Math.floor(member.currentPoints * (bonusPercentage / 100));
const newPoints = member.currentPoints + bonusPoints;
// Create new membership
await LoyaltyMember.create({
programId: newProgramId,
userId: member.userId,
currentPoints: newPoints,
lifetimePoints: member.lifetimePoints,
tier: mapOldTierToNewTier(member.tier),
status: 'ACTIVE',
});
// Deactivate old membership (preserve history)
await LoyaltyMember.update(
{ id: member.id },
{ status: 'MIGRATED', migratedTo: newProgramId }
);
// Log transaction
await PointTransaction.create({
memberId: member.id,
type: 'MIGRATION_BONUS',
points: bonusPoints,
description: `Migration bonus: ${bonusPercentage}% on ${member.currentPoints} points`,
});
}
}
Best Practice:
- Give 3-6 months transition period
- Offer bonus incentive (10-20% point boost)
- Clear communication at every step
- Preserve transaction history
- Never force instant migration
6. Redemption Rate thấp (< 20%) có nghĩa là gì?¶
Câu trả lời:
Normal Redemption Rate Benchmarks:
| Industry | Redemption Rate | Status |
|---|---|---|
| Hotel Loyalty | 20-40% | Healthy |
| Airline Loyalty | 10-30% | Normal |
| Retail Loyalty | 30-60% | High engagement |
If Your Rate < 20%:
Possible Causes:
❌ Problem 1: Redemption Threshold Too High
Example:
redeemRate: 1000 points = 10,000 VND
Average booking: 2,000,000 VND
earnRate: 5 points / 100,000 VND
→ Earns only 100 points/booking
→ Needs 10 bookings for 10,000 VND discount
→ Too little reward, members don't bother
Fix: - Lower redemption threshold - Increase earn rate - Add tiered rewards (redeem from 100 points)
❌ Problem 2: Members Don't Know How to Redeem
Fix: - Add prominent "Redeem Points" button - Email reminders when point balance > threshold - Show "You can save X VND" at checkout
❌ Problem 3: Reward Value Too Low
Fix: - Increase point value (100 pts = 2,000 VND instead of 1,000 VND) - Add non-monetary rewards (spa credit, room upgrade)
❌ Problem 4: Points Expiring
Fix: - Extend pointsValidityPeriod (12 → 24 months) - Send expiry warnings 30 days before - Auto-extend with any activity
If Your Rate > 60%:
⚠️ Too High = Red Flag
Possible Causes:
- Redemption too easy (lose profit)
- Members gaming the system
- Promotional campaign caused spike
Fix: - Review program economics - Adjust redeemRate to sustainable level - Cap max redemption per booking
Ideal Target:
Redemption Rate: 30-40%
This means:
- 30-40% of earned points are redeemed
- 60-70% of points remain as liability
- Members engage but don't drain program
- Sustainable economics
How to Improve Low Redemption:
Tactic 1: Gamification
Tactic 2: Expiry Warnings
Email 30 days before expiry:
"Your 500 points expire on March 31! Redeem now for 50,000 VND discount"
Tactic 3: Flash Redemption Promotions
"Weekend Special: Redeem points at 2x value!"
→ Normal: 100 pts = 1,000 VND
→ This weekend: 100 pts = 2,000 VND
Tactic 4: Reminder at Booking
Monitor This:
-- Check redemption rate monthly
SELECT
COUNT(DISTINCT member_id) as total_members,
SUM(CASE WHEN points_redeemed > 0 THEN 1 ELSE 0 END) as redeemed_members,
(redeemed_members / total_members * 100) as redemption_rate
FROM loyalty_members
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 MONTHS);
Target: 30-40% of members redeem at least once per quarter.
Liên Kết Liên Quan¶
- Thành Viên Loyalty - Danh sách members
- Loyalty Tiers - Quản lý tiers
- Point Transactions - Lịch sử giao dịch điểm
- Loyalty Analytics - Analytics dashboard
- Loyalty Campaigns - Marketing campaigns
- Customer Profile - Hồ sơ khách hàng
Cập nhật lần cuối: 2025-01-29 Phiên bản: 1.0 Tác giả: Claude Code Documentation Team