- {config.profile.name}
+ {profileName}
{config.profile.desc}
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
new file mode 100644
index 0000000..65e6965
--- /dev/null
+++ b/app/privacy/page.tsx
@@ -0,0 +1,120 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft } from "lucide-react";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "นโยบายความเป็นส่วนตัว",
+ description: "นโยบายความเป็นส่วนตัวของเว็บไซต์ Erika!",
+};
+
+export default function PrivacyPolicyPage() {
+ return (
+
+
+
+
+
+
+
+
+
นโยบายความเป็นส่วนตัว
+
+
+
+
+ 1. ข้อมูลที่เราเก็บรวบรวม
+
+
+ เมื่อคุณใช้งานเว็บไซต์ Erika! เราอาจเก็บรวบรวมข้อมูลดังต่อไปนี้:
+
+ ข้อมูลบัญชี Discord (ชื่อผู้ใช้, Discord ID) เมื่อคุณเข้าสู่ระบบ
+ ข้อมูลที่คุณส่งผ่านฟอร์มบนเว็บไซต์
+ ข้อมูล IP Address เพื่อนับจำนวนผู้เข้าชม
+
+
+
+
+
+
+ 2. วัตถุประสงค์ในการใช้ข้อมูล
+
+
+ เราใช้ข้อมูลที่เก็บรวบรวมเพื่อ:
+
+ ยืนยันตัวตนผู้ใช้งานผ่าน Discord
+ ประมวลผลข้อมูลที่ส่งผ่านฟอร์ม
+ ปรับปรุงและพัฒนาเว็บไซต์
+ นับสถิติผู้เข้าชมเว็บไซต์
+
+
+
+
+
+
+ 3. การแบ่งปันข้อมูล
+
+
+ เราจะไม่ขาย แลกเปลี่ยน หรือเปิดเผยข้อมูลส่วนบุคคลของคุณให้แก่บุคคลภายนอก
+ ยกเว้นกรณีที่กฎหมายกำหนด
+
+
+
+
+
+ 4. การเก็บรักษาข้อมูล
+
+
+ ข้อมูลของคุณจะถูกเก็บรักษาไว้ตราบเท่าที่จำเป็นต่อการให้บริการ
+ คุณสามารถขอลบข้อมูลของคุณได้โดยติดต่อผู้ดูแลผ่านทาง Discord server
+
+
+
+
+
+ 5. ความปลอดภัยของข้อมูล
+
+
+ เราใช้มาตรการรักษาความปลอดภัยที่เหมาะสมเพื่อปกป้องข้อมูลของคุณ
+ อย่างไรก็ตาม ไม่มีวิธีการส่งข้อมูลผ่านอินเทอร์เน็ตที่ปลอดภัย 100%
+
+
+
+
+
+ 6. การเปลี่ยนแปลงนโยบาย
+
+
+ เราอาจปรับปรุงนโยบายความเป็นส่วนตัวนี้เป็นครั้งคราว
+ การเปลี่ยนแปลงจะมีผลทันทีเมื่อเผยแพร่บนเว็บไซต์
+
+
+
+
+
+ 7. ติดต่อเรา
+
+
+ หากมีคำถามเกี่ยวกับนโยบายความเป็นส่วนตัว สามารถติดต่อได้ผ่านทาง{" "}
+
+ Discord server
+
+
+
+
+
+
+
+ อัปเดตล่าสุด: 30 มิถุนายน 2569
+
+
+
+ );
+}
diff --git a/app/tos/page.tsx b/app/tos/page.tsx
new file mode 100644
index 0000000..5f1c232
--- /dev/null
+++ b/app/tos/page.tsx
@@ -0,0 +1,99 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft } from "lucide-react";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "ข้อกำหนดการใช้งาน ",
+ description: "ข้อกำหนดและเงื่อนไขการใช้งานเว็บไซต์ Erika!",
+};
+
+export default function TermsOfServicePage() {
+ return (
+
+
+
+
+
+
+
+
+
ข้อกำหนดการใช้งาน
+
+
+
+
+ 1. การยอมรับข้อกำหนด
+
+
+ การเข้าใช้งานเว็บไซต์ Erika! ถือว่าคุณยอมรับข้อกำหนดและเงื่อนไขทั้งหมดที่ระบุไว้ในหน้านี้
+ หากคุณไม่ยอมรับข้อกำหนดเหล่านี้ กรุณาหยุดใช้งานเว็บไซต์
+
+
+
+
+
+ 2. การใช้งานเว็บไซต์
+
+
+ คุณตกลงที่จะใช้งานเว็บไซต์นี้เพื่อวัตถุประสงค์ที่ถูกต้องตามกฎหมายเท่านั้น และจะไม่:
+
+ ใช้เว็บไซต์ในทางที่ผิดกฎหมายหรือฉ้อโกง
+ พยายามเข้าถึงระบบโดยไม่ได้รับอนุญาต
+ รบกวนการทำงานของเว็บไซต์หรือเซิร์ฟเวอร์
+ ส่งเนื้อหาที่ไม่เหมาะสมหรือสแปมผ่านฟอร์มต่างๆ
+
+
+
+
+
+
+ 3. บัญชีผู้ใช้
+
+
+ การเข้าสู่ระบบผ่าน Discord ถือว่าคุณอนุญาตให้เราเข้าถึงข้อมูลพื้นฐานของบัญชี Discord ของคุณ
+ คุณมีหน้าที่รับผิดชอบในการรักษาความปลอดภัยของบัญชีตนเอง
+
+
+
+
+
+ 4. เนื้อหาที่ผู้ใช้ส่ง
+
+
+ เนื้อหาที่คุณส่งผ่านฟอร์มบนเว็บไซต์จะถูกเก็บรักษาไว้เพื่อวัตถุประสงค์ของช่อง Erika!
+ เราขอสงวนสิทธิ์ในการลบเนื้อหาที่ไม่เหมาะสมโดยไม่ต้องแจ้งล่วงหน้า
+
+
+
+
+
+ 5. การจำกัดความรับผิดชอบ
+
+
+ เว็บไซต์นี้ให้บริการตามสภาพที่เป็นอยู่ ("as is") โดยไม่มีการรับประกันใดๆ
+ เราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดจากการใช้งานเว็บไซต์นี้
+
+
+
+
+
+ 6. การเปลี่ยนแปลงข้อกำหนด
+
+
+ เราขอสงวนสิทธิ์ในการแก้ไขข้อกำหนดเหล่านี้ได้ตลอดเวลาโดยไม่ต้องแจ้งล่วงหน้า
+ การใช้งานเว็บไซต์ต่อไปหลังจากมีการเปลี่ยนแปลง ถือว่าคุณยอมรับข้อกำหนดใหม่
+
+
+
+
+
+
+ อัปเดตล่าสุด: 30 มิถุนายน 2569
+
+
+
+ );
+}
diff --git a/lib/followers/index.ts b/lib/followers/index.ts
index b8962a8..1c43921 100644
--- a/lib/followers/index.ts
+++ b/lib/followers/index.ts
@@ -1,7 +1,9 @@
import { getYoutubeSubscriberCount } from "./youtube";
import { getRobloxFollowerCount } from "./roblox";
import { getDiscordMemberCount } from "./discord";
-import { getTiktokFollowerCount } from "./tiktok";
+import { getTiktokProfile, type TiktokProfile } from "./tiktok";
+
+export type { TiktokProfile };
export interface FollowerCounts {
youtube: number | null;
@@ -16,38 +18,58 @@ export interface FollowerInfo {
}
const CACHE_KEY = "erika:follower_counts";
+const CACHE_KEY_TIKTOK = "erika:tiktok_profile";
const CACHE_TTL = 3600; // 1 hour
-export async function getFollowerCounts(): Promise {
+export interface FetchResult {
+ counts: FollowerCounts;
+ tiktokProfile: TiktokProfile;
+}
+
+export async function getFollowerCounts(): Promise {
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
- const cached = await redis.get(CACHE_KEY);
- if (cached) {
- return JSON.parse(cached);
+ const [cachedCounts, cachedProfile] = await Promise.all([
+ redis.get(CACHE_KEY),
+ redis.get(CACHE_KEY_TIKTOK),
+ ]);
+ if (cachedCounts && cachedProfile) {
+ return {
+ counts: JSON.parse(cachedCounts),
+ tiktokProfile: JSON.parse(cachedProfile),
+ };
}
} catch {
// Redis unavailable, fall through to fetch
}
- const [youtube, roblox, discord, tiktok] = await Promise.all([
+ const [youtube, roblox, discord, tiktokProfileResult] = await Promise.all([
getYoutubeSubscriberCount(),
getRobloxFollowerCount(),
getDiscordMemberCount(),
- getTiktokFollowerCount(),
+ getTiktokProfile(),
]);
- const counts: FollowerCounts = { youtube, roblox, discord, tiktok };
+ const counts: FollowerCounts = {
+ youtube,
+ roblox,
+ discord,
+ tiktok: tiktokProfileResult.followerCount,
+ };
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
- await redis.set(CACHE_KEY, JSON.stringify(counts), { ex: CACHE_TTL });
+ await Promise.all([
+ redis.set(CACHE_KEY, JSON.stringify(counts), { ex: CACHE_TTL }),
+ redis.set(CACHE_KEY_TIKTOK, JSON.stringify(tiktokProfileResult), { ex: CACHE_TTL }),
+ ]);
} catch {
// Redis unavailable, skip caching
}
- return counts;
+ return { counts, tiktokProfile: tiktokProfileResult };
}
const followerLabels: Record = {
diff --git a/lib/followers/tiktok.ts b/lib/followers/tiktok.ts
index 2110121..e7e8832 100644
--- a/lib/followers/tiktok.ts
+++ b/lib/followers/tiktok.ts
@@ -1,23 +1,102 @@
-export async function getTiktokFollowerCount(): Promise {
- const apiKey = process.env.TIKTOK_API_KEY;
- const userId = process.env.TIKTOK_USER_ID;
- if (!apiKey || !userId) return null;
+/**
+ * Fetches TikTok follower count using the official TikTok API v2.
+ *
+ * Required env vars:
+ * - TIKTOK_CLIENT_KEY
+ * - TIKTOK_CLIENT_SECRET
+ * - TIKTOK_REFRESH_TOKEN (obtained via one-time OAuth flow at /api/auth/tiktok)
+ */
+
+let cachedAccessToken: string | null = null;
+let tokenExpiresAt = 0;
+
+async function getAccessToken(): Promise {
+ // Return cached token if still valid (with 5 min buffer)
+ if (cachedAccessToken && Date.now() < tokenExpiresAt - 300_000) {
+ return cachedAccessToken;
+ }
+
+ const clientKey = process.env.TIKTOK_CLIENT_KEY;
+ const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
+ const refreshToken = process.env.TIKTOK_REFRESH_TOKEN;
+
+ if (!clientKey || !clientSecret || !refreshToken) return null;
try {
- const res = await fetch(
- `https://api.scrapecreators.com/v1/tiktok/profile?user_id=${encodeURIComponent(userId)}`,
- {
- headers: { 'x-api-key': apiKey },
- next: { revalidate: 86400 },
- signal: AbortSignal.timeout(5000),
- }
- );
+ const res = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_key: clientKey,
+ client_secret: clientSecret,
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ }),
+ signal: AbortSignal.timeout(5000),
+ });
+
if (!res.ok) return null;
const data = await res.json();
- const count = data.statsV2?.followerCount;
- return count != null ? Number(count) : null;
+ if (data.access_token) {
+ cachedAccessToken = data.access_token;
+ tokenExpiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
+ return cachedAccessToken;
+ }
+
+ return null;
} catch {
return null;
}
}
+
+export interface TiktokProfile {
+ followerCount: number | null;
+ displayName: string | null;
+ username: string | null;
+ avatarUrl: string | null;
+}
+
+export async function getTiktokProfile(): Promise {
+ const empty: TiktokProfile = {
+ followerCount: null,
+ displayName: null,
+ username: null,
+ avatarUrl: null,
+ };
+
+ const accessToken = await getAccessToken();
+ if (!accessToken) return empty;
+
+ try {
+ const res = await fetch(
+ "https://open.tiktokapis.com/v2/user/info/?fields=follower_count,display_name,username,avatar_url",
+ {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ next: { revalidate: 86400 },
+ signal: AbortSignal.timeout(5000),
+ }
+ );
+
+ if (!res.ok) return empty;
+
+ const data = await res.json();
+ const user = data.data?.user;
+ if (!user) return empty;
+
+ return {
+ followerCount: user.follower_count != null ? Number(user.follower_count) : null,
+ displayName: user.display_name ?? null,
+ username: user.username ?? null,
+ avatarUrl: user.avatar_url ?? null,
+ };
+ } catch {
+ return empty;
+ }
+}
+
+/** Convenience wrapper that just returns the follower count */
+export async function getTiktokFollowerCount(): Promise {
+ const profile = await getTiktokProfile();
+ return profile.followerCount;
+}
diff --git a/next.config.ts b/next.config.ts
index 3b5024a..efd5f49 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -9,6 +9,18 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
+ {
+ protocol: 'https',
+ hostname: 'p16-sign-sg.tiktokcdn.com',
+ },
+ {
+ protocol: 'https',
+ hostname: 'p16-sign.tiktokcdn-us.com',
+ },
+ {
+ protocol: 'https',
+ hostname: '*.tiktokcdn.com',
+ },
],
},
};