Bỏ qua

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

Loyalty Programs Overview - Tổng quan chương trình loyalty 📸 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

Statistics Cards - Thống kê chương trình 📸 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

Programs Table - Bảng chương trình 📸 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

Create Program Modal - Modal tạo chương trình 📸 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

Create Basic Info - Thông tin cơ bản 📸 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

Create Rates - Cài đặt tỷ lệ 📸 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

Edit Program Modal - Modal chỉnh sửa chương trình 📸 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:

  1. 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);
    };
    

  2. Controlled Switch:

    <Switch
      key={`switch-${editingProgram?.id || 'new'}-${switchValue}`}
      checkedChildren="Active"
      unCheckedChildren="Inactive"
      checked={switchValue}
      onChange={(checked) => {
        setSwitchValue(checked);
        form.setFieldsValue({ isActive: checked });
      }}
    />
    

  3. 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

Point Calculation Modal - Modal tính điểm 📸 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

Delete Confirmation - Xác nhận xóa 📸 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

Mobile View - Giao diện mobile 📸 Chụp từ: http://localhost:8082/customer-management/loyalty.html (mobile device)

Mobile-Specific Features

Responsive Breakpoints:

const isMobile = useIsMobile(); // Custom hook

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:

  1. Statistics Cards
  2. Desktop: 4 columns (Col xs={12} sm={6})
  3. Mobile: 2x2 grid
  4. Abbreviated titles

  5. Table Columns

  6. Desktop: 5 columns (Program, Status, Rates, Members/Usage, Actions)
  7. Mobile: 3 columns (Program, Rates, Actions)
  8. Status hidden on mobile (moved into Program column as tag)
  9. Members/Usage hidden on mobile

  10. Program Name

  11. Desktop: Full name
  12. Mobile: Truncated to 15 chars with "..."

  13. Rates Display

  14. Desktop: Full text ("Earn: 10 points/100,000 ₫")
  15. Mobile: Abbreviated ("Earn: 10 pts", font-size: 10px)

  16. Buttons

  17. Desktop: "Create Program"
  18. Mobile: "Create"

  19. Form Inputs

  20. Desktop: size="middle"
  21. Mobile: size="small"

  22. TextArea

  23. Desktop: 3 rows
  24. Mobile: 2 rows

  25. Modal Body

  26. Desktop: default padding
  27. Mobile: padding: 8px

API Endpoints

Fetch Loyalty Programs

Endpoint:

GET /api/crm/tenants/:tenantId/loyalty-programs

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:

GET /api/crm/tenants/:tenantId/loyalty-programs/stats

Response:

interface LoyaltyProgramStatsResponse {
  totalPrograms: number
  activePrograms: number
  totalMembers: number
  redemptionRate: number // Percentage
}

Create Loyalty Program

Endpoint:

POST /api/crm/tenants/:tenantId/loyalty-programs

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:

{
  ...createdProgram
}

Update Loyalty Program

Endpoint:

PATCH /api/crm/tenants/:tenantId/loyalty-programs/:programId

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:

{
  ...updatedProgram
}

Delete Loyalty Program

Endpoint:

DELETE /api/crm/tenants/:tenantId/loyalty-programs/:programId

Success Response:

{
  statusCode: 200,
  message: "Loyalty program deleted successfully"
}


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;

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:

"Earn 10 points per 100,000 VND spent, up to 1,000 points per booking"

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

If 1000 points = 5,000 VND only:
  → Not worth the effort
  → Members hoard points, never redeem

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

"You're only 200 points away from a free night!"

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

During checkout:
"You have 1,200 points = 120,000 VND discount. Apply now?"
[Apply Points] button

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


Cập nhật lần cuối: 2025-01-29 Phiên bản: 1.0 Tác giả: Claude Code Documentation Team