mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -34,6 +34,27 @@ export interface ClaimOfferResponse {
|
|||||||
expires_at: string | null
|
expires_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyTierInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
threshold_rubles: number
|
||||||
|
server_discount_percent: number
|
||||||
|
traffic_discount_percent: number
|
||||||
|
device_discount_percent: number
|
||||||
|
period_discounts: Record<string, number>
|
||||||
|
is_current: boolean
|
||||||
|
is_achieved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyTiersResponse {
|
||||||
|
tiers: LoyaltyTierInfo[]
|
||||||
|
current_spent_rubles: number
|
||||||
|
current_tier_name: string | null
|
||||||
|
next_tier_name: string | null
|
||||||
|
next_tier_threshold_rubles: number | null
|
||||||
|
progress_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
export const promoApi = {
|
export const promoApi = {
|
||||||
// Get available promo offers
|
// Get available promo offers
|
||||||
getOffers: async (): Promise<PromoOffer[]> => {
|
getOffers: async (): Promise<PromoOffer[]> => {
|
||||||
@@ -53,6 +74,12 @@ export const promoApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get loyalty tiers (promo groups with spending thresholds)
|
||||||
|
getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => {
|
||||||
|
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Claim a promo offer
|
// Claim a promo offer
|
||||||
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
|
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
|
||||||
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
|
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
|
||||||
|
|||||||
@@ -1269,9 +1269,27 @@
|
|||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"offer": "Public Offer",
|
"offer": "Public Offer",
|
||||||
|
"loyalty": "Loyalty",
|
||||||
"noFaq": "No FAQ available",
|
"noFaq": "No FAQ available",
|
||||||
"noContent": "No content available",
|
"noContent": "No content available",
|
||||||
"updatedAt": "Updated"
|
"updatedAt": "Updated",
|
||||||
|
"noLoyaltyTiers": "Loyalty program is not available yet",
|
||||||
|
"yourProgress": "Your Progress",
|
||||||
|
"totalSpent": "Total spent",
|
||||||
|
"currentStatus": "Current status",
|
||||||
|
"nextStatus": "Next status",
|
||||||
|
"toNextStatus": "To next status",
|
||||||
|
"allStatusesAchieved": "You have achieved the maximum status!",
|
||||||
|
"statusAchieved": "Achieved",
|
||||||
|
"statusCurrent": "Current",
|
||||||
|
"statusLocked": "Locked",
|
||||||
|
"threshold": "Threshold",
|
||||||
|
"discounts": "Discounts",
|
||||||
|
"serverDiscount": "Servers",
|
||||||
|
"trafficDiscount": "Traffic",
|
||||||
|
"deviceDiscount": "Devices",
|
||||||
|
"periodDiscount": "{{days}} days",
|
||||||
|
"noDiscounts": "No discounts"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
|
|||||||
@@ -755,9 +755,27 @@
|
|||||||
"rules": "قوانین",
|
"rules": "قوانین",
|
||||||
"privacy": "حریم خصوصی",
|
"privacy": "حریم خصوصی",
|
||||||
"offer": "شرایط خدمات",
|
"offer": "شرایط خدمات",
|
||||||
|
"loyalty": "سطح وفاداری",
|
||||||
"noFaq": "سوال متداولی موجود نیست",
|
"noFaq": "سوال متداولی موجود نیست",
|
||||||
"noContent": "محتوایی موجود نیست",
|
"noContent": "محتوایی موجود نیست",
|
||||||
"updatedAt": "بهروزرسانی"
|
"updatedAt": "بهروزرسانی",
|
||||||
|
"noLoyaltyTiers": "برنامه وفاداری هنوز در دسترس نیست",
|
||||||
|
"yourProgress": "پیشرفت شما",
|
||||||
|
"totalSpent": "کل هزینه شده",
|
||||||
|
"currentStatus": "وضعیت فعلی",
|
||||||
|
"nextStatus": "وضعیت بعدی",
|
||||||
|
"toNextStatus": "تا وضعیت بعدی",
|
||||||
|
"allStatusesAchieved": "شما به بالاترین سطح رسیدهاید!",
|
||||||
|
"statusAchieved": "دستیافته",
|
||||||
|
"statusCurrent": "فعلی",
|
||||||
|
"statusLocked": "قفل شده",
|
||||||
|
"threshold": "آستانه",
|
||||||
|
"discounts": "تخفیفها",
|
||||||
|
"serverDiscount": "سرورها",
|
||||||
|
"trafficDiscount": "ترافیک",
|
||||||
|
"deviceDiscount": "دستگاهها",
|
||||||
|
"periodDiscount": "{{days}} روز",
|
||||||
|
"noDiscounts": "بدون تخفیف"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"skip": "رد کردن",
|
"skip": "رد کردن",
|
||||||
|
|||||||
@@ -1962,9 +1962,27 @@
|
|||||||
"rules": "Правила",
|
"rules": "Правила",
|
||||||
"privacy": "Конфиденциальность",
|
"privacy": "Конфиденциальность",
|
||||||
"offer": "Оферта",
|
"offer": "Оферта",
|
||||||
|
"loyalty": "Статусы",
|
||||||
"noFaq": "Нет вопросов и ответов",
|
"noFaq": "Нет вопросов и ответов",
|
||||||
"noContent": "Нет контента",
|
"noContent": "Нет контента",
|
||||||
"updatedAt": "Обновлено"
|
"updatedAt": "Обновлено",
|
||||||
|
"noLoyaltyTiers": "Программа лояльности пока недоступна",
|
||||||
|
"yourProgress": "Ваш прогресс",
|
||||||
|
"totalSpent": "Всего потрачено",
|
||||||
|
"currentStatus": "Текущий статус",
|
||||||
|
"nextStatus": "Следующий статус",
|
||||||
|
"toNextStatus": "До следующего статуса",
|
||||||
|
"allStatusesAchieved": "Вы достигли максимального статуса!",
|
||||||
|
"statusAchieved": "Достигнут",
|
||||||
|
"statusCurrent": "Текущий",
|
||||||
|
"statusLocked": "Недоступен",
|
||||||
|
"threshold": "Порог",
|
||||||
|
"discounts": "Скидки",
|
||||||
|
"serverDiscount": "На серверы",
|
||||||
|
"trafficDiscount": "На трафик",
|
||||||
|
"deviceDiscount": "На устройства",
|
||||||
|
"periodDiscount": "За {{days}} дн.",
|
||||||
|
"noDiscounts": "Нет скидок"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"skip": "Пропустить",
|
"skip": "Пропустить",
|
||||||
|
|||||||
@@ -756,9 +756,27 @@
|
|||||||
"rules": "规则",
|
"rules": "规则",
|
||||||
"privacy": "隐私政策",
|
"privacy": "隐私政策",
|
||||||
"offer": "服务条款",
|
"offer": "服务条款",
|
||||||
|
"loyalty": "会员等级",
|
||||||
"noFaq": "暂无常见问题",
|
"noFaq": "暂无常见问题",
|
||||||
"noContent": "暂无内容",
|
"noContent": "暂无内容",
|
||||||
"updatedAt": "更新时间"
|
"updatedAt": "更新时间",
|
||||||
|
"noLoyaltyTiers": "会员计划暂不可用",
|
||||||
|
"yourProgress": "您的进度",
|
||||||
|
"totalSpent": "总消费",
|
||||||
|
"currentStatus": "当前等级",
|
||||||
|
"nextStatus": "下一等级",
|
||||||
|
"toNextStatus": "距离下一等级",
|
||||||
|
"allStatusesAchieved": "您已达到最高等级!",
|
||||||
|
"statusAchieved": "已达成",
|
||||||
|
"statusCurrent": "当前",
|
||||||
|
"statusLocked": "未解锁",
|
||||||
|
"threshold": "门槛",
|
||||||
|
"discounts": "折扣",
|
||||||
|
"serverDiscount": "服务器",
|
||||||
|
"trafficDiscount": "流量",
|
||||||
|
"deviceDiscount": "设备",
|
||||||
|
"periodDiscount": "{{days}}天",
|
||||||
|
"noDiscounts": "无折扣"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"skip": "跳过",
|
"skip": "跳过",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { infoApi, FaqPage } from '../api/info'
|
import { infoApi, FaqPage } from '../api/info'
|
||||||
|
import { promoApi, LoyaltyTierInfo } from '../api/promo'
|
||||||
|
|
||||||
const InfoIcon = () => (
|
const InfoIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
@@ -28,6 +29,12 @@ const ShieldIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const StarIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||||
@@ -40,7 +47,7 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
|
type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty'
|
||||||
|
|
||||||
// Sanitize HTML content to prevent XSS
|
// Sanitize HTML content to prevent XSS
|
||||||
const sanitizeHtml = (html: string): string => {
|
const sanitizeHtml = (html: string): string => {
|
||||||
@@ -134,11 +141,20 @@ export default function Info() {
|
|||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: loyaltyData, isLoading: loyaltyLoading } = useQuery({
|
||||||
|
queryKey: ['loyalty-tiers'],
|
||||||
|
queryFn: promoApi.getLoyaltyTiers,
|
||||||
|
enabled: activeTab === 'loyalty',
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
})
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
|
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
|
||||||
{ id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon },
|
{ id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon },
|
||||||
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
|
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
|
||||||
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
|
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
|
||||||
|
{ id: 'loyalty' as TabType, label: t('info.loyalty'), icon: StarIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
const toggleFaq = (id: number) => {
|
const toggleFaq = (id: number) => {
|
||||||
@@ -272,6 +288,181 @@ export default function Info() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'loyalty') {
|
||||||
|
if (loyaltyLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loyaltyData || loyaltyData.tiers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-dark-400">
|
||||||
|
{t('info.noLoyaltyTiers')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (tier: LoyaltyTierInfo) => {
|
||||||
|
if (tier.is_current) {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-accent-500/20 text-accent-400 rounded-full">
|
||||||
|
{t('info.statusCurrent')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tier.is_achieved) {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-success-500/20 text-success-400 rounded-full">
|
||||||
|
{t('info.statusAchieved')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-dark-600 text-dark-400 rounded-full">
|
||||||
|
{t('info.statusLocked')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyDiscount = (tier: LoyaltyTierInfo) => {
|
||||||
|
return (
|
||||||
|
tier.server_discount_percent > 0 ||
|
||||||
|
tier.traffic_discount_percent > 0 ||
|
||||||
|
tier.device_discount_percent > 0 ||
|
||||||
|
Object.keys(tier.period_discounts).length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Progress Card */}
|
||||||
|
<div className="bento-card p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-dark-50 mb-4">{t('info.yourProgress')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="bg-dark-800/50 rounded-xl p-3">
|
||||||
|
<div className="text-xs text-dark-400 mb-1">{t('info.totalSpent')}</div>
|
||||||
|
<div className="text-lg font-bold text-dark-50">
|
||||||
|
{formatCurrency(loyaltyData.current_spent_rubles)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-800/50 rounded-xl p-3">
|
||||||
|
<div className="text-xs text-dark-400 mb-1">{t('info.currentStatus')}</div>
|
||||||
|
<div className="text-lg font-bold text-accent-400">
|
||||||
|
{loyaltyData.current_tier_name || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar to next tier */}
|
||||||
|
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs text-dark-400 mb-2">
|
||||||
|
<span>{t('info.nextStatus')}: {loyaltyData.next_tier_name}</span>
|
||||||
|
<span>{t('info.toNextStatus')}: {formatCurrency(loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-dark-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(100, loyaltyData.progress_percent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-dark-400 mt-1">
|
||||||
|
{loyaltyData.progress_percent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2 text-success-400 font-medium">
|
||||||
|
{t('info.allStatusesAchieved')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tiers List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loyaltyData.tiers.map((tier) => (
|
||||||
|
<div
|
||||||
|
key={tier.id}
|
||||||
|
className={`bento-card p-4 transition-all ${
|
||||||
|
tier.is_current
|
||||||
|
? 'ring-2 ring-accent-500/50 bg-accent-500/5'
|
||||||
|
: tier.is_achieved
|
||||||
|
? 'bg-success-500/5'
|
||||||
|
: 'opacity-70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||||
|
tier.is_current
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: tier.is_achieved
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-700 text-dark-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StarIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-dark-50">{tier.name}</h4>
|
||||||
|
<p className="text-xs text-dark-400">
|
||||||
|
{t('info.threshold')}: {formatCurrency(tier.threshold_rubles)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(tier)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discounts */}
|
||||||
|
{hasAnyDiscount(tier) ? (
|
||||||
|
<div className="bg-dark-800/50 rounded-xl p-3">
|
||||||
|
<div className="text-xs text-dark-400 mb-2">{t('info.discounts')}:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tier.server_discount_percent > 0 && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
|
||||||
|
{t('info.serverDiscount')}: -{tier.server_discount_percent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tier.traffic_discount_percent > 0 && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
|
||||||
|
{t('info.trafficDiscount')}: -{tier.traffic_discount_percent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tier.device_discount_percent > 0 && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
|
||||||
|
{t('info.deviceDiscount')}: -{tier.device_discount_percent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{Object.entries(tier.period_discounts).map(([days, percent]) => (
|
||||||
|
<span key={days} className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
|
||||||
|
{t('info.periodDiscount', { days })}: -{percent}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-dark-500 italic">{t('info.noDiscounts')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user