jerry 2 месяцев назад
Родитель
Сommit
9bd21ef05a

+ 24 - 0
.eslintrc.json

@@ -0,0 +1,24 @@
+{
+  "root": true,
+  "parser": "@typescript-eslint/parser",
+  "parserOptions": {
+    "ecmaVersion": 2024,
+    "sourceType": "module",
+    "ecmaFeatures": {
+      "jsx": true
+    },
+    "project": "./tsconfig.json"
+  },
+  "env": {
+    "browser": true,
+    "node": true,
+    "es2024": true
+  },
+  "plugins": ["@typescript-eslint"],
+  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
+  "rules": {
+    "no-unused-vars": "off",
+    "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
+    "@typescript-eslint/consistent-type-imports": "warn"
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 888 - 8
package-lock.json


+ 3 - 0
package.json

@@ -23,7 +23,10 @@
     "devDependencies": {
         "@types/node": "25.0.3",
         "@types/react": "19.2.7",
+        "@typescript-eslint/eslint-plugin": "^8.57.0",
+        "@typescript-eslint/parser": "^8.57.0",
         "autoprefixer": "^10.0.1",
+        "eslint": "^8.57.1",
         "postcss": "^8",
         "tailwindcss": "^3.3.0",
         "typescript": "5.9.3"

+ 6 - 1
src/app/admin/products/page.tsx

@@ -53,6 +53,11 @@ export default function AdminProductsPage() {
             p.provider.toLowerCase().includes(lowerKey)
           );
         }
+        filtered = filtered.slice().sort((a, b) => {
+          const scoreA = typeof a.recommend_score === 'number' ? a.recommend_score : 0;
+          const scoreB = typeof b.recommend_score === 'number' ? b.recommend_score : 0;
+          return scoreB - scoreA;
+        });
         const start = (targetPage - 1) * pageSize;
         const end = start + pageSize;
         setProducts(filtered.slice(start, end));
@@ -199,4 +204,4 @@ export default function AdminProductsPage() {
       />
     </div>
   );
-}
+}

+ 17 - 8
src/app/admin/visametric/page.tsx

@@ -231,16 +231,25 @@ export default function VisametricCalendarPage() {
                     `}>
                       {format(day, 'd')}
                     </span>
-                    {dayTasks.length > 0 && (
-                      <span className="text-[10px] font-bold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded-full">
-                        {dayTasks.length}
-                      </span>
-                    )}
+              {dayTasks.length > 0 && (
+                <span className="text-[10px] font-bold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded-full">
+                  {dayTasks.length}
+                </span>
+              )}
                   </div>
 
                   {/* 任务列表 */}
-                  <div className="flex flex-col gap-1.5 overflow-y-auto max-h-[140px] pr-1 custom-scrollbar">
-                    {dayTasks.map(task => {
+                  <div className="flex flex-col gap-1 overflow-y-auto max-h-[220px] pr-1 custom-scrollbar">
+                    {dayTasks
+                      .slice()
+                      .sort((a, b) => {
+                        const parseTime = (time: string) => {
+                          const [hour, minute] = time.split(':').map(Number);
+                          return hour * 60 + minute;
+                        };
+                        return parseTime(a.grabbed_history.slot_time) - parseTime(b.grabbed_history.slot_time);
+                      })
+                      .map(task => {
                        const isCancelled = !!task.meta?.cancelled_at;
                        const hasPnr = !!task.grabbed_history.pnr_number;
                        
@@ -413,4 +422,4 @@ export default function VisametricCalendarPage() {
       )}
     </div>
   );
-}
+}

+ 44 - 0
src/app/contact/page.tsx

@@ -0,0 +1,44 @@
+'use client';
+
+import { useLanguage } from '@/lib/i18n/LanguageContext';
+import { Mail, Phone, MapPin } from 'lucide-react';
+
+export default function ContactPage() {
+  const { t } = useLanguage();
+
+  return (
+    <div className="min-h-screen bg-slate-50 py-10">
+      <div className="max-w-3xl mx-auto bg-white shadow-lg rounded-2xl border border-slate-100 p-8 space-y-6">
+        <div>
+          <p className="text-xs uppercase tracking-[0.4em] text-slate-400">{t('contact.subtitle')}</p>
+          <h1 className="text-3xl font-bold text-slate-900 mt-2">{t('contact.title')}</h1>
+          <p className="text-sm text-slate-600 mt-2">{t('contact.description')}</p>
+        </div>
+
+        <div className="space-y-3">
+          <div className="flex items-start gap-3">
+            <Mail className="text-blue-600" />
+            <div>
+              <p className="text-xs text-slate-500 uppercase tracking-[0.3em]">Email</p>
+              <p className="text-sm font-semibold text-slate-900">support@visafly.top</p>
+            </div>
+          </div>
+          <div className="flex items-start gap-3">
+            <Phone className="text-blue-600" />
+            <div>
+              <p className="text-xs text-slate-500 uppercase tracking-[0.3em]">Whatsapp</p>
+              <p className="text-sm font-semibold text-slate-900">+86 10 8888 0000</p>
+            </div>
+          </div>
+          <div className="flex items-start gap-3">
+            <MapPin className="text-blue-600" />
+            <div>
+              <p className="text-xs text-slate-500 uppercase tracking-[0.3em]">{t('contact.address_label')}</p>
+              <p className="text-sm font-semibold text-slate-900">{t('contact.address')}</p>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 28 - 0
src/app/terms/page.tsx

@@ -0,0 +1,28 @@
+'use client';
+
+import { useLanguage } from '@/lib/i18n/LanguageContext';
+import Link from 'next/link';
+
+export default function TermsPage() {
+  const { t } = useLanguage();
+
+  return (
+    <div className="min-h-screen bg-slate-50 py-10">
+      <div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-lg border border-slate-100 p-8 space-y-6">
+        <div>
+          <p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('terms.subtitle')}</p>
+          <h1 className="text-3xl font-bold text-slate-900 mt-2">{t('terms.title')}</h1>
+        </div>
+        <div className="space-y-4 text-sm text-slate-600 leading-relaxed">
+          <p>{t('terms.description')}</p>
+          <ul className="list-disc list-inside space-y-2">
+            <li>{t('terms.point_one')}</li>
+            <li>{t('terms.point_two')}</li>
+            <li>{t('terms.point_three')}</li>
+          </ul>
+          <p>{t('terms.contact_prompt')} <Link href="/contact" className="text-blue-600 underline">{t('footer.contact')}</Link></p>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 116 - 51
src/components/CreateOrderForm.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useEffect } from 'react';
 import api from '@/lib/api';
-import { useRouter } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
 import { 
   Loader2, 
   Info, 
@@ -63,7 +63,19 @@ interface JsonSchema {
 export default function CreateOrderForm({ productId, productName }: CreateOrderFormProps) {
   const router = useRouter();
   const { t } = useLanguage();
-  
+  const searchParams = useSearchParams();
+  const slotRoutingKey = searchParams?.get('slot_routing_key') || undefined;
+  const slotCity = searchParams?.get('slot_city') || undefined;
+  const slotCountry = searchParams?.get('slot_country') || undefined;
+  const slotVisaType = searchParams?.get('slot_visa_type') || undefined;
+  const isSlotSubscription = Boolean(slotRoutingKey);
+  const promoLabel = isSlotSubscription
+    ? (t('order.promo_label_slot') || 'Instant notifications')
+    : (t('order.promo_label') || 'Limited-time guarantee');
+  const promoSubtext = isSlotSubscription
+    ? (t('order.promo_subtext_slot') || 'Email and WhatsApp alerts when slots open')
+    : (t('order.promo_subtext') || 'Auto-lock slot and reminders');
+
   // --- 状态管理 ---
   const [loading, setLoading] = useState<boolean>(true);
   const [submitting, setSubmitting] = useState<boolean>(false);
@@ -126,7 +138,13 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
             if (schemaJson.properties) {
               Object.keys(schemaJson.properties).forEach(key => {
                 const prop = schemaJson.properties[key];
-                
+
+                // slot_routing_key should be pre-filled if provided via query params
+                if (slotRoutingKey && key === 'slot_routing_key') {
+                  initialValues[key] = slotRoutingKey;
+                  return;
+                }
+
                 // 优先使用 schema 定义的 default
                 if (prop.default !== undefined) {
                   initialValues[key] = prop.default;
@@ -299,29 +317,31 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
   const renderProductDescription = (text: string) => {
     if (!text) return null;
 
-    // 1. 按换行符拆分,过滤空行
-    const lines = text.split('\n').filter(line => line.trim().length > 0);
-
-    // 2. 如果只有一行,直接显示(保留 pre-wrap 以防万一)
-    if (lines.length <= 1) {
-      return (
-        <p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap">
-          {text}
-        </p>
-      );
-    }
+    const lines = text
+      .split('\n')
+      .map((line) => line.trim())
+      .filter((line) => line.length > 0);
 
-    // 3. 如果有多行,渲染为漂亮的权益列表
     return (
-      <div className="bg-white rounded-lg border border-slate-100 p-4 mt-3 shadow-sm">
-        <ul className="space-y-3">
-          {lines.map((line, index) => (
-            <li key={index} className="flex items-start gap-3 text-sm text-slate-700">
-              <CheckCircle2 size={18} className="text-green-500 mt-0.5 flex-shrink-0" />
-              <span className="leading-snug">{line.replace(/^[•\-\*]\s*/, '')}</span>
-            </li>
-          ))}
-        </ul>
+      <div className="mt-4 bg-white rounded-2xl border border-slate-100 shadow-sm space-y-3 p-4">
+        <div className="flex items-center text-xs font-bold uppercase tracking-wide text-slate-500 gap-2">
+          <CheckCircle2 size={16} className="text-green-500" />
+          <span>{t('order.service_details') || 'Service Details'}</span>
+        </div>
+        {lines.length === 1 ? (
+          <p className="text-sm text-slate-700 leading-relaxed">{lines[0]}</p>
+        ) : (
+          <ul className="space-y-2">
+            {lines.map((line, index) => (
+              <li key={index} className="flex items-start gap-3 text-sm text-slate-700">
+                <span className="mt-0.5 block h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" />
+                <span className="leading-snug">
+                  {line.replace(/^[•\-\*]\s*/, '')}
+                </span>
+              </li>
+            ))}
+          </ul>
+        )}
       </div>
     );
   };
@@ -438,9 +458,14 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
   const getSortedKeys = () => {
     const properties = formSchema?.properties || {};
     const keys = Object.keys(properties);
-    if (Array.isArray(formSchema?.['ui:order'])) {
-      return formSchema!['ui:order'];
+    const preferredOrder = formSchema?.['ui:order'] || [];
+
+    if (Array.isArray(preferredOrder) && preferredOrder.length > 0) {
+      const filteredUiOrder = preferredOrder.filter((key) => properties[key]);
+      const remaining = keys.filter((key) => !filteredUiOrder.includes(key));
+      return [...filteredUiOrder, ...remaining];
     }
+
     return keys.sort((a, b) => {
       const propA = properties[a];
       const propB = properties[b];
@@ -458,7 +483,8 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
   const properties = formSchema?.properties || {};
   const requiredFields = formSchema?.required || [];
   const sortedKeys = getSortedKeys();
-  const hasFields = sortedKeys.length > 0;
+  const visibleKeys = sortedKeys.filter(key => key !== 'slot_routing_key');
+  const hasFields = visibleKeys.length > 0;
 
   return (
     <div className="max-w-3xl mx-auto">
@@ -469,34 +495,61 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
         2. 更好的边距 (p-6)
         3. 描述文本优化 (renderProductDescription)
       */}
-      <div className="bg-slate-50 p-6 md:p-8 rounded-2xl border border-slate-100 mb-8 relative overflow-hidden">
-        {/* 装饰背景 */}
-        <div className="absolute top-0 right-0 w-40 h-40 bg-blue-100 rounded-full blur-3xl opacity-30 -translate-y-1/2 translate-x-1/2 pointer-events-none"></div>
-
-        <div className="relative z-10">
-          <div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
-            <h1 className="text-2xl font-bold text-slate-900 leading-snug flex-1">
+      <div className="bg-white rounded-3xl p-6 md:p-8 mb-8 shadow-lg border border-slate-100 relative overflow-visible">
+        <div className="absolute right-6 top-6 w-16 h-16 rounded-full bg-gradient-to-br from-blue-100 to-transparent opacity-60 blur-3xl -z-10"></div>
+        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
+          <div className="space-y-2">
+            <p className="text-xs uppercase tracking-widest text-slate-500">{t('order.service_label') || 'Service'}</p>
+            <h1 className="text-3xl md:text-4xl font-bold leading-tight text-slate-900">
               {product.title || productName}
             </h1>
-            <div className="bg-white px-4 py-2 rounded-lg shadow-sm border border-slate-100 flex items-baseline gap-1 shrink-0">
-               <span className="text-sm font-semibold text-slate-500">{product.price_currency === 'CNY' ? '¥' : product.price_currency}</span>
-               <span className="text-2xl font-extrabold text-slate-900">
-                 {(product.price_amount / 100).toLocaleString()}
-               </span>
-            </div>
-          </div>
-
-          <div className="mt-4">
-            {/* 渲染优化后的描述 */}
-            {renderProductDescription(product.description)}
+            <p className="text-sm text-slate-500 max-w-md">
+              {t('order.official_fast_secure') || '官方保障 · 极速处理 · 隐私加密'}
+            </p>
           </div>
 
-          <div className="flex items-center gap-2 mt-4 text-xs text-slate-500">
-            <ShieldCheck size={14} className="text-green-500"/>
-            <span>{t('order.official_fast_secure') || "官方保障 · 极速处理 · 隐私加密"}</span>
+            <div className="col-span-1 lg:col-span-2 flex flex-col gap-4">
+              <div className="bg-slate-900 text-white rounded-2xl px-6 py-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
+                <div>
+                  <p className="text-xs uppercase tracking-wide text-slate-300">{t('order.price_label') || 'Price'}</p>
+                  <p className="text-3xl font-bold">
+                    {(product.price_amount / 100).toLocaleString()} {product.price_currency === 'CNY' ? '¥' : product.price_currency}
+                  </p>
+                </div>
+                <div className="text-right text-xs text-slate-200">
+                  <p className="font-semibold">{promoLabel}</p>
+                  <p className="text-[11px]">{promoSubtext}</p>
+                </div>
+              </div>
+              {(slotCountry || slotCity || slotVisaType) && (
+                <div className="bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm text-slate-700 space-y-2 shadow-sm">
+                  <p className="text-xs uppercase tracking-wider text-slate-500 font-bold">{t('order.slot_subscription_title') || 'Subscribed slot'}</p>
+                  <div className="flex flex-wrap gap-2">
+                    {slotCountry && (
+                      <span className="px-2.5 py-1 rounded-full border border-slate-200 bg-white text-xs font-semibold text-slate-600">
+                        {t('order.slot_subscription_country') || 'Country'}: {slotCountry}
+                      </span>
+                    )}
+                    {slotCity && (
+                      <span className="px-2.5 py-1 rounded-full border border-slate-200 bg-white text-xs font-semibold text-slate-600">
+                        {t('order.slot_subscription_city') || 'City'}: {slotCity}
+                      </span>
+                    )}
+                    {slotVisaType && (
+                      <span className="px-2.5 py-1 rounded-full border border-slate-200 bg-white text-xs font-semibold text-slate-600">
+                        {t('order.slot_subscription_visa') || 'Visa type'}: {slotVisaType}
+                      </span>
+                    )}
+                  </div>
+                  <p className="text-[12px] text-slate-500">
+                    {t('order.notification_hint') || 'We will email and WhatsApp you once the slot is detected; the slot is reserved when you submit this order.'}
+                  </p>
+                </div>
+              )}
+              {renderProductDescription(product.description)}
+            </div>
           </div>
         </div>
-      </div>
 
       {/* 表单区域 */}
       <div className="bg-white p-6 md:p-8 rounded-2xl shadow-sm border border-slate-200">
@@ -520,7 +573,7 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
             </div>
           ) : (
             <div className="grid grid-cols-1 gap-6">
-              {sortedKeys.map((key) => (
+            {visibleKeys.map((key) => (
               <div key={key} className="group">
                   <label className="block text-sm font-bold text-slate-700 mb-2 flex items-center">
                       {properties[key].title || key}
@@ -559,6 +612,18 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
                 </span>
               )}
             </button>
+            {slotRoutingKey && (
+              <input type="hidden" name="slot_routing_key" value={slotRoutingKey} />
+            )}
+            {slotCountry && (
+              <input type="hidden" name="slot_country" value={slotCountry} />
+            )}
+            {slotCity && (
+              <input type="hidden" name="slot_city" value={slotCity} />
+            )}
+            {slotVisaType && (
+              <input type="hidden" name="slot_visa_type" value={slotVisaType} />
+            )}
             <p className="text-center text-xs text-slate-400 mt-4">
               {t('common.agree_agreement') || "点击提交即代表同意服务条款与隐私政策"}
             </p>
@@ -588,4 +653,4 @@ export default function CreateOrderForm({ productId, productName }: CreateOrderF
       />
     </div>
   );
-}
+}

+ 3 - 3
src/components/Footer.tsx

@@ -52,8 +52,8 @@ export default function Footer() {
             <h4 className="font-bold text-slate-900 mb-4 text-sm md:text-base">{t('footer.support')}</h4>
             <ul className="space-y-3 text-sm text-slate-600">
               <li><Link href="/refund-policy" className="hover:text-blue-600 transition block py-1">{t('footer.refund_policy')}</Link></li>
-              <li><Link href="#" className="hover:text-blue-600 transition block py-1">{t('footer.terms')}</Link></li>
-              <li><Link href="#" className="hover:text-blue-600 transition block py-1">{t('footer.contact')}</Link></li>
+              <li><Link href="/terms" className="hover:text-blue-600 transition block py-1">{t('footer.terms')}</Link></li>
+              <li><Link href="/contact" className="hover:text-blue-600 transition block py-1">{t('footer.contact')}</Link></li>
             </ul>
           </div>
         </div>
@@ -71,4 +71,4 @@ export default function Footer() {
       </div>
     </footer>
   );
-}
+}

+ 133 - 137
src/components/PaymentProcessor.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import api from '@/lib/api';
 import { useRouter } from 'next/navigation';
 import { 
@@ -69,6 +69,14 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
   const [providers, setProviders] = useState<PaymentProvider[]>([]);
   const [paymentData, setPaymentData] = useState<PaymentResult | null>(null);
   const [qrCode, setQrCode] = useState<string>('');
+  const [isSlotSubscription, setIsSlotSubscription] = useState(false);
+  const availableProviders = useMemo(() => {
+    if (!isSlotSubscription) return providers;
+    return providers.filter((provider) => {
+      const target = `${provider.name || ''} ${provider.title || ''}`.toLowerCase();
+      return target.includes('stripe') || target.includes('card');
+    });
+  }, [providers, isSlotSubscription]);
 
   // 消息弹窗状态
   const [msgModal, setMsgModal] = useState({
@@ -97,6 +105,7 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
 
   useEffect(() => {
     fetchProviders();
+    fetchOrderInfo();
   }, []);
 
   const fetchProviders = async () => {
@@ -109,6 +118,18 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
     }
   };
 
+  const fetchOrderInfo = async () => {
+    try {
+      const res = await api.get('/api/vas/order/detail', { params: { order_id: orderId } });
+      const data = res.data.data || res.data;
+      const hasSlotRouting = Boolean(data?.user_inputs?.slot_routing_key);
+      const isProductMatch = data?.product_id === 16;
+      setIsSlotSubscription(hasSlotRouting || isProductMatch);
+    } catch (error) {
+      console.warn('Failed to load order info', error);
+    }
+  };
+
   // 发起支付 (Step 1 -> Step 2)
   const handlePay = async (providerName: string) => {
     setLoading(true);
@@ -117,6 +138,11 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
       if (providerCode.includes('wechat')) providerCode = 'wechat';
       else if (providerCode.includes('ali')) providerCode = 'alipay';
       else if (providerCode.includes('stripe') || providerCode.includes('card')) providerCode = 'stripe';
+      if (isSlotSubscription && providerCode !== 'stripe') {
+        showMessage(t('payment.subscription_only_stripe_hint'), 'info');
+        setLoading(false);
+        return;
+      }
 
       // 1. 创建支付单
       const payRes = await api.post('/api/vas/payment/create', {
@@ -211,189 +237,159 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
   };
 
   return (
-    <div className="bg-white p-6 md:p-8 rounded-xl shadow-sm border text-center max-w-2xl mx-auto min-h-[400px] flex flex-col justify-center relative">
+      <div className="bg-white p-6 md:p-8 rounded-2xl shadow-2xl border border-slate-100 text-center max-w-2xl mx-auto min-h-[400px]">
       
       {/* === Step 1: 选择支付方式 === */}
       {step === 1 && (
-        <>
-          <h2 className="text-xl md:text-2xl font-bold mb-2 text-gray-900">{t('payment.order_created')}</h2>
-          <p className="text-gray-500 mb-6 md:mb-8 text-sm break-all">{t('payment.order_id')}: <span className="font-mono font-bold text-gray-700">{orderId}</span></p>
-
-          <h3 className="text-left font-semibold mb-4 text-gray-800">{t('payment.select_method')}</h3>
-          
-          {providers.length === 0 ? (
-             <div className="text-gray-400 py-4 text-sm">{t('common.loading')}</div>
+        <div className="space-y-6 text-left">
+          <div className="rounded-2xl border border-slate-100 bg-white p-6 shadow-md">
+            <p className="text-xs uppercase tracking-[0.4em] text-slate-500">{t('payment.order_created')}</p>
+            <div className="mt-2 text-2xl lg:text-3xl font-bold flex items-baseline gap-2">
+              <span>{t('payment.order_id')}</span>
+              <span className="font-mono text-blue-600 text-lg lg:text-xl">{orderId}</span>
+            </div>
+            <p className="mt-3 text-sm leading-relaxed text-slate-500">
+              {t('payment.select_method_subtitle')}
+            </p>
+            {isSlotSubscription && (
+              <div className="mt-4 inline-flex items-center gap-1 rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500 bg-slate-50">
+                <Sparkles size={14} className="text-blue-500" />
+                {t('payment.subscription_only_stripe_hint')}
+              </div>
+            )}
+          </div>
+
+          {availableProviders.length === 0 ? (
+             <div className="text-gray-400 py-4 text-sm text-center">{t('common.loading')}</div>
           ) : (
             <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-              {providers.map((p, idx) => (
+              {availableProviders.map((p, idx) => (
                 <button
                   key={idx}
                   onClick={() => handlePay(p.name)}
                   disabled={loading}
-                  className="group flex flex-col items-center justify-center p-4 py-5 md:py-6 border rounded-xl hover:bg-blue-50 hover:border-blue-200 transition bg-white h-auto active:scale-95 shadow-sm"
+                  className="group relative flex flex-col items-center justify-center gap-3 rounded-2xl border border-slate-200 bg-white p-5 hover:border-blue-300 hover:shadow-[0_15px_45px_rgba(14,165,233,0.15)] transition-all active:scale-[0.98]"
                 >
                   {p.icon ? (
                     <img 
                       src={p.icon} 
                       alt={p.name} 
-                      className="h-12 w-full object-contain mb-2 md:h-16 md:mb-3 group-hover:scale-105 transition-transform" 
+                      className="h-12 w-full object-contain mb-2 md:h-16 md:mb-3 transition-transform duration-200"
                     />
                   ) : (
-                    <div className="h-12 w-12 md:h-16 md:w-16 bg-gray-100 rounded-full mb-2 md:mb-3 flex items-center justify-center text-gray-400 text-xs font-bold">
-                      {p.name.substring(0, 3)}
+                    <div className="flex h-14 w-14 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-xs font-bold text-slate-500">
+                      {p.name.substring(0, 3).toUpperCase()}
                     </div>
                   )}
-                  <span className="font-medium text-gray-600 text-sm group-hover:text-blue-700">{p.title || p.name}</span>
-                  {p.currency && <span className="text-xs text-gray-400 mt-1">({p.currency})</span>}
+                  <span className="font-semibold text-slate-800 text-base md:text-lg">{p.title || p.name}</span>
+                  {p.currency && <span className="text-xs text-slate-400">{p.currency}</span>}
                 </button>
               ))}
             </div>
           )}
-          {loading && <div className="mt-6 flex justify-center text-blue-600 text-sm items-center"><Loader2 className="animate-spin w-4 h-4 mr-2"/>{t('payment.creating')}</div>}
-        </>
+          {loading && <div className="mt-4 text-blue-600 text-sm flex items-center justify-center gap-2"><Loader2 className="animate-spin w-4 h-4"/>{t('payment.creating')}</div>}
+        </div>
       )}
 
       {/* === Step 2: 支付详情 & 确认 === */}
       {step === 2 && paymentData && (
-        <div className="animate-in fade-in zoom-in duration-300 text-left">
-          
-          {/* 顶部导航 */}
-          <div className="flex items-center justify-between mb-6">
+        <div className="space-y-6 text-left">
+          <div className="flex items-center justify-between bg-white rounded-2xl border border-slate-100 p-4 shadow-sm">
             <button 
               onClick={handleBack}
-              className="text-gray-400 hover:text-gray-600 flex items-center text-sm transition active:scale-95 py-2 pr-2"
+              className="text-slate-500 hover:text-slate-800 flex items-center gap-2 text-sm font-medium transition"
             >
-              <ArrowLeft className="w-4 h-4 mr-1" /> {t('payment.reselect')}
+              <ArrowLeft className="w-4 h-4" /> {t('payment.reselect')}
             </button>
-            <div className="flex items-center text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded whitespace-nowrap">
-              <Clock className="w-3 h-3 mr-1" />
-              <LocalTime 
-                date={paymentData.expire_at} 
-                options={{ hour: '2-digit', minute: '2-digit' }} 
-              /> 
-              <span className="ml-1">{t('payment.expires')}</span>
+            <div className="flex items-center gap-2 text-xs text-blue-600 bg-blue-50 px-3 py-1 rounded-full">
+              <Clock className="w-3 h-3" />
+              <LocalTime date={paymentData.expire_at} options={{ hour: '2-digit', minute: '2-digit' }} />
+              <span>{t('payment.expires')}</span>
             </div>
           </div>
 
-          <h2 className="text-lg md:text-xl font-bold mb-4 text-gray-900 text-center">{t('payment.confirm_info')}</h2>
-
-          {/* Amount Card */}
-          <div className="bg-slate-50 rounded-xl p-4 md:p-5 mb-6 border border-slate-100 space-y-3">
-            
-            {/* 1. 原始金额 */}
-            <div className="flex justify-between text-sm text-gray-600">
-              <span>{t('payment.original_amount')}</span>
-              <span className="font-medium text-gray-900">{formatMoney(paymentData.base_amount, paymentData.base_currency)}</span>
-            </div>
-
-            {/* 2. 汇率 */}
-            {paymentData.currency !== paymentData.base_currency && (
-              <div className="flex justify-between text-xs text-gray-400 items-center">
-                <span className="flex items-center gap-1"><ArrowRightLeft size={12}/> {t('payment.exchange_rate')}</span>
-                <span>1 {paymentData.base_currency} ≈ {paymentData.exchange_rate} {paymentData.currency}</span>
-              </div>
-            )}
+          <h2 className="text-lg md:text-xl font-bold text-slate-900">{t('payment.confirm_info')}</h2>
 
-            {/* 4. 人工调价 (New) */}
-            {paymentData.adjustment_delta && paymentData.adjustment_delta !== 0 ? (
-              <div className={`flex justify-between text-sm font-medium items-center p-2 rounded-lg border 
-                ${paymentData.adjustment_delta < 0 
-                  ? 'bg-green-50 text-green-700 border-green-100' // 降价
-                  : 'bg-amber-50 text-amber-700 border-amber-100' // 加价
-                }`}>
-                <span className="flex items-center gap-1">
-                  {paymentData.adjustment_delta < 0 ? <TrendingDown size={14}/> : <TrendingUp size={14}/>}
-                  {t('payment.manual_adjustment')}
-                </span>
-                <span>
-                  {paymentData.adjustment_delta > 0 ? '+' : ''}
-                  {formatMoney(paymentData.adjustment_delta, paymentData.base_currency)}
-                </span>
+          <div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
+            <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 space-y-4">
+              <div className="flex justify-between items-center text-sm text-slate-500">
+                <span>{t('payment.original_amount')}</span>
+                <span className="font-medium text-slate-900">{formatMoney(paymentData.base_amount, paymentData.base_currency)}</span>
               </div>
-            ) : null}
-
-            {/* 3. 随机立减 */}
-            {paymentData.random_offset !== 0 && (
-              <div className="flex justify-between text-sm text-red-500 font-medium items-center bg-red-50 p-2 rounded-lg border border-red-100">
-                <span className="flex items-center gap-1">
-                  <Sparkles size={14} className="fill-red-500" /> {t('payment.random_discount')}
-                </span>
-                <span>
-                  -{formatMoney(paymentData.random_offset, paymentData.currency)}
-                </span>
+              {paymentData.currency !== paymentData.base_currency && (
+                <div className="flex justify-between text-xs text-slate-400">
+                  <span>{t('payment.exchange_rate')}</span>
+                  <span>1 {paymentData.base_currency} ≈ {paymentData.exchange_rate} {paymentData.currency}</span>
+                </div>
+              )}
+              {paymentData.adjustment_delta !== undefined && paymentData.adjustment_delta !== 0 && (
+                <div className={`flex justify-between text-sm items-center rounded-xl border px-4 py-2 ${paymentData.adjustment_delta < 0 ? 'border-green-100 bg-green-50 text-green-700' : 'border-amber-100 bg-amber-50 text-amber-700'}`}>
+                  <span className="flex items-center gap-1">
+                    {paymentData.adjustment_delta < 0 ? <TrendingDown size={14} /> : <TrendingUp size={14} />}
+                    {t('payment.manual_adjustment')}
+                  </span>
+                  <span>
+                    {paymentData.adjustment_delta > 0 ? '+' : ''}{formatMoney(paymentData.adjustment_delta, paymentData.base_currency)}
+                  </span>
+                </div>
+              )}
+              {paymentData.random_offset !== 0 && (
+                <div className="flex justify-between text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2 border border-red-100">
+                  <span className="flex items-center gap-1">
+                    <Sparkles size={14} className="text-red-500" /> {t('payment.random_discount')}
+                  </span>
+                  <span>-{formatMoney(paymentData.random_offset, paymentData.currency)}</span>
+                </div>
+              )}
+              <div className="border-t border-slate-200 pt-3 text-lg font-bold flex justify-between items-center">
+                <span>{t('payment.actual_pay')}</span>
+                <span className="text-blue-600 text-2xl md:text-3xl">{formatMoney(paymentData.amount, paymentData.currency)}</span>
               </div>
-            )}
-
-            <div className="border-t border-slate-200 my-2"></div>
-
-            {/* 5. 实际需付 */}
-            <div className="flex justify-between items-end">
-              <span className="text-gray-600 font-medium pb-1">{t('payment.actual_pay')}</span>
-              <span className="text-2xl md:text-3xl font-bold text-blue-600">
-                {formatMoney(paymentData.amount, paymentData.currency)}
-              </span>
             </div>
-          </div>
 
-          {/* Action Area */}
-          <div className="text-center">
-            
-            {/* Link Payment */}
-            {paymentData.channel === 'online_link' && (
-              <div className="space-y-4 mb-8">
-                <p className="text-sm text-gray-500">{t('payment.link_pay_hint')}</p>
-                <a 
-                  href={paymentData.payment_url} 
-                  target="_blank"
-                  rel="noopener noreferrer"
-                  className="w-full flex items-center justify-center gap-2 px-6 py-3.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 font-bold transition shadow-lg shadow-blue-200 active:scale-95"
-                >
-                  {t('payment.go_to_pay')} <ExternalLink size={18} />
-                </a>
-              </div>
-            )}
-
-            {/* QR Payment */}
-            {paymentData.channel === 'qr_static' && (
-              <div className="space-y-4 mb-8">
-                <p className="text-sm text-gray-500">{t('payment.qr_pay_hint')}</p>
-                <div className="bg-white p-4 inline-block rounded-xl border shadow-sm ring-4 ring-slate-50 max-w-full">
-                  {qrCode ? (
-                    <img 
-                      src={qrCode.startsWith('http') || qrCode.startsWith('data:') ? qrCode : `data:image/png;base64,${qrCode}`} 
-                      alt="Payment QR" 
-                      className="w-48 h-48 md:w-52 md:h-52 object-contain bg-white rounded-lg" 
-                    />
-                  ) : (
-                    <div className="w-48 h-48 flex items-center justify-center text-gray-400">{t('common.loading')}</div>
-                  )}
+            <div className="space-y-4">
+              {paymentData.channel === 'online_link' && (
+                <div className="bg-white rounded-3xl border border-slate-200 p-5 shadow-sm space-y-3">
+                  <p className="text-sm text-slate-500">{t('payment.link_pay_hint')}</p>
+                  <a 
+                    href={paymentData.payment_url} 
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="w-full inline-flex items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold transition hover:bg-slate-100"
+                  >
+                    {t('payment.go_to_pay')} <ExternalLink size={18} />
+                  </a>
                 </div>
+              )}
+              {paymentData.channel === 'qr_static' && (
+                <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-4 text-center">
+                  <p className="text-sm text-slate-500 mb-3">{t('payment.qr_pay_hint')}</p>
+                  <div className="inline-flex rounded-2xl border border-slate-200 p-3">
+                    {qrCode ? (
+                      <img 
+                        src={qrCode.startsWith('http') || qrCode.startsWith('data:') ? qrCode : `data:image/png;base64,${qrCode}`} 
+                        alt="Payment QR" 
+                        className="w-36 h-36 object-contain" 
+                      />
+                    ) : (
+                      <div className="w-36 h-36 flex items-center justify-center text-gray-400">{t('common.loading')}</div>
+                    )}
+                  </div>
+                </div>
+              )}
+              <div className="rounded-3xl border border-amber-100 bg-amber-50 p-4 text-xs text-amber-700">
+                <AlertCircle size={16} className="inline-block mr-1" /> {t('payment.confirm_hint_text')}
               </div>
-            )}
-
-            {/* Confirm Area */}
-            <div className="mt-8 pt-6 border-t border-slate-100">
-              <div className="flex items-start gap-2 bg-amber-50 p-3 rounded-lg text-left mb-4 border border-amber-100">
-                <AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
-                <p className="text-xs text-amber-800 leading-relaxed">
-                  {t('payment.confirm_hint_text')}
-                </p>
-              </div>
-
               <button 
                 onClick={handleUserConfirm} 
                 disabled={confirming} 
-                className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3.5 rounded-xl transition shadow-lg shadow-green-200 flex items-center justify-center gap-2 active:scale-[0.98]"
+                className="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-3.5 rounded-2xl transition shadow-lg shadow-red-200 flex items-center justify-center gap-2"
               >
                 {confirming ? <Loader2 size={20} className="animate-spin" /> : <CheckCircle2 size={20} />}
                 {t('payment.completed_btn')}
               </button>
-              
-              <p className="text-xs text-gray-400 mt-3">
-                {t('payment.confirm_subtext')}
-              </p>
+              <p className="text-center text-xs text-slate-400">{t('payment.confirm_subtext')}</p>
             </div>
-
           </div>
         </div>
       )}
@@ -408,4 +404,4 @@ export default function PaymentProcessor({ orderId }: PaymentProcessorProps) {
       />
     </div>
   );
-}
+}

+ 82 - 103
src/components/ServiceList.tsx

@@ -117,7 +117,12 @@ export default function ServiceList() {
 
   // === 核心辅助函数:解析描述文本 ===
   const renderDescription = (text: string) => {
-    if (!text) {
+    const lines = (text || '')
+      .split('\n')
+      .map(line => line.trim())
+      .filter(line => line.length > 0);
+
+    if (lines.length === 0) {
       return (
         <div className="flex items-center gap-2 text-slate-400 text-sm mt-2">
           <FileText size={14} />
@@ -126,31 +131,15 @@ export default function ServiceList() {
       );
     }
 
-    // 1. 按换行符分割
-    // 2. 过滤掉空行
-    // 3. 只取前 4 行,防止卡片无限拉长
-    const lines = text.split('\n').filter(line => line.trim().length > 0).slice(0, 4);
-
-    // 如果只有一行文字(没有换行符),或者处理后只剩一行,就显示为普通段落
-    if (lines.length <= 1) {
-      return (
-        <p className="text-slate-500 text-sm mt-2 line-clamp-3 leading-relaxed">
-          {text}
-        </p>
-      );
-    }
-
-    // 如果有多行,渲染为带图标的列表
     return (
-      <ul className="space-y-2 mt-3">
-        {lines.map((line, index) => (
-          <li key={index} className="flex items-start gap-2.5 text-sm text-slate-600">
-            {/* 蓝色对勾图标 */}
-            <CheckCircle2 size={16} className="text-blue-500 mt-0.5 flex-shrink-0" />
-            <span className="leading-snug">{line.replace(/^[•\-\*]\s*/, '')}</span> 
-          </li>
+      <div className="flex flex-col gap-2 mt-1 text-sm text-slate-600">
+        {lines.slice(0, 4).map((line, index) => (
+          <div key={index} className="flex items-start gap-2">
+            <span className="mt-1 block w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
+            <span className="leading-snug">{line.replace(/^[•\-\*]\s*/, '')}</span>
+          </div>
         ))}
-      </ul>
+      </div>
     );
   };
 
@@ -256,96 +245,86 @@ export default function ServiceList() {
       </div>
 
       {/* === 商品列表 === */}
-      {loading ? (
-        <div className="flex justify-center p-20">
-          <Loader2 className="animate-spin text-blue-600 w-8 h-8" />
-        </div>
-      ) : error ? (
+      {error && (
         <div className="text-center text-red-500 p-10 bg-red-50 rounded-xl border border-red-100">{error}</div>
-      ) : products.length === 0 ? (
-        <div className="text-center py-20 bg-white rounded-xl border border-dashed border-slate-200">
-          <div className="mx-auto w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
-            <Search className="text-slate-300" size={32} />
-          </div>
-          <h3 className="text-slate-900 font-bold mb-1">{t('services.no_result_title') || 'No results found'}</h3>
-          <p className="text-slate-500 text-sm">{t('services.no_result_desc') || 'Try adjusting your search criteria'}</p>
-        </div>
-      ) : (
-        /* 修改 Grid 布局:大屏幕使用 2 列 (lg:grid-cols-2),给予长文本更多空间 */
-        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
-          {products.map((item) => (
-            <div 
-              key={item.id} 
-              className="group bg-white rounded-2xl border border-slate-200 shadow-[0_2px_8px_rgba(0,0,0,0.04)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)] hover:border-blue-500/30 transition-all duration-300 flex flex-col overflow-hidden relative"
+      )}
+      {!error && (
+        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+          {(loading ? Array.from({ length: pageSize }) : products).map((item, index) => (
+            <div
+              key={item ? item.id : `placeholder-${index}`}
+              className={`
+                group bg-white rounded-2xl border border-slate-200 shadow-sm relative flex flex-col overflow-hidden
+                ${item ? 'hover:shadow-lg hover:border-blue-300' : 'animate-pulse'}
+              `}
             >
-              {/* 装饰背景:右上角的淡色圆圈,增加层次感 */}
-              <div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-50 to-transparent rounded-bl-[100px] -z-0 opacity-40 transition-opacity group-hover:opacity-100 pointer-events-none"></div>
-
-              {/* --- 卡片内容主体 --- */}
-              <div className="p-6 flex-grow flex flex-col z-10">
-                
-                {/* 1. 顶部元数据:国家 & 签证类型 */}
-                <div className="flex items-center justify-between mb-4">
-                  <div className="flex items-center gap-3">
-                    {/* 图标容器:模仿国旗或地点图标,增加视觉锚点 */}
-                    <div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center text-blue-600 border border-blue-100 shadow-sm shrink-0">
-                      <Plane size={20} className="-rotate-45" /> 
+              {item ? (
+                <>
+                  <div className="p-5 flex flex-col gap-3 flex-grow">
+                    <div className="flex items-center gap-3 text-sm font-semibold text-slate-700">
+                      <div className="w-9 h-9 rounded-full bg-blue-50 flex items-center justify-center text-blue-600 border border-blue-100">
+                        <Plane size={16} className="-rotate-45" />
+                      </div>
+                      <div className="flex flex-col">
+                        <span className="text-slate-900 flex items-center gap-1">
+                          <span>{item.country}</span>
+                          {item.city && (
+                            <>
+                              <MapPin size={12} className="text-slate-400" />
+                              <span className="text-xs text-slate-500">{item.city}</span>
+                            </>
+                          )}
+                        </span>
+                        <span className="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">
+                          {item.visa_type}
+                        </span>
+                      </div>
                     </div>
-                    
+                    <h2 className="text-lg font-bold text-slate-900 line-clamp-2 leading-snug">
+                      {item.title}
+                    </h2>
+                    {renderDescription(item.description)}
+                  </div>
+                  <div className="px-5 py-3 bg-slate-50 border-t border-slate-100 flex items-center justify-between gap-3">
                     <div>
-                      <h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
-                        {item.country}
-                        {item.city && <span className="font-normal text-slate-400">| {item.city}</span>}
-                      </h3>
-                      <span className="text-xs font-medium text-slate-500 bg-slate-100 px-2 py-0.5 rounded mt-1 inline-block border border-slate-200">
-                        {item.visa_type}
-                      </span>
+                      <p className="text-[11px] text-slate-400 mb-0.5">{t('services.service_fee') || 'Service Fee'}</p>
+                      <div className="flex items-baseline gap-1 text-slate-900 font-bold">
+                        <span className="text-sm">{item.price_currency === 'CNY' ? '¥' : item.price_currency}</span>
+                        <span className="text-2xl">{(item.price_amount / 100).toLocaleString()}</span>
+                      </div>
                     </div>
+                    <button
+                      onClick={() => handleOrderClick(item.id)}
+                      className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-semibold"
+                    >
+                      {t('services.apply_now') || 'Apply Now'}
+                      <ArrowRight size={16} />
+                    </button>
                   </div>
+                </>
+              ) : (
+                <div className="p-5 flex flex-col gap-3">
+                  <div className="h-4 w-3/4 bg-slate-200 rounded"></div>
+                  <div className="h-4 w-full bg-slate-200 rounded"></div>
+                  <div className="h-4 w-full bg-slate-200 rounded"></div>
+                  <div className="h-4 w-1/2 bg-slate-200 rounded"></div>
                 </div>
-
-                {/* 2. 服务标题 (限制高度,防止错位) */}
-                <h2 
-                  className="text-lg font-bold text-slate-900 mb-4 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2 min-h-[3.5rem]"
-                  title={item.title}
-                >
-                  {item.title}
-                </h2>
-
-                {/* 3. 极细分割线 */}
-                <div className="h-px w-full bg-slate-100 mb-4"></div>
-
-                {/* 4. 描述信息:核心卖点列表 */}
-                <div className="flex-grow">
-                  {renderDescription(item.description)}
-                </div>
-              </div>
-
-              {/* --- 底部栏 --- */}
-              <div className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
-                <div>
-                  <p className="text-xs text-slate-400 mb-0.5">{t('services.service_fee') || 'Service Fee'}</p>
-                  <div className="flex items-baseline gap-1">
-                    <span className="text-sm font-semibold text-slate-900">{item.price_currency === 'CNY' ? '¥' : item.price_currency}</span>
-                    <span className="text-2xl font-bold text-slate-900 tracking-tight">
-                      {(item.price_amount / 100).toLocaleString()}
-                    </span>
-                  </div>
-                </div>
-
-                <button
-                  onClick={() => handleOrderClick(item.id)}
-                  className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 text-white rounded-lg font-semibold text-sm shadow hover:bg-blue-600 hover:shadow-blue-200 hover:-translate-y-0.5 transition-all active:scale-95"
-                >
-                  {t('services.apply_now') || 'Apply Now'}
-                  <ArrowRight size={16} />
-                </button>
-              </div>
+              )}
             </div>
           ))}
         </div>
       )}
 
+      {!loading && !error && products.length === 0 && (
+        <div className="text-center py-16 bg-white rounded-xl border border-dashed border-slate-200 mt-4">
+          <div className="mx-auto w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
+            <Search className="text-slate-300" size={32} />
+          </div>
+          <h3 className="text-slate-900 font-bold mb-1">{t('services.no_result_title') || 'No results found'}</h3>
+          <p className="text-slate-500 text-sm">{t('services.no_result_desc') || 'Try adjusting your search criteria'}</p>
+        </div>
+      )}
+
       <Pagination 
         currentPage={page}
         total={total}
@@ -354,4 +333,4 @@ export default function ServiceList() {
       />
     </div>
   );
-}
+}

+ 11 - 10
src/components/admin/cards/CardModal.tsx

@@ -18,9 +18,7 @@ const getPreviewUrl = (imageField: string | null) => {
   if (imageField.startsWith('http') || imageField.startsWith('data:')) {
     return imageField;
   }
-  // 假设存的是 file_id,拼接下载链接
-  const firstFid = imageField.split(',')[0];
-  return `/api/resource/download_file?fid=${firstFid}`;
+  return `/api/resource/download_file?fid=${encodeURIComponent(imageField)}`;
 };
 
 export default function CardModal({ isOpen, onClose, onSuccess, card }: CardModalProps) {
@@ -81,20 +79,23 @@ export default function CardModal({ isOpen, onClose, onSuccess, card }: CardModa
 
     // 3. 上传到服务器
     const formData = new FormData();
-    formData.append('pdf', file); // 注意:根据 API 文档,字段名为 'pdf'
+    formData.append('file', file);
 
     try {
       const res = await api.post('/api/resource/upload_file', formData, {
         headers: { 'Content-Type': 'multipart/form-data' }
       });
       
-      // 假设返回结构: { code: 0, data: "fid_string" } 或 { data: { url: "..." } }
       const result = res.data.data;
-      
-      // 如果返回的是对象且有 url/id,取对应值;如果是字符串直接用
-      const uploadedValue = (typeof result === 'object' && result !== null) ? (result.url || result.id) : result;
-      
+      const uploadedValue = (typeof result === 'object' && result !== null)
+        ? (result.fid || result.url)
+        : result;
+
       setForm(prev => ({ ...prev, image: uploadedValue }));
+
+      if (typeof result === 'object' && result.url) {
+        setPreviewUrl(result.url);
+      }
     } catch (error) {
       console.error("Upload failed", error);
       alert("图片上传失败,请检查网络或重试");
@@ -288,4 +289,4 @@ export default function CardModal({ isOpen, onClose, onSuccess, card }: CardModa
       </div>
     </div>
   );
-}
+}

+ 2 - 3
src/components/admin/cards/CardTable.tsx

@@ -13,8 +13,7 @@ interface CardTableProps {
 const getImageUrl = (fidString: string | null) => {
   if (!fidString) return null;
   if (fidString.startsWith('http')) return fidString;
-  const firstFid = fidString.split(' ')[0];
-  return `/api/resource/download_file?fid=${firstFid}`;
+  return `/api/resource/download_file?fid=${encodeURIComponent(fidString)}`;
 };
 
 export default function CardTable({ cards, loading, onEdit }: CardTableProps) {
@@ -153,4 +152,4 @@ export default function CardTable({ cards, loading, onEdit }: CardTableProps) {
 
     </div>
   );
-}
+}

+ 198 - 42
src/components/admin/orders/OrderDetailModal.tsx

@@ -36,6 +36,14 @@ export interface PaymentRecord {
   status: string;
   created_at: string;
   external_trade_no?: string;
+  description?: string;
+  qr_id?: number;
+  payment_intent_id?: string;
+}
+
+interface PaymentQrDetail {
+  id: number;
+  description?: string;
 }
 
 interface UserDetail {
@@ -70,6 +78,8 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
   const [loadingPayments, setLoadingPayments] = useState(false);
   const [userData, setUserData] = useState<UserDetail | null>(null);
   const [loadingUser, setLoadingUser] = useState(false);
+  const [qrDetails, setQrDetails] = useState<Record<number, PaymentQrDetail>>({});
+  const [loadingQrDetails, setLoadingQrDetails] = useState(false);
 
   useEffect(() => {
     if (isOpen && order?.id) {
@@ -133,6 +143,55 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
     });
   }, [order?.user_inputs]);
 
+  useEffect(() => {
+    const qrIds = Array.from(
+      new Set(
+        paymentList
+          .filter((pay) => pay.channel === 'qr_static' && pay.qr_id)
+          .map((pay) => pay.qr_id as number)
+      )
+    );
+
+    if (qrIds.length === 0) {
+      setQrDetails({});
+      setLoadingQrDetails(false);
+      return;
+    }
+
+    let canceled = false;
+    setLoadingQrDetails(true);
+
+    const fetchDetails = async () => {
+      const detailMap: Record<number, PaymentQrDetail> = {};
+      await Promise.all(
+        qrIds.map(async (qrId) => {
+          try {
+            const res = await api.get('/api/vas/payment_qr/qrcode', { params: { id: qrId } });
+            const data = res.data.data;
+            if (data) {
+              detailMap[qrId] = { id: data.id, description: data.description };
+            }
+          } catch (error) {
+            console.warn('Failed to fetch QR detail', qrId, error);
+          }
+        })
+      );
+      if (!canceled) {
+        setQrDetails(detailMap);
+      }
+    };
+
+    fetchDetails().finally(() => {
+      if (!canceled) {
+        setLoadingQrDetails(false);
+      }
+    });
+
+    return () => {
+      canceled = true;
+    };
+  }, [paymentList]);
+
   if (!isOpen || !order) return null;
 
   const formatMoney = (amount: number, currency: string) => `${(amount / 100).toFixed(2)} ${currency}`;
@@ -147,6 +206,26 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
     return <span className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${styles[status] || 'bg-gray-100'}`}>{status}</span>;
   };
 
+  const getPaymentDescription = (pay: PaymentRecord) => {
+    if (pay.description && pay.description.trim()) {
+      return pay.description;
+    }
+    if (pay.channel === 'qr_static' && pay.qr_id) {
+      return qrDetails[pay.qr_id]?.description;
+    }
+    return undefined;
+  };
+
+  const formatStripeMode = (id: string) => (id.includes('_live_') ? 'live' : 'test');
+  const getStripeLink = (pay: PaymentRecord) => {
+    if (pay.provider.toLowerCase() !== 'stripe') return undefined;
+    const id = pay.payment_intent_id;
+    if (!id) return undefined;
+    const mode = formatStripeMode(id);
+    const resourcePath = id.startsWith('cs_') ? 'checkout/sessions' : 'payments';
+    return `https://dashboard.stripe.com/${mode}/${resourcePath}/${id}`;
+  };
+
   const displayEmail = userData?.email || order.user?.email || order.user_email || '未知';
   const displayNickname = userData?.nickname || order.user_name || order.user?.nickname || '-';
   const displayPhone = userData?.phone || order.user?.phone || '-';
@@ -258,51 +337,128 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
               <div className="flex items-center gap-2">
                 <CreditCard size={16} className="text-slate-600" />
                 <h4 className="font-bold text-sm text-slate-700">支付流水记录</h4>
+                {loadingQrDetails && (
+                  <span className="text-[11px] text-slate-500 ml-2">QR 描述加载中...</span>
+                )}
               </div>
-              {loadingPayments && <span className="text-xs text-blue-600 flex items-center"><Loader2 size={12} className="animate-spin mr-1"/> 加载中...</span>}
+              {loadingPayments && (
+                <span className="text-xs text-blue-600 flex items-center">
+                  <Loader2 size={12} className="animate-spin mr-1" /> 加载中...
+                </span>
+              )}
             </div>
-            
-            <table className="min-w-full text-sm text-left">
-              <thead className="bg-white border-b">
-                <tr>
-                  <th className="px-4 py-2 font-medium text-gray-500">支付ID</th>
-                  <th className="px-4 py-2 font-medium text-gray-500">渠道 (Provider)</th>
-                  <th className="px-4 py-2 font-medium text-gray-500">金额</th>
-                  <th className="px-4 py-2 font-medium text-gray-500">状态</th>
-                  <th className="px-4 py-2 font-medium text-gray-500">创建时间</th>
-                  <th className="px-4 py-2 font-medium text-gray-500">外部流水号</th>
-                </tr>
-              </thead>
-              <tbody className="divide-y divide-gray-100 bg-white">
-                {paymentList.length > 0 ? (
-                  paymentList.map((pay) => (
-                    <tr key={pay.id} className="hover:bg-gray-50">
-                      <td className="px-4 py-2 text-gray-500">#{pay.id}</td>
-                      <td className="px-4 py-2">
-                        <span className="uppercase font-bold text-xs">{pay.provider}</span>
-                        <span className="text-xs text-gray-400 ml-1">({pay.channel})</span>
-                      </td>
-                      <td className="px-4 py-2 font-mono">
-                        {formatMoney(pay.amount, pay.currency)}
-                      </td>
-                      <td className="px-4 py-2">{getStatusBadge(pay.status)}</td>
-                      <td className="px-4 py-2 text-gray-500 text-xs">
-                        <LocalTime date={pay.created_at} />
-                      </td>
-                      <td className="px-4 py-2 text-gray-400 text-xs font-mono">
-                        {pay.external_trade_no || '-'}
-                      </td>
-                    </tr>
-                  ))
-                ) : (
+
+            {/* 桌面表格 */}
+            <div className="hidden md:block">
+              <table className="min-w-full text-sm text-left">
+                <thead className="bg-white border-b">
                   <tr>
-                    <td colSpan={6} className="px-4 py-6 text-center text-gray-400">
-                      {loadingPayments ? '正在查询支付记录...' : '该订单暂无支付记录'}
-                    </td>
+                    <th className="px-4 py-2 font-medium text-gray-500">支付ID</th>
+                    <th className="px-4 py-2 font-medium text-gray-500">渠道 (Provider)</th>
+                    <th className="px-4 py-2 font-medium text-gray-500">金额</th>
+                    <th className="px-4 py-2 font-medium text-gray-500">状态</th>
+                    <th className="px-4 py-2 font-medium text-gray-500">创建时间</th>
+                    <th className="px-4 py-2 font-medium text-gray-500">外部流水号</th>
                   </tr>
-                )}
-              </tbody>
-            </table>
+                </thead>
+                <tbody className="divide-y divide-gray-100 bg-white">
+                  {paymentList.length > 0 ? (
+                    paymentList.map((pay) => {
+                      const description = getPaymentDescription(pay);
+                      const stripeLink = getStripeLink(pay);
+                      return (
+                        <tr key={pay.id} className="hover:bg-gray-50">
+                          <td className="px-4 py-2 text-gray-500">#{pay.id}</td>
+                          <td className="px-4 py-2">
+                            <div className="flex flex-col">
+                              <span className="uppercase font-bold text-xs text-slate-800">{pay.provider}</span>
+                              <span className="text-xs text-gray-400">{pay.channel}</span>
+                              {stripeLink && (
+                                <a
+                                  href={stripeLink}
+                                  target="_blank"
+                                  rel="noreferrer"
+                                  className="text-[11px] text-blue-600 hover:underline mt-0.5"
+                                >
+                                  View Stripe payment
+                                </a>
+                              )}
+                              {description && (
+                                <span className="text-[11px] text-slate-500 mt-0.5 truncate">
+                                  {description}
+                                </span>
+                              )}
+                            </div>
+                          </td>
+                          <td className="px-4 py-2 font-mono">{formatMoney(pay.amount, pay.currency)}</td>
+                          <td className="px-4 py-2">{getStatusBadge(pay.status)}</td>
+                          <td className="px-4 py-2 text-gray-500 text-xs">
+                            <LocalTime date={pay.created_at} />
+                          </td>
+                          <td className="px-4 py-2 text-gray-400 text-xs font-mono">{pay.external_trade_no || '-'}</td>
+                        </tr>
+                      );
+                    })
+                  ) : (
+                    <tr>
+                      <td colSpan={6} className="px-4 py-6 text-center text-gray-400">
+                        {loadingPayments ? '正在查询支付记录...' : '该订单暂无支付记录'}
+                      </td>
+                    </tr>
+                  )}
+                </tbody>
+              </table>
+            </div>
+
+            {/* 移动卡片 */}
+            <div className="md:hidden bg-white">
+              {paymentList.length > 0 ? (
+                paymentList.map((pay) => {
+                  const description = getPaymentDescription(pay);
+                  const stripeLink = getStripeLink(pay);
+                  return (
+                    <div key={pay.id} className="px-4 py-4 border-b last:border-b-0">
+                      <div className="flex justify-between items-start gap-2">
+                        <div className="text-xs text-gray-500">支付ID #{pay.id}</div>
+                        {getStatusBadge(pay.status)}
+                      </div>
+                      <div className="mt-2 flex justify-between items-center gap-4">
+                        <div>
+                          <div className="text-sm font-bold text-slate-900">{formatMoney(pay.amount, pay.currency)}</div>
+                          <div className="text-xs uppercase text-slate-500">{pay.provider}</div>
+                          <div className="text-[11px] text-gray-400">{pay.channel}</div>
+                        </div>
+                        <div className="text-right text-[11px] text-slate-500 font-mono">
+                          <LocalTime date={pay.created_at} />
+                        </div>
+                      </div>
+                        <div className="mt-2 text-[11px] text-slate-500">
+                          流水号: {pay.external_trade_no || '-'}
+                        </div>
+                        {stripeLink && (
+                          <a
+                            href={stripeLink}
+                            target="_blank"
+                            rel="noreferrer"
+                            className="text-[11px] text-blue-600 hover:underline mt-1 block"
+                          >
+                            View Stripe payment
+                          </a>
+                        )}
+                      {description && (
+                        <p className="mt-2 text-[12px] text-slate-600 bg-slate-50 rounded-lg p-2 leading-relaxed">
+                          {description}
+                        </p>
+                      )}
+                    </div>
+                  );
+                })
+              ) : (
+                <div className="px-4 py-6 text-center text-gray-400">
+                  {loadingPayments ? '正在查询支付记录...' : '该订单暂无支付记录'}
+                </div>
+              )}
+            </div>
           </div>
 
         </div>
@@ -316,4 +472,4 @@ export default function OrderDetailModal({ isOpen, onClose, order }: OrderDetail
       </div>
     </div>
   );
-}
+}

+ 8 - 1
src/components/admin/products/ProductList.tsx

@@ -12,6 +12,7 @@ interface Product {
   price_currency: string;
   provider: string;
   enabled: number;
+  recommend_score?: number;
 }
 
 interface ProductListProps {
@@ -62,6 +63,7 @@ export default function ProductList({ products, loading, onEdit, onManageRouting
               <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">签证类型</th>
               <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">价格</th>
               <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Provider</th>
+              <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">推荐权重</th>
               <th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
               <th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">操作</th>
             </tr>
@@ -78,6 +80,7 @@ export default function ProductList({ products, loading, onEdit, onManageRouting
                   {(product.price_amount / 100).toFixed(2)} {product.price_currency}
                 </td>
                 <td className="px-4 py-3 text-sm text-gray-600">{product.provider}</td>
+                <td className="px-4 py-3 text-sm text-gray-500">{product.recommend_score ?? 0}</td>
                 <td className="px-4 py-3 text-sm">{renderStatus(product.enabled)}</td>
                 <td className="px-4 py-3 text-right text-sm font-medium space-x-3">
                   <button 
@@ -132,6 +135,10 @@ export default function ProductList({ products, loading, onEdit, onManageRouting
                   {(product.price_amount / 100).toFixed(2)} {product.price_currency}
                 </span>
               </div>
+              <div>
+                <span className="text-xs text-gray-400 block">推荐权重</span>
+                <span className="font-bold text-gray-800">{product.recommend_score ?? 0}</span>
+              </div>
               <div>
                 <span className="text-xs text-gray-400 block">Provider</span>
                 <span>{product.provider}</span>
@@ -163,4 +170,4 @@ export default function ProductList({ products, loading, onEdit, onManageRouting
       </div>
     </div>
   );
-}
+}

+ 21 - 12
src/components/admin/products/ProductModal.tsx

@@ -37,7 +37,8 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
     provider: 'TROOV',
     visa_type: 'Tourist', // 默认为单值字符串
     schema_id: 0,
-    enabled: 1
+    enabled: 1,
+    recommend_score: 0
   });
 
   useEffect(() => {
@@ -54,8 +55,9 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
         setForm({
           ...product,
           price_amount: product.price_amount / 100, // 分转元
-          // 确保有值,如果没有值默认选第一个
-          visa_type: product.visa_type || 'Tourist' 
+          // 确保有值
+          visa_type: product.visa_type || 'Tourist',
+          recommend_score: product.recommend_score ?? 0
         });
       } else {
         // 创建模式:重置
@@ -63,7 +65,8 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
           title: '', description: '', country: '', city: '',
           price_amount: 0, price_currency: 'CNY', provider: 'TROOV',
           visa_type: 'Tourist', // 默认值
-          schema_id: 0, enabled: 1
+          schema_id: 0, enabled: 1,
+          recommend_score: 0
         });
       }
     }
@@ -73,12 +76,13 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
     e.preventDefault();
     setLoading(true);
     try {
-      await onSubmit({
-        ...form,
-        // 直接提交字符串,无需 join
-        price_amount: Number(form.price_amount) * 100, // 元转分
-        id: product?.id
-      });
+        await onSubmit({
+          ...form,
+          // 直接提交字符串,无需 join
+          price_amount: Number(form.price_amount) * 100, // 元转分
+          id: product?.id
+          , recommend_score: Number(form.recommend_score || 0)
+        });
       onClose();
     } catch (e) {
       console.error(e);
@@ -152,7 +156,7 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
             </div>
 
             {/* 4. 价格与状态 */}
-            <div className="grid grid-cols-3 gap-4">
+            <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
               <div>
                 <label className={labelClass}>价格 (元) <span className="text-red-500">*</span></label>
                 <input type="number" required min="0" step="0.01" className={inputClass} value={form.price_amount} 
@@ -175,6 +179,11 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
                   <option value={0}>下架</option>
                 </select>
               </div>
+              <div>
+                <label className={labelClass}>推荐权重</label>
+                <input type="number" min="0" step="0.1" className={inputClass} value={form.recommend_score || 0}
+                  onChange={e => setForm({...form, recommend_score: Number(e.target.value)})} />
+              </div>
             </div>
 
             {/* 5. 技术配置 */}
@@ -227,4 +236,4 @@ export default function ProductModal({ isOpen, onClose, product, onSubmit, onMan
       </div>
     </div>
   );
-}
+}

+ 54 - 4
src/components/dashboard/UserOrderDetailModal.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import { useState, useEffect, useMemo } from 'react';
-import { X, CreditCard, FileText, Package, Check, Clock } from 'lucide-react';
+import { X, CreditCard, FileText, Package, Check, Clock, Loader2 } from 'lucide-react';
 import api from '@/lib/api';
 import { useLanguage } from '@/lib/i18n/LanguageContext';
 import LocalTime from '@/components/common/LocalTime';
@@ -39,12 +39,16 @@ export default function UserOrderDetailModal({ isOpen, onClose, order }: UserOrd
 
   const [payments, setPayments] = useState<any[]>([]);
   const [loadingPayments, setLoadingPayments] = useState(false);
+  const [events, setEvents] = useState<any[]>([]);
+  const [loadingEvents, setLoadingEvents] = useState(false);
 
   useEffect(() => {
     if (isOpen && order?.id) {
       fetchPayments(order.id);
+      fetchEvents(order.id);
     } else {
       setPayments([]);
+      setEvents([]);
     }
   }, [isOpen, order]);
 
@@ -83,6 +87,19 @@ export default function UserOrderDetailModal({ isOpen, onClose, order }: UserOrd
     }
   };
 
+  const fetchEvents = async (orderId: string) => {
+    setLoadingEvents(true);
+    try {
+      const res = await api.get('/api/vas/order-event/list', { params: { order_id: orderId } });
+      const list = Array.isArray(res.data.data) ? res.data.data : [];
+      setEvents(list);
+    } catch (e) {
+      // ignore errors
+    } finally {
+      setLoadingEvents(false);
+    }
+  };
+
   if (!isOpen || !order) return null;
 
   const formatMoney = (amount: number, currency: string) => 
@@ -171,7 +188,7 @@ export default function UserOrderDetailModal({ isOpen, onClose, order }: UserOrd
             </div>
           </div>
 
-          {/* 3. 支付记录 */}
+        {/* 3. 支付记录 */}
           {payments.length > 0 && (
             <div className="border rounded-xl overflow-hidden">
               <div className="bg-slate-50 px-4 py-3 border-b flex items-center gap-2">
@@ -199,8 +216,41 @@ export default function UserOrderDetailModal({ isOpen, onClose, order }: UserOrd
             </div>
           )}
 
+          {/* 4. 事件记录 */}
+          <div className="border rounded-xl overflow-hidden">
+            <div className="bg-slate-50 px-4 py-3 border-b flex items-center gap-2">
+              <Loader2 size={18} className="text-slate-600" />
+              <h4 className="font-bold text-sm text-slate-800">{t('order.event_history')}</h4>
+            </div>
+            {loadingEvents ? (
+              <div className="p-6 flex flex-col items-center justify-center text-slate-400 space-y-2">
+                <Loader2 className="animate-spin" />
+                <p className="text-xs">{t('common.loading')}</p>
+              </div>
+            ) : events.length === 0 ? (
+              <div className="p-6 text-center text-slate-500 text-sm">
+                {t('order.no_events')}
+              </div>
+            ) : (
+              <div className="divide-y divide-slate-100">
+                {events.map((event) => (
+                  <div key={event.id} className="p-4 space-y-2">
+                    <div className="flex items-center justify-between text-xs text-slate-500">
+                      <span className="font-mono">{event.order_no || order.id}</span>
+                      <LocalTime date={event.event_time || event.created_at} />
+                    </div>
+                    <div className="text-sm font-bold text-slate-900">{event.event_title}</div>
+                    <p className="text-sm text-slate-600 leading-relaxed whitespace-pre-line">
+                      {event.event_message}
+                    </p>
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+
         </div>
-        
+
         {/* Footer */}
         <div className="p-4 border-t bg-slate-50 rounded-b-xl flex justify-end">
           <button 
@@ -213,4 +263,4 @@ export default function UserOrderDetailModal({ isOpen, onClose, order }: UserOrd
       </div>
     </div>
   );
-}
+}

+ 5 - 3
src/components/knowledge/KnowledgeCard.tsx

@@ -9,8 +9,10 @@ import { CardData } from '@/types/card';
 
 const getImageUrl = (fidString: string | null) => {
   if (!fidString) return null;
-  const firstFid = fidString.split(' ')[0];
-  return `/api/resource/download_file?fid=${firstFid}`;
+  if (fidString.startsWith('http') || fidString.startsWith('data:')) {
+    return fidString;
+  }
+  return `/api/resource/download_file?fid=${encodeURIComponent(fidString)}`;
 };
 
 export default function KnowledgeCard({ data }: { data: CardData }) {
@@ -97,4 +99,4 @@ export default function KnowledgeCard({ data }: { data: CardData }) {
       </div>
     </div>
   );
-}
+}

+ 37 - 1
src/components/slots/CitySlotCard.tsx

@@ -1,5 +1,6 @@
 'use client';
 
+import { useRouter } from 'next/navigation';
 import { Calendar, ExternalLink, AlertCircle, CheckCircle, Hourglass, Activity, History } from 'lucide-react';
 import TimeAgo from '@/components/common/TimeAgo';
 import { useLanguage } from '@/lib/i18n/LanguageContext';
@@ -14,10 +15,16 @@ export interface SlotSnapshot {
   snapshot_at: string;
   website?: string;
   last_check_at?: string | null;
+  routing_key?: string;
+  slot_snapshot?: {
+    routing_key?: string;
+    [key: string]: any;
+  };
 }
 
 export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
   const { t, lang } = useLanguage();
+  const router = useRouter();
   
   // === 1. 时间计算与解析 ===
   const now = new Date().getTime();
@@ -110,6 +117,21 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
   };
 
   const style = getStyleConfig();
+  const slotRoutingKey = data.slot_snapshot?.routing_key || data.routing_key;
+  const slotCity = data.city;
+  const slotCountry = data.country;
+  const slotVisaType = data.visa_type;
+  const canSubscribe = Boolean(slotRoutingKey);
+
+  const handleSubscribe = () => {
+    if (!slotRoutingKey) return;
+    const params = new URLSearchParams();
+    params.set('slot_routing_key', slotRoutingKey);
+    if (slotCity) params.set('slot_city', slotCity);
+    if (slotCountry) params.set('slot_country', slotCountry);
+    if (slotVisaType) params.set('slot_visa_type', slotVisaType);
+    router.push(`/create-order/16?${params.toString()}`);
+  };
 
   // 决定中间显示什么内容
   const renderCenterContent = () => {
@@ -228,7 +250,21 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
             </a>
           </div>
         )}
+        <div className="pt-2 mt-2 border-t border-dashed border-slate-200">
+          <button
+            onClick={handleSubscribe}
+            disabled={!canSubscribe}
+            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'}`}
+          >
+            {t('slots.subscribe_notification') || 'Subscribe for updates'}
+          </button>
+          {!canSubscribe && (
+            <p className="text-xs text-slate-400 mt-1">
+              {t('slots.subscribe_unavailable') || 'Routing info unavailable'}
+            </p>
+          )}
+        </div>
       </div>
     </div>
   );
-}
+}

+ 36 - 2
src/lib/i18n/locales/en.ts

@@ -165,6 +165,14 @@ export const en = {
     application_data: 'Application Data',
     no_application_data: 'No application data provided',
     payment_history: 'Payment History',
+    event_history: 'Event Log',
+    service_label: 'Service',
+    price_label: 'Price',
+    promo_label: 'Limited-time guarantee',
+    promo_subtext: 'Auto-lock slot and reminders',
+    promo_label_slot: 'Instant notifications',
+    promo_subtext_slot: 'Email and WhatsApp alerts when slots open',
+    service_details: 'Service Details',
     load_product_failed: 'Failed to load product info',
     product_not_found: 'Product not found',
     create_failed: 'Order creation failed',
@@ -172,6 +180,12 @@ export const en = {
     no_extra_info_needed: 'No extra information needed, please proceed.',
     submit_and_pay: 'Submit & Pay',
     confirm_submit_msg: 'Please verify your information. It cannot be easily changed after submission.',
+    no_events: 'No events have been logged for this order yet.',
+    slot_subscription_title: 'Subscribed slot',
+    slot_subscription_country: 'Country',
+    slot_subscription_city: 'City',
+    slot_subscription_visa: 'Visa type',
+    notification_hint: 'We will email or WhatsApp you once the slot snapshot is found; submit this order to reserve the appointment.'
   },
   status: {
     pending: 'Pending',
@@ -312,6 +326,8 @@ export const en = {
     subtitle: 'Check real-time appointment availability for embassies',
     status_available: 'Slots Available',
     status_unavailable: 'No Slots Available',
+    subscribe_notification: 'Subscribe for slot updates',
+    subscribe_unavailable: 'Routing info unavailable',
     updated_at: 'Updated at',
     earliest_date: 'Earliest Date',
     slots_count: 'slots',
@@ -364,11 +380,29 @@ export const en = {
     privacy: 'Privacy Policy',
     cookie: 'Cookie Policy',
   },
+  terms: {
+    subtitle: 'Visitor Agreement',
+    title: 'Terms Of Service',
+    description: 'We strive to operate our platform transparently and securely. By engaging with Visafly web services, you agree to the policies outlined in this section.',
+    point_one: 'You are responsible for providing accurate personal and travel information.',
+    point_two: 'Payments must be completed through the provided channels; we do not store card data ourselves.',
+    point_three: 'Orders are subject to local embassy and provider availability; we reserve the right to refuse or cancel services based on objective constraints.',
+    contact_prompt: 'If you have questions about these terms, please reach out via'
+  },
+  contact: {
+    subtitle: 'Reach Out Anytime',
+    title: 'Contact Us',
+    description: 'Our team is available around the clock to handle inquiries, support tickets, and refunds. Choose the channel that suits you best.',
+    address_label: 'Office',
+    address: 'Room 12B, Tech Park, Beijing, China'
+  },
   payment: {
     manual_adjustment: 'Price Adjustment',
     order_created: 'Order Created',
     order_id: 'Order ID',
     select_method: 'Select Payment Method',
+    select_method_subtitle: 'Choose the payment channel that suits you best.',
+    subscription_only_stripe_hint: 'Slot subscription orders only support Stripe payments.',
     creating: 'Creating payment...',
     reselect: 'Back',
     expires: 'expires',
@@ -383,7 +417,7 @@ export const en = {
     unsupported_channel: 'Unsupported payment channel',
     active_payment_exists: 'There is already an active payment for this order.',
     init_failed: 'Payment initialization failed',
-    link_pay_hint: 'Click the button below to pay, then return here:',
+    link_pay_hint: 'Please click the payment button below to pay, then return here once finished.',
     qr_pay_hint: 'Scan to pay, then simply click the confirm button below:',
     completed_btn: 'I Have Paid, Confirm Now',
     confirm_hint_text: 'Important: After completing the payment, you MUST click the button below. This notifies our system to verify your transaction immediately.',
@@ -412,4 +446,4 @@ export const en = {
     section3_title: '3. Managing Cookies',
     section3_desc: 'You can control and manage cookies through your browser settings. However, disabling cookies may affect some website functionalities.',
   }
-};
+};

+ 35 - 1
src/lib/i18n/locales/zh.ts

@@ -165,6 +165,14 @@ export const zh = {
     application_data: '申请资料 (Application Data)',
     no_application_data: '未填写申请资料',
     payment_history: '支付流水',
+    event_history: '事件记录',
+    service_label: '服务',
+    price_label: '价格',
+    promo_label: '限时保障',
+    promo_subtext: '自动锁定座位与提醒',
+    promo_label_slot: '即时通知',
+    promo_subtext_slot: '名额出现时通过邮件和Whatsapp提醒',
+    service_details: '服务详情',
     load_product_failed: '商品信息加载失败',
     product_not_found: '无法找到该服务',
     create_failed: '创建订单失败',
@@ -172,6 +180,12 @@ export const zh = {
     no_extra_info_needed: '无需填写额外信息,请直接提交。',
     submit_and_pay: '提交订单并支付',
     confirm_submit_msg: '请核对填写的信息是否准确,提交后将无法直接修改。',
+    no_events: '该订单暂无事件记录。',
+    slot_subscription_title: '订阅名额',
+    slot_subscription_country: '国家',
+    slot_subscription_city: '城市',
+    slot_subscription_visa: '签证类型',
+    notification_hint: '名额快照出现后我们会通过邮件或Whatsapp通知,提交订单即可保留该名额。'
   },
   status: {
     pending: '待支付',
@@ -312,6 +326,8 @@ export const zh = {
     subtitle: '实时查询各使馆最新可预约名额',
     status_available: '当前有名额可约',
     status_unavailable: '暂无名额',
+    subscribe_notification: '订阅名额更新',
+    subscribe_unavailable: '暂缺路由信息',
     updated_at: '数据更新于',
     earliest_date: '最早可约',
     slots_count: '个时段',
@@ -364,11 +380,29 @@ export const zh = {
     privacy: '隐私政策',
     cookie: 'Cookie 政策',
   },
+  terms: {
+    subtitle: '访客协议',
+    title: '服务条款',
+    description: '我们致力于为每一次服务建立清晰的规则。使用 Visafly 平台即意味着您同意遵守以下条款。',
+    point_one: '请认真核对并提交您的个人与出行信息,确保其真实有效。',
+    point_two: '所有支付行为必须通过授权通道完成,我们不会保存您的银行卡号。',
+    point_three: '订单以当地使馆或合作服务商可用性为准,Visafly 有权在客观原因下调整或取消服务。',
+    contact_prompt: '如需进一步说明,请通过以下方式联系'
+  },
+  contact: {
+    subtitle: '随时欢迎联系',
+    title: '联系我们',
+    description: '我们的支持团队 24/7 在线,帮助您处理咨询、工单或退款问题。请选择最方便的方式和我们沟通。',
+    address_label: '办公地点',
+    address: '北京市科技园区 12B 室'
+  },
   payment: {
     manual_adjustment: '人工调价',
     order_created: '订单已创建',
     order_id: '订单号',
     select_method: '选择支付方式',
+    select_method_subtitle: '请选择支付方式;订阅类订单仅支持 Stripe。',
+    subscription_only_stripe_hint: '订阅名额订单仅支持 Stripe 支付。',
     creating: '正在创建支付...',
     reselect: '重选方式',
     expires: '过期',
@@ -412,4 +446,4 @@ export const zh = {
     section3_title: '3. 如何管理 Cookie',
     section3_desc: '您可以通过浏览器设置禁用 Cookie,但这可能会影响网站的部分功能。',
   }
-};
+};

Некоторые файлы не были показаны из-за большого количества измененных файлов