Xây Dựng Video Platform: Public Course Catalog & SEO

Chúng ta có một platform hoạt động — course, video, streaming, subscription. Nhưng user không thể tìm thấy nó. Không có public catalog, không có SEO, không có cách nào để Google index course. Trong bài này, chúng ta sẽ build storefront: một course catalog server-rendered giúp rank trên search engine và convert visitor thành subscriber.
Ý tưởng chính: catalog là công cụ marketing. Mỗi course page cần rank trên Google, thuyết phục visitor rằng content đáng trả tiền (với free preview lesson), và dẫn họ vào subscription flow. Chúng ta sẽ dùng Next.js server component cho tốc độ load nhanh và SEO, JSON-LD cho rich search result, và UI gọn gàng để bán hàng.
Thời gian: 3–4 giờ
Yêu cầu: Phase 9: Stripe Subscription Integration
Những gì chúng ta sẽ build trong bài này:
✅ Public course catalog với responsive grid layout
✅ Server-rendered course detail page với full SEO
✅ generateMetadata() cho dynamic Open Graph tag
✅ JSON-LD structured data cho rich Google result
✅ Free preview lesson cho non-subscriber
✅ Enrollment flow: duyệt → preview → subscribe → xem
✅ Course progress indicator cho logged-in user
Public API Endpoint
Trước khi build frontend, chúng ta cần public (không cần auth) API endpoint serve course data mà không yêu cầu JWT token.
PublicCourseController
// src/main/java/com/videoplatform/api/controller/PublicCourseController.java
package com.videoplatform.api.controller;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.PublicCourseDetailResponse;
import com.videoplatform.api.dto.response.PublicCourseListResponse;
import com.videoplatform.api.service.PublicCourseService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/public/courses")
public class PublicCourseController {
private final PublicCourseService courseService;
public PublicCourseController(PublicCourseService courseService) {
this.courseService = courseService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<PublicCourseListResponse>>> listCourses(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size) {
var courses = courseService.getPublishedCourses(page, size);
return ResponseEntity.ok(ApiResponse.success(courses));
}
@GetMapping("/{slug}")
public ResponseEntity<ApiResponse<PublicCourseDetailResponse>> getCourse(
@PathVariable String slug) {
var course = courseService.getCourseBySlug(slug);
return ResponseEntity.ok(ApiResponse.success(course));
}
@GetMapping("/featured")
public ResponseEntity<ApiResponse<List<PublicCourseListResponse>>> getFeaturedCourses() {
var courses = courseService.getFeaturedCourses();
return ResponseEntity.ok(ApiResponse.success(courses));
}
}Public DTO
// src/main/java/com/videoplatform/api/dto/response/PublicCourseListResponse.java
package com.videoplatform.api.dto.response;
public record PublicCourseListResponse(
Long id,
String title,
String slug,
String description,
String thumbnailUrl,
String difficulty,
int totalLessons,
int totalDurationMinutes,
int enrolledCount
) {}// src/main/java/com/videoplatform/api/dto/response/PublicCourseDetailResponse.java
package com.videoplatform.api.dto.response;
import java.util.List;
public record PublicCourseDetailResponse(
Long id,
String title,
String slug,
String description,
String longDescription,
String thumbnailUrl,
String difficulty,
int totalLessons,
int totalDurationMinutes,
int enrolledCount,
List<SectionDetail> sections
) {
public record SectionDetail(
Long id,
String title,
int sortOrder,
List<LessonPreview> lessons
) {}
public record LessonPreview(
Long id,
String title,
int durationSeconds,
boolean isFreePreview,
int sortOrder
) {}
}PublicCourseService
// src/main/java/com/videoplatform/api/service/PublicCourseService.java
package com.videoplatform.api.service;
import com.videoplatform.api.dto.response.PublicCourseDetailResponse;
import com.videoplatform.api.dto.response.PublicCourseListResponse;
import com.videoplatform.api.entity.Course;
import com.videoplatform.api.entity.CourseStatus;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.CourseRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PublicCourseService {
private final CourseRepository courseRepository;
public PublicCourseService(CourseRepository courseRepository) {
this.courseRepository = courseRepository;
}
@Transactional(readOnly = true)
public List<PublicCourseListResponse> getPublishedCourses(int page, int size) {
var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return courseRepository.findByStatus(CourseStatus.PUBLISHED, pageable)
.stream()
.map(this::toListResponse)
.toList();
}
@Transactional(readOnly = true)
public PublicCourseDetailResponse getCourseBySlug(String slug) {
Course course = courseRepository.findBySlugAndStatus(slug, CourseStatus.PUBLISHED)
.orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy course: " + slug));
return toDetailResponse(course);
}
@Transactional(readOnly = true)
public List<PublicCourseListResponse> getFeaturedCourses() {
return courseRepository.findByStatusAndFeaturedTrue(CourseStatus.PUBLISHED)
.stream()
.map(this::toListResponse)
.toList();
}
private PublicCourseListResponse toListResponse(Course course) {
int totalLessons = course.getSections().stream()
.mapToInt(s -> s.getLessons().size())
.sum();
int totalDuration = course.getSections().stream()
.flatMap(s -> s.getLessons().stream())
.mapToInt(l -> l.getDurationSeconds() != null ? l.getDurationSeconds() : 0)
.sum();
return new PublicCourseListResponse(
course.getId(),
course.getTitle(),
course.getSlug(),
course.getDescription(),
course.getThumbnailUrl(),
course.getDifficulty().name(),
totalLessons,
totalDuration / 60,
course.getEnrolledCount()
);
}
private PublicCourseDetailResponse toDetailResponse(Course course) {
var sections = course.getSections().stream()
.sorted((a, b) -> a.getSortOrder() - b.getSortOrder())
.map(section -> {
var lessons = section.getLessons().stream()
.sorted((a, b) -> a.getSortOrder() - b.getSortOrder())
.map(lesson -> new PublicCourseDetailResponse.LessonPreview(
lesson.getId(),
lesson.getTitle(),
lesson.getDurationSeconds() != null ? lesson.getDurationSeconds() : 0,
lesson.isFreePreview(),
lesson.getSortOrder()
))
.toList();
return new PublicCourseDetailResponse.SectionDetail(
section.getId(),
section.getTitle(),
section.getSortOrder(),
lessons
);
})
.toList();
int totalLessons = sections.stream()
.mapToInt(s -> s.lessons().size())
.sum();
int totalDuration = sections.stream()
.flatMap(s -> s.lessons().stream())
.mapToInt(PublicCourseDetailResponse.LessonPreview::durationSeconds)
.sum();
return new PublicCourseDetailResponse(
course.getId(),
course.getTitle(),
course.getSlug(),
course.getDescription(),
course.getLongDescription(),
course.getThumbnailUrl(),
course.getDifficulty().name(),
totalLessons,
totalDuration / 60,
course.getEnrolledCount(),
sections
);
}
}Thêm Free Preview Flag Vào Lesson Entity
-- src/main/resources/db/migration/V6__add_free_preview_flag.sql
ALTER TABLE lessons ADD COLUMN is_free_preview BOOLEAN NOT NULL DEFAULT FALSE;
-- Đặt lesson đầu tiên của mỗi course làm free preview mặc định
UPDATE lessons SET is_free_preview = TRUE
WHERE id IN (
SELECT l.id FROM lessons l
INNER JOIN sections s ON l.section_id = s.id
WHERE s.sort_order = 0 AND l.sort_order = 0
);// Thêm vào Lesson entity
@Column(name = "is_free_preview", nullable = false)
private boolean freePreview = false;
public boolean isFreePreview() { return freePreview; }
public void setFreePreview(boolean freePreview) { this.freePreview = freePreview; }Update Security Config
// Trong SecurityConfig.java — thêm public course endpoint
.requestMatchers("/api/public/**").permitAll()Course Catalog Page
Data Fetching
// web/src/lib/api.ts — thêm public API function
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
// Server-side fetch (không cần auth cho public endpoint)
export async function getPublicCourses(page = 0, size = 12) {
const res = await fetch(
`${API_BASE}/api/public/courses?page=${page}&size=${size}`,
{ next: { revalidate: 300 } } // ISR: revalidate mỗi 5 phút
);
if (!res.ok) throw new Error("Không thể fetch course");
const data = await res.json();
return data.data;
}
export async function getPublicCourse(slug: string) {
const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
next: { revalidate: 300 },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error("Không thể fetch course");
}
const data = await res.json();
return data.data;
}
export async function getFeaturedCourses() {
const res = await fetch(`${API_BASE}/api/public/courses/featured`, {
next: { revalidate: 300 },
});
if (!res.ok) throw new Error("Không thể fetch featured course");
const data = await res.json();
return data.data;
}Type
// web/src/types/course.ts
export interface CourseListItem {
id: number;
title: string;
slug: string;
description: string;
thumbnailUrl: string | null;
difficulty: "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
totalLessons: number;
totalDurationMinutes: number;
enrolledCount: number;
}
export interface CourseDetail {
id: number;
title: string;
slug: string;
description: string;
longDescription: string | null;
thumbnailUrl: string | null;
difficulty: "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
totalLessons: number;
totalDurationMinutes: number;
enrolledCount: number;
sections: SectionDetail[];
}
export interface SectionDetail {
id: number;
title: string;
sortOrder: number;
lessons: LessonPreview[];
}
export interface LessonPreview {
id: number;
title: string;
durationSeconds: number;
isFreePreview: boolean;
sortOrder: number;
}Catalog Page (Server Component)
// web/src/app/(public)/courses/page.tsx
import { Metadata } from "next";
import { getPublicCourses } from "@/lib/api";
import { CourseCard } from "@/components/CourseCard";
import { CourseListItem } from "@/types/course";
export const metadata: Metadata = {
title: "Course Catalog | VideoLearn",
description:
"Duyệt bộ sưu tập khóa học video. Học lập trình, web development, và nhiều hơn nữa với video tutorial thực hành.",
openGraph: {
title: "Course Catalog | VideoLearn",
description: "Duyệt bộ sưu tập khóa học video.",
type: "website",
},
};
export default async function CourseCatalogPage() {
const courses: CourseListItem[] = await getPublicCourses();
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">Course Catalog</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Khóa học video thực hành, project-based. Subscribe một lần, truy cập tất cả.
</p>
</div>
{courses.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground text-lg">
Chưa có khóa học nào. Quay lại sau nhé!
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
)}
</div>
);
}CourseCard Component
// web/src/components/CourseCard.tsx
import Link from "next/link";
import Image from "next/image";
import { Clock, BookOpen, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CourseListItem } from "@/types/course";
const difficultyColors = {
BEGINNER: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
INTERMEDIATE: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
ADVANCED: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) return `${mins}m`;
if (mins === 0) return `${hours}h`;
return `${hours}h ${mins}m`;
}
export function CourseCard({ course }: { course: CourseListItem }) {
return (
<Link href={`/courses/${course.slug}`} className="group">
<div className="rounded-xl border bg-card overflow-hidden transition-all
hover:shadow-lg hover:border-primary/50 h-full flex flex-col">
{/* Thumbnail */}
<div className="relative aspect-video bg-muted">
{course.thumbnailUrl ? (
<Image
src={course.thumbnailUrl}
alt={course.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center
bg-gradient-to-br from-primary/20 to-primary/5">
<BookOpen className="h-12 w-12 text-primary/40" />
</div>
)}
<Badge
className={`absolute top-3 left-3 ${difficultyColors[course.difficulty]}`}
>
{course.difficulty.toLowerCase()}
</Badge>
</div>
{/* Content */}
<div className="p-5 flex flex-col flex-1">
<h3 className="font-semibold text-lg mb-2 line-clamp-2 group-hover:text-primary
transition-colors">
{course.title}
</h3>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 flex-1">
{course.description}
</p>
{/* Thống kê */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
{course.totalLessons} bài
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatDuration(course.totalDurationMinutes)}
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{course.enrolledCount}
</span>
</div>
</div>
</div>
</Link>
);
}Course Detail Page Với SEO
Dynamic Metadata Với generateMetadata()
// web/src/app/(public)/courses/[slug]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPublicCourse, getPublicCourses } from "@/lib/api";
import { CourseSyllabus } from "@/components/CourseSyllabus";
import { CourseHero } from "@/components/CourseHero";
import { CourseJsonLd } from "@/components/CourseJsonLd";
import { CourseDetail } from "@/types/course";
interface Props {
params: Promise<{ slug: string }>;
}
// Generate static path cho published course
export async function generateStaticParams() {
const courses = await getPublicCourses(0, 100);
return courses.map((course: { slug: string }) => ({
slug: course.slug,
}));
}
// Dynamic metadata cho mỗi course
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const course: CourseDetail | null = await getPublicCourse(slug);
if (!course) {
return { title: "Course Not Found" };
}
const title = `${course.title} | VideoLearn`;
const description = course.description;
const url = `${process.env.NEXT_PUBLIC_SITE_URL}/courses/${slug}`;
return {
title,
description,
openGraph: {
title,
description,
url,
type: "website",
images: course.thumbnailUrl
? [{ url: course.thumbnailUrl, width: 1200, height: 630 }]
: undefined,
},
twitter: {
card: "summary_large_image",
title,
description,
},
alternates: {
canonical: url,
},
};
}
export default async function CourseDetailPage({ params }: Props) {
const { slug } = await params;
const course: CourseDetail | null = await getPublicCourse(slug);
if (!course) notFound();
return (
<>
<CourseJsonLd course={course} />
<CourseHero course={course} />
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Mô tả dài */}
{course.longDescription && (
<section className="mb-12">
<h2 className="text-2xl font-bold mb-4">Về Khóa Học Này</h2>
<div className="prose dark:prose-invert max-w-none">
<p>{course.longDescription}</p>
</div>
</section>
)}
{/* Syllabus */}
<section>
<h2 className="text-2xl font-bold mb-6">Nội Dung Khóa Học</h2>
<p className="text-muted-foreground mb-6">
{course.totalLessons} bài học ·{" "}
{Math.floor(course.totalDurationMinutes / 60)}h{" "}
{course.totalDurationMinutes % 60}m tổng
</p>
<CourseSyllabus sections={course.sections} courseSlug={slug} />
</section>
</div>
</>
);
}JSON-LD Structured Data
JSON-LD cho Google biết chính xác page là về cái gì. Với course, nó enable rich result hiển thị rating, giá, và provider.
// web/src/components/CourseJsonLd.tsx
import { CourseDetail } from "@/types/course";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export function CourseJsonLd({ course }: { course: CourseDetail }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Course",
name: course.title,
description: course.description,
url: `${SITE_URL}/courses/${course.slug}`,
provider: {
"@type": "Organization",
name: "VideoLearn",
sameAs: SITE_URL,
},
// Chi tiết course
educationalLevel: course.difficulty.toLowerCase(),
numberOfLessons: course.totalLessons,
timeRequired: `PT${course.totalDurationMinutes}M`,
// Thumbnail
...(course.thumbnailUrl && {
image: course.thumbnailUrl,
}),
// Offer (subscription)
offers: [
{
"@type": "Offer",
category: "Subscription",
price: "9.99",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
url: `${SITE_URL}/pricing`,
description: "Monthly subscription — truy cập tất cả khóa học",
},
{
"@type": "Offer",
category: "Subscription",
price: "99.99",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
url: `${SITE_URL}/pricing`,
description: "Yearly subscription — truy cập tất cả khóa học (tiết kiệm 17%)",
},
],
// Syllabus dưới dạng item list
hasCourseInstance: {
"@type": "CourseInstance",
courseMode: "online",
courseWorkload: `PT${course.totalDurationMinutes}M`,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}Google Thấy Gì
Khi Google crawl /courses/spring-boot-fundamentals, nó tìm thấy:
{
"@context": "https://schema.org",
"@type": "Course",
"name": "Spring Boot Fundamentals",
"description": "Learn Spring Boot from scratch...",
"provider": { "@type": "Organization", "name": "VideoLearn" },
"educationalLevel": "beginner",
"numberOfLessons": 24,
"offers": [
{ "@type": "Offer", "price": "9.99", "priceCurrency": "USD" }
]
}Điều này có thể hiện rich result với tên course, provider, giá, và level trực tiếp trên kết quả tìm kiếm.
Course Hero Section
// web/src/components/CourseHero.tsx
import { Clock, BookOpen, Users, BarChart } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CourseDetail } from "@/types/course";
import { SubscribeButton } from "./SubscribeButton";
const difficultyLabels = {
BEGINNER: "Beginner",
INTERMEDIATE: "Intermediate",
ADVANCED: "Advanced",
};
export function CourseHero({ course }: { course: CourseDetail }) {
const freePreviewCount = course.sections
.flatMap((s) => s.lessons)
.filter((l) => l.isFreePreview).length;
return (
<div className="bg-gradient-to-b from-primary/5 to-background border-b">
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="flex flex-col gap-6">
{/* Badge difficulty */}
<Badge variant="outline" className="w-fit">
<BarChart className="h-3 w-3 mr-1" />
{difficultyLabels[course.difficulty]}
</Badge>
{/* Tiêu đề */}
<h1 className="text-4xl font-bold tracking-tight">
{course.title}
</h1>
{/* Mô tả */}
<p className="text-xl text-muted-foreground max-w-2xl">
{course.description}
</p>
{/* Hàng thống kê */}
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<BookOpen className="h-4 w-4" />
{course.totalLessons} bài học
</span>
<span className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
{Math.floor(course.totalDurationMinutes / 60)}h{" "}
{course.totalDurationMinutes % 60}m
</span>
<span className="flex items-center gap-1.5">
<Users className="h-4 w-4" />
{course.enrolledCount} đã đăng ký
</span>
</div>
{/* CTA */}
<div className="flex items-center gap-4 mt-2">
<SubscribeButton />
{freePreviewCount > 0 && (
<span className="text-sm text-muted-foreground">
{freePreviewCount} bài preview miễn phí
</span>
)}
</div>
</div>
</div>
</div>
);
}Course Syllabus Với Free Preview
// web/src/components/CourseSyllabus.tsx
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, Play, Lock, Eye } from "lucide-react";
import { SectionDetail, LessonPreview } from "@/types/course";
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
interface Props {
sections: SectionDetail[];
courseSlug: string;
}
export function CourseSyllabus({ sections, courseSlug }: Props) {
const [expandedSections, setExpandedSections] = useState<Set<number>>(
// Mở rộng section đầu tiên mặc định
new Set(sections.length > 0 ? [sections[0].id] : [])
);
const toggleSection = (sectionId: number) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
};
return (
<div className="space-y-2">
{sections.map((section) => {
const isExpanded = expandedSections.has(section.id);
const sectionDuration = section.lessons.reduce(
(sum, l) => sum + l.durationSeconds, 0
);
const freeCount = section.lessons.filter((l) => l.isFreePreview).length;
return (
<div key={section.id} className="rounded-lg border overflow-hidden">
{/* Header section */}
<button
onClick={() => toggleSection(section.id)}
className="w-full flex items-center justify-between p-4
hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 shrink-0" />
) : (
<ChevronRight className="h-5 w-5 shrink-0" />
)}
<div>
<h3 className="font-medium">{section.title}</h3>
<p className="text-sm text-muted-foreground">
{section.lessons.length} bài ·{" "}
{formatTime(sectionDuration)}
{freeCount > 0 && ` · ${freeCount} miễn phí`}
</p>
</div>
</div>
</button>
{/* Danh sách lesson */}
{isExpanded && (
<div className="border-t">
{section.lessons.map((lesson) => (
<LessonRow
key={lesson.id}
lesson={lesson}
courseSlug={courseSlug}
/>
))}
</div>
)}
</div>
);
})}
</div>
);
}
function LessonRow({ lesson, courseSlug }: { lesson: LessonPreview; courseSlug: string }) {
return (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30
transition-colors border-b last:border-b-0">
<div className="flex items-center gap-3">
{lesson.isFreePreview ? (
<Eye className="h-4 w-4 text-primary shrink-0" />
) : (
<Lock className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className={lesson.isFreePreview ? "text-primary" : ""}>
{lesson.title}
</span>
{lesson.isFreePreview && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Xem Miễn Phí
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{formatTime(lesson.durationSeconds)}
</span>
{lesson.isFreePreview && (
<a
href={`/courses/${courseSlug}/lessons/${lesson.id}`}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
<Play className="h-3 w-3" />
Xem
</a>
)}
</div>
</div>
);
}Subscribe Button
Button này thay đổi theo trạng thái auth và subscription:
// web/src/components/SubscribeButton.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
export function SubscribeButton() {
const router = useRouter();
const [status, setStatus] = useState<"loading" | "guest" | "subscribed" | "unsubscribed">(
"loading"
);
useEffect(() => {
const token = localStorage.getItem("accessToken");
if (!token) {
setStatus("guest");
return;
}
fetch(`${API_BASE}/api/subscriptions/current`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((data) => {
setStatus(data.data ? "subscribed" : "unsubscribed");
})
.catch(() => setStatus("unsubscribed"));
}, []);
if (status === "loading") {
return (
<button disabled className="px-6 py-3 rounded-lg bg-primary/50 text-primary-foreground">
Đang tải...
</button>
);
}
if (status === "subscribed") {
return (
<button
onClick={() => router.push("/dashboard")}
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground
hover:bg-primary/90 transition-colors font-medium"
>
Vào Dashboard
</button>
);
}
return (
<button
onClick={() => {
if (status === "guest") {
router.push("/login?redirect=/pricing");
} else {
router.push("/pricing");
}
}}
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground
hover:bg-primary/90 transition-colors font-medium"
>
Subscribe Để Xem
</button>
);
}Enrollment Flow
Đây là hành trình đầy đủ của user:
Funnel là: Khám phá → Preview → Subscribe → Xem. Free preview lesson là công cụ conversion chính — chúng cho user đánh giá chất lượng content trước khi cam kết.
Course Progress Cho User Đã Đăng Nhập
Với subscriber, hiển thị bao nhiêu phần trăm course đã hoàn thành:
// web/src/components/CourseProgress.tsx
"use client";
import { useEffect, useState } from "react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
interface Props {
courseId: number;
totalLessons: number;
}
export function CourseProgress({ courseId, totalLessons }: Props) {
const [completedCount, setCompletedCount] = useState<number | null>(null);
useEffect(() => {
const token = localStorage.getItem("accessToken");
if (!token) return;
fetch(`${API_BASE}/api/courses/${courseId}/progress`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((data) => setCompletedCount(data.data?.completedLessons ?? 0))
.catch(() => {});
}, [courseId]);
if (completedCount === null || completedCount === 0) return null;
const percentage = Math.round((completedCount / totalLessons) * 100);
return (
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Tiến độ của bạn</span>
<span className="font-medium">{percentage}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{completedCount} / {totalLessons} bài đã hoàn thành
</p>
</div>
);
}Progress API Endpoint
// Thêm vào controller hiện có
@GetMapping("/api/courses/{courseId}/progress")
public ResponseEntity<ApiResponse<CourseProgressResponse>> getCourseProgress(
@PathVariable Long courseId,
@AuthenticationPrincipal UserDetails userDetails) {
User user = findUser(userDetails);
long completedLessons = progressRepository
.countByUserAndLessonCourseSectionCourseIdAndCompletedTrue(user, courseId);
return ResponseEntity.ok(ApiResponse.success(
new CourseProgressResponse(completedLessons)
));
}
record CourseProgressResponse(long completedLessons) {}Sitemap Generation
Để SEO, generate dynamic sitemap bao gồm tất cả published course:
// web/src/app/sitemap.ts
import { MetadataRoute } from "next";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Static page
const staticPages: MetadataRoute.Sitemap = [
{ url: SITE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${SITE_URL}/courses`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
{ url: `${SITE_URL}/pricing`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
];
// Dynamic course page
try {
const res = await fetch(`${API_BASE}/api/public/courses?page=0&size=100`);
const data = await res.json();
const courses = data.data || [];
const coursePages: MetadataRoute.Sitemap = courses.map(
(course: { slug: string }) => ({
url: `${SITE_URL}/courses/${course.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
})
);
return [...staticPages, ...coursePages];
} catch {
return staticPages;
}
}Testing
1. Kiểm Tra Public API
# List published course
curl -s http://localhost:8080/api/public/courses | jq
# Lấy course theo slug
curl -s http://localhost:8080/api/public/courses/spring-boot-fundamentals | jq
# Kiểm tra free preview flag
curl -s http://localhost:8080/api/public/courses/spring-boot-fundamentals | \
jq '.data.sections[].lessons[] | select(.isFreePreview == true)'2. Test SEO Output
# Kiểm tra meta tag
curl -s http://localhost:3000/courses/spring-boot-fundamentals | \
grep -E '<meta|<script type="application/ld\+json"' | head -20
# Validate JSON-LD
# Copy JSON-LD từ page source và paste vào:
# https://validator.schema.org/3. Test Sitemap
curl -s http://localhost:3000/sitemap.xml
# Phải bao gồm /courses/spring-boot-fundamentals4. Test Enrollment Flow
- Mở
/courses/spring-boot-fundamentalsở incognito - Kiểm tra: thông tin course hiển thị, lesson có lock icon, free preview có link "Xem"
- Click "Subscribe Để Xem" → redirect tới login
- Đăng nhập → redirect tới pricing
- Subscribe → có quyền truy cập tất cả lesson
5. Lighthouse SEO Audit
Chạy Lighthouse trên course page:
npx lighthouse http://localhost:3000/courses/spring-boot-fundamentals \
--only-categories=seo --output=json | jq '.categories.seo.score'
# Mục tiêu: 0.9+Sai Lầm Thường Gặp
1. Client-Side Data Fetching Cho SEO Page
// SAI — Google không thấy content này
"use client";
export default function CoursePage() {
const [course, setCourse] = useState(null);
useEffect(() => { fetchCourse().then(setCourse); }, []);
return <div>{course?.title}</div>;
}
// ĐÚNG — server component, content trong initial HTML
export default async function CoursePage({ params }: Props) {
const course = await getPublicCourse(params.slug);
return <div>{course.title}</div>;
}Google crawler có execute JavaScript, nhưng server-rendered content được index nhanh hơn và đáng tin cậy hơn.
2. Thiếu generateMetadata()
Nếu không có generateMetadata(), tất cả course page dùng chung meta tag generic. Mỗi course cần title, description, và Open Graph image riêng cho social sharing.
3. Block Toàn Bộ Page Sau Auth
// SAI — Google không index được, visitor không preview được
if (!isLoggedIn) return <LoginPrompt />;
return <CourseContent />;
// ĐÚNG — hiển thị course info công khai, gate video playback
return (
<>
<CourseHero course={course} /> {/* Luôn hiển thị */}
<CourseSyllabus sections={sections} /> {/* Luôn hiển thị */}
<VideoPlayer requiresAuth /> {/* Bị gate */}
</>
);4. Quên ISR/Revalidation
// SAI — fetch mỗi request (chậm)
const res = await fetch(url);
// SAI — cache mãi mãi (data cũ)
const res = await fetch(url, { cache: "force-cache" });
// ĐÚNG — revalidate định kỳ
const res = await fetch(url, { next: { revalidate: 300 } }); // 5 phútTiếp Theo Là Gì?
Public catalog đã live — course được index, visitor có thể preview content, và subscription funnel đã hoàn chỉnh. Trong Bài #12, chúng ta sẽ build admin analytics:
- Dashboard với key metric (revenue, subscriber, completion)
- Revenue chart với Recharts
- Popular lesson và completion rate
- User management với search và manual access grant
- Export functionality cho report
Đến lúc cho admin nhìn thấy mọi thứ đang xảy ra trên platform.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 9: Stripe Subscription Integration
Tiếp theo: Phase 11: Admin Analytics & User Management
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.