CitySlotCard.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. 'use client';
  2. import { useRouter } from 'next/navigation';
  3. import { Calendar, ExternalLink, AlertCircle, CheckCircle, Hourglass, Activity, History } from 'lucide-react';
  4. import TimeAgo from '@/components/common/TimeAgo';
  5. import { useLanguage } from '@/lib/i18n/LanguageContext';
  6. export interface SlotSnapshot {
  7. id: number;
  8. country: string;
  9. city: string;
  10. visa_type: string;
  11. availability_status: 'None' | 'Available' | 'Waitlist';
  12. earliest_date: string | null;
  13. snapshot_at: string;
  14. website?: string;
  15. last_check_at?: string | null;
  16. routing_key?: string;
  17. slot_snapshot?: {
  18. routing_key?: string;
  19. [key: string]: any;
  20. };
  21. }
  22. export default function CitySlotCard({ data, isBlacklisted = false }: { data: SlotSnapshot; isBlacklisted?: boolean }) {
  23. const { t, lang } = useLanguage();
  24. const router = useRouter();
  25. // === 1. 时间计算与解析 ===
  26. const now = new Date().getTime();
  27. const THRESHOLD = 6 * 60 * 1000; // 5分钟阈值
  28. const parseTime = (timeStr: string | null | undefined): number => {
  29. if (!timeStr) return 0;
  30. let t = timeStr;
  31. if (!t.endsWith('Z') && !t.includes('+')) t += 'Z';
  32. return new Date(t).getTime();
  33. };
  34. const lastCheckTime = parseTime(data.last_check_at);
  35. const snapshotTime = parseTime(data.snapshot_at);
  36. // === 2. 状态判定逻辑 ===
  37. const isMonitorStale = (now - lastCheckTime) > THRESHOLD;
  38. const isSnapshotStale = (now - snapshotTime) > THRESHOLD;
  39. const isDataStale = isMonitorStale || isSnapshotStale; // 统称数据过期
  40. const rawStatus = data.availability_status;
  41. // 定义“有号”:包括具体的 Available 和 Waitlist
  42. const hasSlot = rawStatus === 'Available' || rawStatus === 'Waitlist';
  43. // 1. 真正的有号 (Live Slots) -> 数据新鲜且有号
  44. const isLiveSlot = hasSlot && !isDataStale;
  45. // 2. 历史记录 (History) -> 曾经有号,但数据旧了
  46. const isHistorySlot = hasSlot && isDataStale;
  47. // 格式化日期
  48. const formatDate = (dateStr: string | null) => {
  49. if (!dateStr) return 'N/A';
  50. const d = new Date(dateStr);
  51. return d.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
  52. month: 'short', day: 'numeric', weekday: 'long'
  53. });
  54. };
  55. // === 3. 动态样式配置 ===
  56. const getStyleConfig = () => {
  57. // 情况 A: 历史快照 (有号但过期) -> 橙色
  58. if (isHistorySlot) {
  59. return {
  60. bg: 'bg-orange-50/40 border-orange-200',
  61. badgeBg: 'bg-orange-100 text-orange-700',
  62. icon: <History size={14} />,
  63. label: lang === 'zh' ? '历史快照' : 'History/Stale', // 依然提示数据旧
  64. contentColor: 'text-slate-700 opacity-70',
  65. isStale: true
  66. };
  67. }
  68. // 情况 B: 真正的有号 (Available OR Waitlist) -> 绿色
  69. // *修改点:Waitlist 现在完全共享 Available 的绿色样式*
  70. if (isLiveSlot) {
  71. return {
  72. bg: 'bg-green-50/60 border-green-200',
  73. badgeBg: 'bg-green-100 text-green-700',
  74. icon: <CheckCircle size={14} />,
  75. label: t('slots.status_available') || 'Available', // 统称 Available
  76. contentColor: 'text-green-700',
  77. isStale: false
  78. };
  79. }
  80. // 情况 C: 监控过期 (Monitor Offline) 且原本就没号
  81. if (isMonitorStale) {
  82. return {
  83. bg: 'bg-slate-50 border-slate-200',
  84. badgeBg: 'bg-slate-200 text-slate-500',
  85. icon: <Activity size={14} className="text-slate-400" />,
  86. label: lang === 'zh' ? '监控离线' : 'Monitor Offline',
  87. contentColor: 'text-slate-400',
  88. isStale: true
  89. };
  90. }
  91. // 情况 D: 正常无号 (None) -> 灰色
  92. return {
  93. bg: 'bg-white border-slate-200 opacity-90',
  94. badgeBg: 'bg-slate-100 text-slate-500',
  95. icon: <AlertCircle size={14} />,
  96. label: t('slots.status_unavailable') || 'None',
  97. contentColor: 'text-slate-400',
  98. isStale: false
  99. };
  100. };
  101. const style = getStyleConfig();
  102. const slotRoutingKey = data.slot_snapshot?.routing_key || data.routing_key;
  103. const slotCity = data.city;
  104. const slotCountry = data.country;
  105. const slotVisaType = data.visa_type;
  106. const canSubscribe = Boolean(slotRoutingKey) && !isBlacklisted;
  107. const handleSubscribe = () => {
  108. if (!slotRoutingKey) return;
  109. const params = new URLSearchParams();
  110. params.set('slot_routing_key', slotRoutingKey);
  111. if (slotCity) params.set('slot_city', slotCity);
  112. if (slotCountry) params.set('slot_country', slotCountry);
  113. if (slotVisaType) params.set('slot_visa_type', slotVisaType);
  114. router.push(`/create-order/16?${params.toString()}`);
  115. };
  116. // 决定中间显示什么内容
  117. const renderCenterContent = () => {
  118. // 如果是 Waitlist (无论是否过期)
  119. if (rawStatus === 'Waitlist') {
  120. return (
  121. <>
  122. <Hourglass size={18} />
  123. <span>Waitlist</span>
  124. {style.isStale && (
  125. <span className="text-xs font-normal ml-2 text-orange-600/70 border border-orange-200 px-1 rounded">Exp?</span>
  126. )}
  127. </>
  128. );
  129. }
  130. // 如果是 Available (有日期)
  131. if (rawStatus === 'Available') {
  132. return (
  133. <>
  134. <Calendar size={18} />
  135. {formatDate(data.earliest_date)}
  136. {style.isStale && (
  137. <span className="text-xs font-normal ml-2 text-orange-600/70 border border-orange-200 px-1 rounded">Exp?</span>
  138. )}
  139. </>
  140. );
  141. }
  142. // 既不是 Available 也不是 Waitlist (即 None)
  143. if (style.isStale) {
  144. return <span className="text-base font-normal text-slate-400">{lang === 'zh' ? '数据已过期' : 'Data Stale'}</span>;
  145. }
  146. return <span className="text-base font-normal">暂无名额</span>;
  147. };
  148. return (
  149. <div className={`relative overflow-hidden rounded-xl border p-5 transition-all hover:shadow-md ${style.bg} flex flex-col h-full`}>
  150. {/* 右上角状态标签 */}
  151. <div className="absolute top-4 right-4">
  152. <span className={`flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full ${style.badgeBg}`}>
  153. {style.icon} {style.label}
  154. </span>
  155. </div>
  156. {/* 标题 */}
  157. <div className="mb-4 pr-24">
  158. <h3 className="font-bold text-slate-900 text-lg flex items-center gap-2">
  159. <span className="w-8 h-8 rounded-lg bg-white border border-slate-100 flex items-center justify-center text-sm font-bold shadow-sm">
  160. {data.country.substring(0, 2).toUpperCase()}
  161. </span>
  162. {data.country}
  163. </h3>
  164. <div className="flex items-center gap-1.5 text-xs text-slate-500 mt-1.5 pl-1">
  165. <span className="bg-white/50 px-1.5 py-0.5 rounded border border-slate-200/50">
  166. {data.visa_type}
  167. </span>
  168. </div>
  169. </div>
  170. {/* 核心指标区域 */}
  171. <div className="bg-white/80 rounded-lg p-3 border border-black/5 mb-4 shadow-sm backdrop-blur-sm flex-1 flex flex-col justify-center">
  172. <p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mb-1">
  173. {/* 这里文案也统一,如果是 Waitlist 也可以算作 Slot Info */}
  174. {rawStatus === 'Waitlist' ? 'Slot Status' : (t('slots.earliest_date') || 'Earliest Slot')}
  175. </p>
  176. <div className={`flex items-center gap-2 text-lg font-bold ${style.contentColor}`}>
  177. {renderCenterContent()}
  178. </div>
  179. </div>
  180. {/* 底部:监控状态 & 链接 */}
  181. <div className="pt-3 border-t border-slate-200/50 space-y-1.5">
  182. <div className="flex items-center justify-between text-xs">
  183. <span className="text-slate-400 flex items-center gap-1">
  184. <Activity size={12} className={!isMonitorStale ? "text-green-500" : "text-slate-300"} />
  185. {lang === 'zh' ? '监控状态' : 'Monitor'}
  186. </span>
  187. <div className="flex items-center gap-1">
  188. {/*
  189. 只要是有号(日期或Waitlist)且新鲜,就闪烁绿灯
  190. */}
  191. {isLiveSlot && (
  192. <span className="relative flex h-2 w-2 mr-1">
  193. <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
  194. <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
  195. </span>
  196. )}
  197. <span className={`font-medium ${!isMonitorStale ? 'text-green-700' : 'text-slate-400'}`}>
  198. <TimeAgo date={data.last_check_at} />
  199. </span>
  200. </div>
  201. </div>
  202. {/* 调试用:历史快照时间 */}
  203. {isHistorySlot && (
  204. <div className="flex items-center justify-between text-[10px] text-orange-400 px-1">
  205. <span>Last seen:</span>
  206. <TimeAgo date={data.snapshot_at} />
  207. </div>
  208. )}
  209. {data.website && (
  210. <div className="pt-2 mt-2 border-t border-dashed border-slate-200">
  211. <a
  212. href={data.website}
  213. target="_blank"
  214. rel="noopener noreferrer"
  215. className="w-full flex items-center justify-center gap-1 text-blue-600 hover:text-blue-800 text-xs font-bold transition bg-blue-50/50 py-1.5 rounded-md hover:bg-blue-100"
  216. >
  217. {t('common.book_now') || '前往预约'} <ExternalLink size={12} />
  218. </a>
  219. </div>
  220. )}
  221. <div className="pt-2 mt-2 border-t border-dashed border-slate-200">
  222. <button
  223. onClick={handleSubscribe}
  224. disabled={!canSubscribe}
  225. className={`w-full px-3 py-2 rounded-lg text-sm font-bold transition ${canSubscribe ? 'bg-slate-900 text-white hover:bg-slate-800' : 'bg-slate-100 text-slate-500 cursor-not-allowed'}`}
  226. >
  227. {t('slots.subscribe_notification') || 'Subscribe for updates'}
  228. </button>
  229. {!canSubscribe && !isBlacklisted && (
  230. <p className="text-xs text-slate-400 mt-1">
  231. {t('slots.subscribe_unavailable') || 'Routing info unavailable'}
  232. </p>
  233. )}
  234. </div>
  235. </div>
  236. </div>
  237. );
  238. }