ALIEZ před 1 měsícem
rodič
revize
0ee549ae15

+ 6 - 1
src/app/[locale]/courses/[slug]/page.tsx

@@ -4,6 +4,7 @@ import {
   fetchCourses,
   getCourseBySlug,
   shouldShowBuyButton,
+  type Course,
 } from "@/data/courses";
 import { CourseVideosListClient } from "@/app/[locale]/courses/[slug]/videos-list-client";
 import { CourseFilesListClient } from "@/app/[locale]/courses/[slug]/files-list-client";
@@ -16,6 +17,10 @@ type DetailProps = {
   searchParams: SearchParams;
 };
 
+function shouldShowFreeBadge(course: Course): boolean {
+  return course.category === "trial" && course.price === 0;
+}
+
 export default async function CourseDetailPage({ params, searchParams }: DetailProps) {
   const { locale, slug } = await params;
   const query = await searchParams;
@@ -76,7 +81,7 @@ export default async function CourseDetailPage({ params, searchParams }: DetailP
               <p className="text-[11px] font-semibold uppercase tracking-widest text-slate-400">
                 {t("lessons", { count: course.lessonCount })}
               </p>
-              {course.price > 0 ? (
+              {!shouldShowFreeBadge(course) ? (
                 <div className="flex w-fit items-baseline gap-2.5 rounded-2xl bg-gradient-to-br from-amber-50 via-amber-50 to-amber-100/90 px-4 py-2.5 shadow-md shadow-amber-900/5 ring-1 ring-amber-200/90 tabular-nums">
                   <span className="text-3xl font-bold leading-none tracking-tight text-amber-900 md:text-4xl">
                     ${course.price}

+ 48 - 6
src/app/[locale]/courses/courses-list-client.tsx

@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
 import { Link } from "@/i18n/navigation";
 import { CourseBuyButton } from "@/components/course-buy-button";
 import { InlineLoading, SkeletonBlock } from "@/components/ui/loading-state";
-import { fetchCourses, type Course, type CourseCategory } from "@/data/courses";
+import { fetchCoursesPaged, type Course, type CourseCategory } from "@/data/courses";
 
 type Props = {
   active: CourseCategory | null;
@@ -16,21 +16,33 @@ function shouldShowBuyButton(course: Course): boolean {
   return !(course.goodsType === 1 || course.customType === 2);
 }
 
+function shouldShowFreeBadge(course: Course): boolean {
+  return course.category === "trial" && course.price === 0;
+}
+
 export function CoursesListClient({ active, initialCourses }: Props) {
+  const pageSize = 10;
   const [courses, setCourses] = useState(initialCourses);
   const [loading, setLoading] = useState(true);
+  const [page, setPage] = useState(1);
+  const [total, setTotal] = useState(0);
   const t = useTranslations("courses");
   const tn = useTranslations("nav");
 
   useEffect(() => {
     let cancelled = false;
-    void fetchCourses(active ?? undefined)
-      .then((data) => {
+    void fetchCoursesPaged(active ?? undefined, { current: page, row: pageSize })
+      .then((result) => {
         if (cancelled) return;
-        setCourses(data);
+        setCourses(result.list);
+        setTotal(result.page.total);
       })
       .catch(() => {
         // Keep server-rendered fallback data.
+        if (!cancelled) {
+          setCourses([]);
+          setTotal(0);
+        }
       })
       .finally(() => {
         if (!cancelled) setLoading(false);
@@ -38,7 +50,10 @@ export function CoursesListClient({ active, initialCourses }: Props) {
     return () => {
       cancelled = true;
     };
-  }, [active]);
+  }, [active, page]);
+
+  const canPrev = page > 1 && !loading;
+  const canNext = !loading && page * pageSize < total;
 
   return (
     <div>
@@ -73,7 +88,7 @@ export function CoursesListClient({ active, initialCourses }: Props) {
                   {c.title}
                 </h2>
                 <div className="shrink-0 translate-y-px">
-                  {c.price === 0 ? (
+                  {shouldShowFreeBadge(c) ? (
                     <span className="inline-flex w-fit items-center rounded-full bg-emerald-50 px-3 py-2 text-sm font-bold tracking-tight text-emerald-700 ring-1 ring-emerald-200/70">
                       {t("free")}
                     </span>
@@ -114,6 +129,33 @@ export function CoursesListClient({ active, initialCourses }: Props) {
         </li>
       ))}
       </ul>
+      <div className="mt-5 flex items-center justify-end gap-3">
+        <button
+          type="button"
+          onClick={() => {
+            if (!canPrev) return;
+            setLoading(true);
+            setPage((p) => Math.max(1, p - 1));
+          }}
+          disabled={!canPrev}
+          className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
+        >
+          {t("videoPrevPage")}
+        </button>
+        <span className="text-sm text-slate-500">{t("videoCurrentPage", { page })}</span>
+        <button
+          type="button"
+          onClick={() => {
+            if (!canNext) return;
+            setLoading(true);
+            setPage((p) => p + 1);
+          }}
+          disabled={!canNext}
+          className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
+        >
+          {t("videoNextPage")}
+        </button>
+      </div>
     </div>
   );
 }

+ 1 - 0
src/app/[locale]/courses/page.tsx

@@ -69,6 +69,7 @@ export default async function CoursesPage({ params, searchParams }: Props) {
         </div>
 
         <CoursesListClient
+          key={active ?? "all"}
           active={active}
           initialCourses={list}
         />

+ 20 - 3
src/components/course-buy-button.tsx

@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
 import { useAuth } from "@/providers/auth-provider";
 import { generateOrderByGoodId } from "@/lib/order-api";
 import { ModalShell } from "@/components/ui/modal-shell";
+import { ApiError } from "@/lib/api";
 
 type Props = {
   courseSlug: string;
@@ -38,6 +39,7 @@ export function CourseBuyButton({
     message: "",
   });
   const [isFreeOrderSubmitting, setIsFreeOrderSubmitting] = useState(false);
+  const [isAuthExpiredError, setIsAuthExpiredError] = useState(false);
   const checkoutPath =
     `/checkout?course=${encodeURIComponent(courseSlug)}` +
     `&id=${encodeURIComponent(courseId)}` +
@@ -68,6 +70,7 @@ export function CourseBuyButton({
     }
 
     try {
+      setIsAuthExpiredError(false);
       setIsFreeOrderSubmitting(true);
       setFreeOrderDialog({
         open: true,
@@ -85,13 +88,21 @@ export function CourseBuyButton({
       }, 900);
       refreshTimerRef.current = window.setTimeout(() => {
         router.refresh();
+        // 某些场景下仅 refresh 不会触发列表客户端状态更新,补一次整页刷新确保权益即时生效。
+        window.setTimeout(() => {
+          window.location.reload();
+        }, 120);
       }, 1200);
     } catch (error) {
+      const isAuthExpired =
+        (error instanceof ApiError && error.status === 600) ||
+        (error instanceof Error && error.message.includes("登录状态失效"));
+      setIsAuthExpiredError(isAuthExpired);
       const message = error instanceof Error && error.message.trim() ? error.message : "购买失败,请稍后重试。";
       setFreeOrderDialog({
         open: true,
         status: "error",
-        message,
+        message: isAuthExpired ? "登录失效,请重新登录后再试。" : message,
       });
     } finally {
       setIsFreeOrderSubmitting(false);
@@ -143,10 +154,16 @@ export function CourseBuyButton({
             {freeOrderDialog.status === "error" ? (
               <button
                 type="button"
-                onClick={() => setFreeOrderDialog((prev) => ({ ...prev, open: false }))}
+                onClick={() => {
+                  setFreeOrderDialog((prev) => ({ ...prev, open: false }));
+                  if (isAuthExpiredError) {
+                    const next = encodeURIComponent(checkoutPath);
+                    router.push(`/auth/login?next=${next}`);
+                  }
+                }}
                 className="mt-5 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm hover:bg-[var(--background)]"
               >
-                知道了
+                {isAuthExpiredError ? "去登录" : "知道了"}
               </button>
             ) : null}
           </div>

+ 32 - 5
src/data/courses.ts

@@ -64,6 +64,15 @@ export type CourseFilePageResult = {
   };
 };
 
+export type CoursePageResult = {
+  list: Course[];
+  page: {
+    current: number;
+    row: number;
+    total: number;
+  };
+};
+
 export const COURSE_CATEGORIES: {
   id: CourseCategory;
   labelKey: string;
@@ -512,7 +521,12 @@ function normalizeFileFromVideo(item: GoodsVideoListItem): CourseFile[] {
   }));
 }
 
-export async function fetchCourses(category?: CourseCategory): Promise<Course[]> {
+export async function fetchCoursesPaged(
+  category?: CourseCategory,
+  page: { current: number; row: number } = { current: 1, row: 10 },
+): Promise<CoursePageResult> {
+  const current = Number.isFinite(page.current) ? Math.max(1, page.current) : 1;
+  const row = Number.isFinite(page.row) ? Math.max(1, page.row) : 10;
   try {
     const payload = await apiPost<
       GoodsSearchListResponse,
@@ -521,19 +535,32 @@ export async function fetchCourses(category?: CourseCategory): Promise<Course[]>
       "/goods/search/list",
       {
         ...(category ? { goodsType: categoryToGoodsType(category) } : {}),
-        page: { current: 1, row: 10 },
+        page: { current, row },
       },
     );
-    const normalized = extractList(payload)
+    const list = extractList(payload)
       .map(normalizeCourse)
       .filter((c): c is Course => c !== null);
-    return normalized;
+    const serverPage = extractSearchPage(payload);
+    return {
+      list,
+      page: {
+        current: serverPage.current || current,
+        row: serverPage.row || row,
+        total: serverPage.total || list.length,
+      },
+    };
   } catch {
     // Return empty list when API fails to avoid showing mock items.
-    return [];
+    return { list: [], page: { current, row, total: 0 } };
   }
 }
 
+export async function fetchCourses(category?: CourseCategory): Promise<Course[]> {
+  const result = await fetchCoursesPaged(category, { current: 1, row: 10 });
+  return result.list;
+}
+
 export async function fetchCourseVideos(
   goodsId: string,
   page: { current: number; row: number } = { current: 1, row: 10 },