Spaces:
Sleeping
Sleeping
Commit
·
c4412d0
1
Parent(s):
2b235a9
feat: add embeddedable chat component
Browse files- app/(audiences)/components/chat-bot-card.tsx +22 -4
- app/(audiences)/components/chat-bot-grid-skeleton.tsx +16 -2
- app/(audiences)/components/chat-bot-grid.tsx +34 -1
- app/(audiences)/components/search-bar.tsx +14 -0
- app/(audiences)/components/subject-faceted-filter.tsx +27 -0
- app/(audiences)/components/subject-filter-view-options.tsx +16 -0
- app/(audiences)/components/subject-filter.tsx +23 -4
- app/(audiences)/for-students/page.tsx +17 -3
- app/(audiences)/for-teachers/page.tsx +24 -3
- app/api/embed/route.ts +109 -0
- app/components/embeddable-chat-bot.tsx +374 -0
- app/components/landing-page-chat-bot.tsx +20 -1
- app/embed/chat/page.tsx +29 -0
- app/embed/layout.tsx +26 -0
- app/layout.tsx +13 -2
- app/types/chatbot.ts +0 -10
- middleware.ts +36 -0
- public/embed.js +86 -0
- public/test-embed.html +278 -0
- test-server.js +30 -0
app/(audiences)/components/chat-bot-card.tsx
CHANGED
@@ -1,25 +1,40 @@
|
|
1 |
-
|
2 |
import Image from "next/image";
|
3 |
import { MessageSquareShare } from "lucide-react";
|
|
|
|
|
|
|
4 |
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
5 |
|
|
|
6 |
interface ChatBotCardProps {
|
7 |
chatbot: ChatBot;
|
8 |
}
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
|
|
11 |
const baseIconName = chatbot.icon.split(".")[0];
|
12 |
|
13 |
return (
|
14 |
<Card className="group relative overflow-hidden bg-background-primary/50 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 backdrop-blur-sm border-2 border-border/40 hover:border-primary/20 duration-300 aspect-square">
|
15 |
<CardContent className="p-8 h-full flex flex-col">
|
16 |
-
{/* Avatar with
|
17 |
<div className="flex-shrink-0 mb-6">
|
18 |
<picture>
|
|
|
19 |
<source
|
20 |
srcSet={`/chatbots/${baseIconName}.webp`}
|
21 |
type="image/webp"
|
22 |
/>
|
|
|
23 |
<Image
|
24 |
src={`/chatbots/${baseIconName}.png`}
|
25 |
alt={chatbot.title}
|
@@ -30,16 +45,19 @@ export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
|
30 |
</picture>
|
31 |
</div>
|
32 |
|
33 |
-
{/*
|
34 |
<div className="flex-1 flex flex-col">
|
|
|
35 |
<h3 className="text-xl font-bold text-text-primary truncate bg-gradient-to-r from-[#FF6B6B] via-[#4ECDC4] to-[#45B7D1] bg-clip-text text-transparent group-hover:tracking-wide transition-all duration-300 mb-3">
|
36 |
{chatbot.title}
|
37 |
</h3>
|
38 |
|
|
|
39 |
<p className="text-base text-text-secondary line-clamp-3 mb-6 flex-1 leading-relaxed">
|
40 |
{chatbot.description}
|
41 |
</p>
|
42 |
|
|
|
43 |
<button
|
44 |
className="flex-shrink-0 inline-flex items-center justify-center rounded-full bg-primary w-12 h-12 text-white transition-all duration-300 hover:scale-110 active:scale-95 hover:bg-primary/90 ml-auto"
|
45 |
aria-label="開始對話"
|
@@ -49,7 +67,7 @@ export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
|
49 |
</div>
|
50 |
</CardContent>
|
51 |
|
52 |
-
{/* Decorative corner accent */}
|
53 |
<div className="absolute top-0 right-0 w-20 h-20 overflow-hidden">
|
54 |
<div className="absolute top-0 right-0 w-16 h-16 bg-gradient-to-bl from-primary/10 via-primary/5 to-transparent transform rotate-45 translate-x-10 -translate-y-10 group-hover:translate-x-8 group-hover:-translate-y-8 transition-transform duration-300" />
|
55 |
</div>
|
|
|
1 |
+
// External dependencies
|
2 |
import Image from "next/image";
|
3 |
import { MessageSquareShare } from "lucide-react";
|
4 |
+
|
5 |
+
// Internal UI components
|
6 |
+
import { Card, CardContent } from "@/components/ui/card";
|
7 |
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
8 |
|
9 |
+
// Component props interface
|
10 |
interface ChatBotCardProps {
|
11 |
chatbot: ChatBot;
|
12 |
}
|
13 |
|
14 |
+
/**
|
15 |
+
* ChatBotCard - A responsive card component that displays chatbot information
|
16 |
+
* Features:
|
17 |
+
* - Optimized image loading with WebP support and PNG fallback
|
18 |
+
* - Hover animations and transitions
|
19 |
+
* - Gradient text effects
|
20 |
+
* - Decorative corner accent
|
21 |
+
*/
|
22 |
export default function ChatBotCard({ chatbot }: ChatBotCardProps) {
|
23 |
+
// Extract base name from icon path for use in both WebP and PNG sources
|
24 |
const baseIconName = chatbot.icon.split(".")[0];
|
25 |
|
26 |
return (
|
27 |
<Card className="group relative overflow-hidden bg-background-primary/50 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 backdrop-blur-sm border-2 border-border/40 hover:border-primary/20 duration-300 aspect-square">
|
28 |
<CardContent className="p-8 h-full flex flex-col">
|
29 |
+
{/* Avatar section with modern image format optimization */}
|
30 |
<div className="flex-shrink-0 mb-6">
|
31 |
<picture>
|
32 |
+
{/* WebP format for modern browsers */}
|
33 |
<source
|
34 |
srcSet={`/chatbots/${baseIconName}.webp`}
|
35 |
type="image/webp"
|
36 |
/>
|
37 |
+
{/* PNG fallback for older browsers */}
|
38 |
<Image
|
39 |
src={`/chatbots/${baseIconName}.png`}
|
40 |
alt={chatbot.title}
|
|
|
45 |
</picture>
|
46 |
</div>
|
47 |
|
48 |
+
{/* Main content section with flex layout for dynamic spacing */}
|
49 |
<div className="flex-1 flex flex-col">
|
50 |
+
{/* Title with gradient text effect and hover animation */}
|
51 |
<h3 className="text-xl font-bold text-text-primary truncate bg-gradient-to-r from-[#FF6B6B] via-[#4ECDC4] to-[#45B7D1] bg-clip-text text-transparent group-hover:tracking-wide transition-all duration-300 mb-3">
|
52 |
{chatbot.title}
|
53 |
</h3>
|
54 |
|
55 |
+
{/* Description with line clamping for consistent card heights */}
|
56 |
<p className="text-base text-text-secondary line-clamp-3 mb-6 flex-1 leading-relaxed">
|
57 |
{chatbot.description}
|
58 |
</p>
|
59 |
|
60 |
+
{/* Interactive chat button with hover and active states */}
|
61 |
<button
|
62 |
className="flex-shrink-0 inline-flex items-center justify-center rounded-full bg-primary w-12 h-12 text-white transition-all duration-300 hover:scale-110 active:scale-95 hover:bg-primary/90 ml-auto"
|
63 |
aria-label="開始對話"
|
|
|
67 |
</div>
|
68 |
</CardContent>
|
69 |
|
70 |
+
{/* Decorative gradient corner accent with hover animation */}
|
71 |
<div className="absolute top-0 right-0 w-20 h-20 overflow-hidden">
|
72 |
<div className="absolute top-0 right-0 w-16 h-16 bg-gradient-to-bl from-primary/10 via-primary/5 to-transparent transform rotate-45 translate-x-10 -translate-y-10 group-hover:translate-x-8 group-hover:-translate-y-8 transition-transform duration-300" />
|
73 |
</div>
|
app/(audiences)/components/chat-bot-grid-skeleton.tsx
CHANGED
@@ -1,22 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
1 |
export default function ChatBotGridSkeleton() {
|
2 |
return (
|
|
|
3 |
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
4 |
{[...Array(6)].map((_, index) => (
|
5 |
<div
|
6 |
key={index}
|
7 |
className="animate-pulse rounded-lg border border-gray-200 bg-white p-6 shadow-sm"
|
8 |
>
|
|
|
9 |
<div className="mb-4 flex items-center justify-between">
|
10 |
-
<div className="h-8 w-8 rounded-full bg-gray-200" />
|
11 |
-
|
|
|
|
|
12 |
</div>
|
13 |
|
|
|
14 |
<div className="mb-2 h-6 w-3/4 rounded bg-gray-200" />
|
|
|
|
|
15 |
<div className="mb-4 space-y-2">
|
16 |
<div className="h-4 w-full rounded bg-gray-200" />
|
17 |
<div className="h-4 w-5/6 rounded bg-gray-200" />
|
18 |
</div>
|
19 |
|
|
|
20 |
<div className="h-10 w-full rounded-lg bg-gray-200" />
|
21 |
</div>
|
22 |
))}
|
|
|
1 |
+
/**
|
2 |
+
* ChatBotGridSkeleton - A loading placeholder component that displays a grid of skeleton cards
|
3 |
+
* Used to show a loading state while chat bot data is being fetched
|
4 |
+
* Renders 6 identical skeleton cards in a responsive grid layout
|
5 |
+
*/
|
6 |
export default function ChatBotGridSkeleton() {
|
7 |
return (
|
8 |
+
// Responsive grid container: 1 column (mobile), 2 columns (sm), 3 columns (lg)
|
9 |
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
10 |
+
{/* Generate 6 skeleton cards using Array constructor and map */}
|
11 |
{[...Array(6)].map((_, index) => (
|
12 |
<div
|
13 |
key={index}
|
14 |
className="animate-pulse rounded-lg border border-gray-200 bg-white p-6 shadow-sm"
|
15 |
>
|
16 |
+
{/* Header section with avatar and status indicator */}
|
17 |
<div className="mb-4 flex items-center justify-between">
|
18 |
+
<div className="h-8 w-8 rounded-full bg-gray-200" />{" "}
|
19 |
+
{/* Avatar placeholder */}
|
20 |
+
<div className="h-6 w-24 rounded-full bg-gray-200" />{" "}
|
21 |
+
{/* Status indicator placeholder */}
|
22 |
</div>
|
23 |
|
24 |
+
{/* Title placeholder */}
|
25 |
<div className="mb-2 h-6 w-3/4 rounded bg-gray-200" />
|
26 |
+
|
27 |
+
{/* Description text placeholders - two lines */}
|
28 |
<div className="mb-4 space-y-2">
|
29 |
<div className="h-4 w-full rounded bg-gray-200" />
|
30 |
<div className="h-4 w-5/6 rounded bg-gray-200" />
|
31 |
</div>
|
32 |
|
33 |
+
{/* Action button placeholder */}
|
34 |
<div className="h-10 w-full rounded-lg bg-gray-200" />
|
35 |
</div>
|
36 |
))}
|
app/(audiences)/components/chat-bot-grid.tsx
CHANGED
@@ -1,9 +1,16 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import { useState, useEffect } from "react";
|
|
|
4 |
import ChatBotCard from "./chat-bot-card";
|
|
|
5 |
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
6 |
|
|
|
|
|
|
|
|
|
7 |
interface SectionProps {
|
8 |
title: string;
|
9 |
subtitle: string;
|
@@ -11,6 +18,10 @@ interface SectionProps {
|
|
11 |
columns?: number;
|
12 |
}
|
13 |
|
|
|
|
|
|
|
|
|
14 |
function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
15 |
if (bots.length === 0) return null;
|
16 |
|
@@ -20,6 +31,7 @@ function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
|
20 |
<h2 className="text-2xl font-bold text-text-primary mb-2">{title}</h2>
|
21 |
<p className="text-text-secondary">{subtitle}</p>
|
22 |
</div>
|
|
|
23 |
<div className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-${columns}`}>
|
24 |
{bots.map((chatbot) => (
|
25 |
<ChatBotCard key={chatbot.id} chatbot={chatbot} />
|
@@ -29,6 +41,10 @@ function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
|
29 |
);
|
30 |
}
|
31 |
|
|
|
|
|
|
|
|
|
32 |
interface ChatBotGridProps {
|
33 |
selectedCategories: string[];
|
34 |
selectedSubjects: string[];
|
@@ -38,6 +54,11 @@ interface ChatBotGridProps {
|
|
38 |
getAllBots: () => ChatBot[];
|
39 |
}
|
40 |
|
|
|
|
|
|
|
|
|
|
|
41 |
export default function ChatBotGrid({
|
42 |
selectedCategories,
|
43 |
selectedSubjects,
|
@@ -46,7 +67,7 @@ export default function ChatBotGrid({
|
|
46 |
getTrendingBots,
|
47 |
getAllBots,
|
48 |
}: ChatBotGridProps) {
|
49 |
-
// Initialize state with
|
50 |
const [filteredPopularBots, setFilteredPopularBots] = useState<ChatBot[]>(
|
51 |
() => getPopularBots(),
|
52 |
);
|
@@ -57,10 +78,15 @@ export default function ChatBotGrid({
|
|
57 |
getAllBots(),
|
58 |
);
|
59 |
|
|
|
|
|
|
|
|
|
60 |
useEffect(() => {
|
61 |
const filterBots = (bots: ChatBot[]) => {
|
62 |
let filtered = bots;
|
63 |
|
|
|
64 |
if (searchQuery) {
|
65 |
const query = searchQuery.toLowerCase();
|
66 |
filtered = filtered.filter(
|
@@ -72,12 +98,14 @@ export default function ChatBotGrid({
|
|
72 |
);
|
73 |
}
|
74 |
|
|
|
75 |
if (selectedCategories.length > 0) {
|
76 |
filtered = filtered.filter(
|
77 |
(bot) => bot.category && selectedCategories.includes(bot.category),
|
78 |
);
|
79 |
}
|
80 |
|
|
|
81 |
if (selectedSubjects.length > 0) {
|
82 |
filtered = filtered.filter((bot) =>
|
83 |
selectedSubjects.includes(bot.subject),
|
@@ -87,6 +115,7 @@ export default function ChatBotGrid({
|
|
87 |
return filtered;
|
88 |
};
|
89 |
|
|
|
90 |
setFilteredPopularBots(filterBots(getPopularBots()));
|
91 |
setFilteredTrendingBots(filterBots(getTrendingBots()));
|
92 |
setFilteredAllBots(filterBots(getAllBots()));
|
@@ -99,9 +128,11 @@ export default function ChatBotGrid({
|
|
99 |
getAllBots,
|
100 |
]);
|
101 |
|
|
|
102 |
const hasFiltersOrSearch =
|
103 |
selectedCategories.length > 0 || selectedSubjects.length > 0 || searchQuery;
|
104 |
|
|
|
105 |
if (hasFiltersOrSearch) {
|
106 |
return (
|
107 |
<Section
|
@@ -113,12 +144,14 @@ export default function ChatBotGrid({
|
|
113 |
);
|
114 |
}
|
115 |
|
|
|
116 |
const tutorBots = filteredAllBots.filter((bot) => bot.category === "教育");
|
117 |
const teachingBots = filteredAllBots.filter((bot) => bot.subject === "教學");
|
118 |
const assessmentBots = filteredAllBots.filter(
|
119 |
(bot) => bot.subject === "評量",
|
120 |
);
|
121 |
|
|
|
122 |
return (
|
123 |
<div className="space-y-8">
|
124 |
<Section
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// Core React imports
|
4 |
import { useState, useEffect } from "react";
|
5 |
+
// Local component imports
|
6 |
import ChatBotCard from "./chat-bot-card";
|
7 |
+
// Type definitions and data
|
8 |
import { ChatBot } from "@/app/(audiences)/for-students/data/chatbots";
|
9 |
|
10 |
+
/**
|
11 |
+
* Props interface for the Section component that displays a group of chatbots
|
12 |
+
* in a responsive grid layout with a title and subtitle
|
13 |
+
*/
|
14 |
interface SectionProps {
|
15 |
title: string;
|
16 |
subtitle: string;
|
|
|
18 |
columns?: number;
|
19 |
}
|
20 |
|
21 |
+
/**
|
22 |
+
* Renders a section of chatbots with a header and responsive grid layout
|
23 |
+
* Returns null if no bots are provided
|
24 |
+
*/
|
25 |
function Section({ title, subtitle, bots, columns = 4 }: SectionProps) {
|
26 |
if (bots.length === 0) return null;
|
27 |
|
|
|
31 |
<h2 className="text-2xl font-bold text-text-primary mb-2">{title}</h2>
|
32 |
<p className="text-text-secondary">{subtitle}</p>
|
33 |
</div>
|
34 |
+
{/* Responsive grid: 1 column on mobile, 2 on sm screens, configurable columns on lg */}
|
35 |
<div className={`grid gap-4 sm:grid-cols-2 lg:grid-cols-${columns}`}>
|
36 |
{bots.map((chatbot) => (
|
37 |
<ChatBotCard key={chatbot.id} chatbot={chatbot} />
|
|
|
41 |
);
|
42 |
}
|
43 |
|
44 |
+
/**
|
45 |
+
* Props interface for the main ChatBotGrid component
|
46 |
+
* Includes filter criteria and functions to fetch different bot categories
|
47 |
+
*/
|
48 |
interface ChatBotGridProps {
|
49 |
selectedCategories: string[];
|
50 |
selectedSubjects: string[];
|
|
|
54 |
getAllBots: () => ChatBot[];
|
55 |
}
|
56 |
|
57 |
+
/**
|
58 |
+
* Main component that displays a filterable grid of chatbots
|
59 |
+
* Supports filtering by category, subject, and search query
|
60 |
+
* Shows different sections based on filter state
|
61 |
+
*/
|
62 |
export default function ChatBotGrid({
|
63 |
selectedCategories,
|
64 |
selectedSubjects,
|
|
|
67 |
getTrendingBots,
|
68 |
getAllBots,
|
69 |
}: ChatBotGridProps) {
|
70 |
+
// Initialize state with lazy evaluation using callback functions
|
71 |
const [filteredPopularBots, setFilteredPopularBots] = useState<ChatBot[]>(
|
72 |
() => getPopularBots(),
|
73 |
);
|
|
|
78 |
getAllBots(),
|
79 |
);
|
80 |
|
81 |
+
/**
|
82 |
+
* Effect to filter bots whenever filter criteria or bot data changes
|
83 |
+
* Applies search query, category, and subject filters
|
84 |
+
*/
|
85 |
useEffect(() => {
|
86 |
const filterBots = (bots: ChatBot[]) => {
|
87 |
let filtered = bots;
|
88 |
|
89 |
+
// Apply text search filter across multiple fields
|
90 |
if (searchQuery) {
|
91 |
const query = searchQuery.toLowerCase();
|
92 |
filtered = filtered.filter(
|
|
|
98 |
);
|
99 |
}
|
100 |
|
101 |
+
// Apply category filter if categories are selected
|
102 |
if (selectedCategories.length > 0) {
|
103 |
filtered = filtered.filter(
|
104 |
(bot) => bot.category && selectedCategories.includes(bot.category),
|
105 |
);
|
106 |
}
|
107 |
|
108 |
+
// Apply subject filter if subjects are selected
|
109 |
if (selectedSubjects.length > 0) {
|
110 |
filtered = filtered.filter((bot) =>
|
111 |
selectedSubjects.includes(bot.subject),
|
|
|
115 |
return filtered;
|
116 |
};
|
117 |
|
118 |
+
// Update all filtered bot lists
|
119 |
setFilteredPopularBots(filterBots(getPopularBots()));
|
120 |
setFilteredTrendingBots(filterBots(getTrendingBots()));
|
121 |
setFilteredAllBots(filterBots(getAllBots()));
|
|
|
128 |
getAllBots,
|
129 |
]);
|
130 |
|
131 |
+
// Check if any filters are active
|
132 |
const hasFiltersOrSearch =
|
133 |
selectedCategories.length > 0 || selectedSubjects.length > 0 || searchQuery;
|
134 |
|
135 |
+
// When filters are active, show only search results
|
136 |
if (hasFiltersOrSearch) {
|
137 |
return (
|
138 |
<Section
|
|
|
144 |
);
|
145 |
}
|
146 |
|
147 |
+
// Filter bots by category and subject for different sections
|
148 |
const tutorBots = filteredAllBots.filter((bot) => bot.category === "教育");
|
149 |
const teachingBots = filteredAllBots.filter((bot) => bot.subject === "教學");
|
150 |
const assessmentBots = filteredAllBots.filter(
|
151 |
(bot) => bot.subject === "評量",
|
152 |
);
|
153 |
|
154 |
+
// Default view showing multiple sections of categorized bots
|
155 |
return (
|
156 |
<div className="space-y-8">
|
157 |
<Section
|
app/(audiences)/components/search-bar.tsx
CHANGED
@@ -1,12 +1,25 @@
|
|
1 |
"use client";
|
2 |
|
|
|
|
|
|
|
|
|
3 |
interface SearchBarProps {
|
4 |
onSearch: (query: string) => void;
|
5 |
}
|
6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
export default function SearchBar({ onSearch }: SearchBarProps) {
|
8 |
return (
|
9 |
<div className="relative max-w-full mx-auto">
|
|
|
10 |
<input
|
11 |
type="text"
|
12 |
placeholder="搜尋 PlayGO AI..."
|
@@ -15,6 +28,7 @@ export default function SearchBar({ onSearch }: SearchBarProps) {
|
|
15 |
backdrop-blur-sm shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50
|
16 |
text-lg transition-all duration-200 placeholder:text-text-secondary/50"
|
17 |
/>
|
|
|
18 |
<svg
|
19 |
className="absolute left-4 top-1/2 h-6 w-6 -translate-y-1/2 text-text-secondary/50"
|
20 |
fill="none"
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
/**
|
4 |
+
* Props interface for the SearchBar component
|
5 |
+
* @property onSearch - Callback function triggered when search input changes
|
6 |
+
*/
|
7 |
interface SearchBarProps {
|
8 |
onSearch: (query: string) => void;
|
9 |
}
|
10 |
|
11 |
+
/**
|
12 |
+
* SearchBar component that provides a styled search input with an icon
|
13 |
+
* Features:
|
14 |
+
* - Real-time search with onChange event
|
15 |
+
* - Glassmorphic design with backdrop blur
|
16 |
+
* - Responsive width with max-width constraint
|
17 |
+
* - Animated focus state with primary color ring
|
18 |
+
*/
|
19 |
export default function SearchBar({ onSearch }: SearchBarProps) {
|
20 |
return (
|
21 |
<div className="relative max-w-full mx-auto">
|
22 |
+
{/* Search input field with glassmorphic styling */}
|
23 |
<input
|
24 |
type="text"
|
25 |
placeholder="搜尋 PlayGO AI..."
|
|
|
28 |
backdrop-blur-sm shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50
|
29 |
text-lg transition-all duration-200 placeholder:text-text-secondary/50"
|
30 |
/>
|
31 |
+
{/* Search icon positioned absolutely within the input */}
|
32 |
<svg
|
33 |
className="absolute left-4 top-1/2 h-6 w-6 -translate-y-1/2 text-text-secondary/50"
|
34 |
fill="none"
|
app/(audiences)/components/subject-faceted-filter.tsx
CHANGED
@@ -1,7 +1,10 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import * as React from "react";
|
4 |
import { Check, PlusCircle } from "lucide-react";
|
|
|
|
|
5 |
import { cn } from "@/lib/utils";
|
6 |
import { Badge } from "@/components/ui/badge";
|
7 |
import { Button } from "@/components/ui/button";
|
@@ -21,6 +24,13 @@ import {
|
|
21 |
} from "@/components/ui/popover";
|
22 |
import { Separator } from "@/components/ui/separator";
|
23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
interface DataTableFacetedFilterProps {
|
25 |
title: string;
|
26 |
options: {
|
@@ -31,6 +41,14 @@ interface DataTableFacetedFilterProps {
|
|
31 |
onSelectionChange: (values: string[]) => void;
|
32 |
}
|
33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
export function DataTableFacetedFilter({
|
35 |
title,
|
36 |
options,
|
@@ -47,15 +65,18 @@ export function DataTableFacetedFilter({
|
|
47 |
>
|
48 |
<PlusCircle className="mr-3 h-6 w-6" />
|
49 |
{title}
|
|
|
50 |
{selectedValues?.length > 0 && (
|
51 |
<>
|
52 |
<Separator orientation="vertical" className="mx-4 h-8" />
|
|
|
53 |
<Badge
|
54 |
variant="secondary"
|
55 |
className="rounded-md px-3 py-1.5 text-base font-normal lg:hidden"
|
56 |
>
|
57 |
{selectedValues.length}
|
58 |
</Badge>
|
|
|
59 |
<div className="hidden space-x-2 lg:flex">
|
60 |
{selectedValues.length > 2 ? (
|
61 |
<Badge
|
@@ -82,6 +103,7 @@ export function DataTableFacetedFilter({
|
|
82 |
)}
|
83 |
</Button>
|
84 |
</PopoverTrigger>
|
|
|
85 |
<PopoverContent className="w-[320px] p-0" align="start">
|
86 |
<Command>
|
87 |
<CommandInput
|
@@ -91,6 +113,7 @@ export function DataTableFacetedFilter({
|
|
91 |
<CommandList>
|
92 |
<CommandEmpty>找不到結果</CommandEmpty>
|
93 |
<CommandGroup>
|
|
|
94 |
{options.map((option) => {
|
95 |
const isSelected = selectedValues.includes(option.value);
|
96 |
return (
|
@@ -98,17 +121,20 @@ export function DataTableFacetedFilter({
|
|
98 |
key={option.value}
|
99 |
onSelect={() => {
|
100 |
if (isSelected) {
|
|
|
101 |
onSelectionChange(
|
102 |
selectedValues.filter(
|
103 |
(value) => value !== option.value,
|
104 |
),
|
105 |
);
|
106 |
} else {
|
|
|
107 |
onSelectionChange([...selectedValues, option.value]);
|
108 |
}
|
109 |
}}
|
110 |
className="p-4 text-lg"
|
111 |
>
|
|
|
112 |
<div
|
113 |
className={cn(
|
114 |
"mr-4 flex h-6 w-6 items-center justify-center rounded-sm border-2 border-primary",
|
@@ -124,6 +150,7 @@ export function DataTableFacetedFilter({
|
|
124 |
);
|
125 |
})}
|
126 |
</CommandGroup>
|
|
|
127 |
{selectedValues.length > 0 && (
|
128 |
<>
|
129 |
<CommandSeparator />
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// External library imports
|
4 |
import * as React from "react";
|
5 |
import { Check, PlusCircle } from "lucide-react";
|
6 |
+
|
7 |
+
// Internal utility and component imports
|
8 |
import { cn } from "@/lib/utils";
|
9 |
import { Badge } from "@/components/ui/badge";
|
10 |
import { Button } from "@/components/ui/button";
|
|
|
24 |
} from "@/components/ui/popover";
|
25 |
import { Separator } from "@/components/ui/separator";
|
26 |
|
27 |
+
/**
|
28 |
+
* Interface for the faceted filter component props
|
29 |
+
* @param title - Display name for the filter
|
30 |
+
* @param options - Array of filter options with labels and values
|
31 |
+
* @param selectedValues - Currently selected filter values
|
32 |
+
* @param onSelectionChange - Callback function when selection changes
|
33 |
+
*/
|
34 |
interface DataTableFacetedFilterProps {
|
35 |
title: string;
|
36 |
options: {
|
|
|
41 |
onSelectionChange: (values: string[]) => void;
|
42 |
}
|
43 |
|
44 |
+
/**
|
45 |
+
* A faceted filter component for data tables that supports multiple selection
|
46 |
+
* Features:
|
47 |
+
* - Displays selected items as badges
|
48 |
+
* - Supports search functionality
|
49 |
+
* - Responsive design with different displays for mobile/desktop
|
50 |
+
* - Clear all selections option
|
51 |
+
*/
|
52 |
export function DataTableFacetedFilter({
|
53 |
title,
|
54 |
options,
|
|
|
65 |
>
|
66 |
<PlusCircle className="mr-3 h-6 w-6" />
|
67 |
{title}
|
68 |
+
{/* Display selected values section */}
|
69 |
{selectedValues?.length > 0 && (
|
70 |
<>
|
71 |
<Separator orientation="vertical" className="mx-4 h-8" />
|
72 |
+
{/* Mobile view: Show count of selected items */}
|
73 |
<Badge
|
74 |
variant="secondary"
|
75 |
className="rounded-md px-3 py-1.5 text-base font-normal lg:hidden"
|
76 |
>
|
77 |
{selectedValues.length}
|
78 |
</Badge>
|
79 |
+
{/* Desktop view: Show either all selected items (if ≤2) or count */}
|
80 |
<div className="hidden space-x-2 lg:flex">
|
81 |
{selectedValues.length > 2 ? (
|
82 |
<Badge
|
|
|
103 |
)}
|
104 |
</Button>
|
105 |
</PopoverTrigger>
|
106 |
+
{/* Dropdown content with search and selection options */}
|
107 |
<PopoverContent className="w-[320px] p-0" align="start">
|
108 |
<Command>
|
109 |
<CommandInput
|
|
|
113 |
<CommandList>
|
114 |
<CommandEmpty>找不到結果</CommandEmpty>
|
115 |
<CommandGroup>
|
116 |
+
{/* Map through options and handle selection/deselection */}
|
117 |
{options.map((option) => {
|
118 |
const isSelected = selectedValues.includes(option.value);
|
119 |
return (
|
|
|
121 |
key={option.value}
|
122 |
onSelect={() => {
|
123 |
if (isSelected) {
|
124 |
+
// Remove value if already selected
|
125 |
onSelectionChange(
|
126 |
selectedValues.filter(
|
127 |
(value) => value !== option.value,
|
128 |
),
|
129 |
);
|
130 |
} else {
|
131 |
+
// Add value to selection
|
132 |
onSelectionChange([...selectedValues, option.value]);
|
133 |
}
|
134 |
}}
|
135 |
className="p-4 text-lg"
|
136 |
>
|
137 |
+
{/* Custom checkbox implementation */}
|
138 |
<div
|
139 |
className={cn(
|
140 |
"mr-4 flex h-6 w-6 items-center justify-center rounded-sm border-2 border-primary",
|
|
|
150 |
);
|
151 |
})}
|
152 |
</CommandGroup>
|
153 |
+
{/* Show clear filter option when items are selected */}
|
154 |
{selectedValues.length > 0 && (
|
155 |
<>
|
156 |
<CommandSeparator />
|
app/(audiences)/components/subject-filter-view-options.tsx
CHANGED
@@ -1,9 +1,11 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
4 |
import { Table } from "@tanstack/react-table";
|
5 |
import { Settings2 } from "lucide-react";
|
6 |
|
|
|
7 |
import { Button } from "@/components/ui/button";
|
8 |
import {
|
9 |
DropdownMenu,
|
@@ -13,15 +15,27 @@ import {
|
|
13 |
DropdownMenuSeparator,
|
14 |
} from "@/components/ui/dropdown-menu";
|
15 |
|
|
|
|
|
|
|
|
|
16 |
interface DataTableViewOptionsProps<TData> {
|
17 |
table: Table<TData>;
|
18 |
}
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
export function DataTableViewOptions<TData>({
|
21 |
table,
|
22 |
}: DataTableViewOptionsProps<TData>) {
|
23 |
return (
|
24 |
<DropdownMenu>
|
|
|
25 |
<DropdownMenuTrigger asChild>
|
26 |
<Button
|
27 |
variant="outline"
|
@@ -35,10 +49,12 @@ export function DataTableViewOptions<TData>({
|
|
35 |
<DropdownMenuContent align="end" className="w-[150px]">
|
36 |
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
37 |
<DropdownMenuSeparator />
|
|
|
38 |
{table
|
39 |
.getAllColumns()
|
40 |
.filter(
|
41 |
(column) =>
|
|
|
42 |
typeof column.accessorFn !== "undefined" && column.getCanHide(),
|
43 |
)
|
44 |
.map((column) => {
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// External dependencies
|
4 |
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
5 |
import { Table } from "@tanstack/react-table";
|
6 |
import { Settings2 } from "lucide-react";
|
7 |
|
8 |
+
// Internal UI components
|
9 |
import { Button } from "@/components/ui/button";
|
10 |
import {
|
11 |
DropdownMenu,
|
|
|
15 |
DropdownMenuSeparator,
|
16 |
} from "@/components/ui/dropdown-menu";
|
17 |
|
18 |
+
/**
|
19 |
+
* Props interface for the DataTableViewOptions component
|
20 |
+
* @template TData - Generic type parameter for the table data
|
21 |
+
*/
|
22 |
interface DataTableViewOptionsProps<TData> {
|
23 |
table: Table<TData>;
|
24 |
}
|
25 |
|
26 |
+
/**
|
27 |
+
* DataTableViewOptions - A component that renders a dropdown menu for toggling column visibility
|
28 |
+
* in a data table. It provides a UI for users to show/hide specific columns.
|
29 |
+
*
|
30 |
+
* @template TData - Generic type parameter representing the table data structure
|
31 |
+
* @param {DataTableViewOptionsProps<TData>} props - Component props containing the table instance
|
32 |
+
*/
|
33 |
export function DataTableViewOptions<TData>({
|
34 |
table,
|
35 |
}: DataTableViewOptionsProps<TData>) {
|
36 |
return (
|
37 |
<DropdownMenu>
|
38 |
+
{/* Trigger button for the dropdown menu - only visible on large screens */}
|
39 |
<DropdownMenuTrigger asChild>
|
40 |
<Button
|
41 |
variant="outline"
|
|
|
49 |
<DropdownMenuContent align="end" className="w-[150px]">
|
50 |
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
51 |
<DropdownMenuSeparator />
|
52 |
+
{/* Map through all available columns and create toggle options */}
|
53 |
{table
|
54 |
.getAllColumns()
|
55 |
.filter(
|
56 |
(column) =>
|
57 |
+
// Only include columns that have an accessor function and can be hidden
|
58 |
typeof column.accessorFn !== "undefined" && column.getCanHide(),
|
59 |
)
|
60 |
.map((column) => {
|
app/(audiences)/components/subject-filter.tsx
CHANGED
@@ -1,27 +1,43 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import * as React from "react";
|
|
|
|
|
|
|
4 |
import { categories, subjects } from "@/app/(audiences)/for-students/data/data";
|
|
|
5 |
import { DataTableFacetedFilter } from "@/app/(audiences)/components/subject-faceted-filter";
|
6 |
import { Button } from "@/components/ui/button";
|
7 |
-
import { X } from "lucide-react";
|
8 |
|
|
|
9 |
interface SubjectFilterProps {
|
|
|
10 |
selectedCategories: string[];
|
|
|
11 |
setSelectedCategories: (categories: string[]) => void;
|
|
|
12 |
selectedSubjects: string[];
|
|
|
13 |
setSelectedSubjects: (subjects: string[]) => void;
|
14 |
}
|
15 |
|
|
|
|
|
|
|
|
|
|
|
16 |
export function SubjectFilter({
|
17 |
selectedCategories,
|
18 |
setSelectedCategories,
|
19 |
selectedSubjects,
|
20 |
setSelectedSubjects,
|
21 |
}: SubjectFilterProps) {
|
|
|
22 |
const isFiltered =
|
23 |
selectedCategories.length > 0 || selectedSubjects.length > 0;
|
24 |
|
|
|
25 |
const handleReset = () => {
|
26 |
setSelectedCategories([]);
|
27 |
setSelectedSubjects([]);
|
@@ -30,18 +46,21 @@ export function SubjectFilter({
|
|
30 |
return (
|
31 |
<div className="flex items-center justify-between">
|
32 |
<div className="flex flex-1 items-center space-x-6">
|
|
|
33 |
<DataTableFacetedFilter
|
34 |
-
title="科目"
|
35 |
options={subjects}
|
36 |
selectedValues={selectedSubjects}
|
37 |
onSelectionChange={setSelectedSubjects}
|
38 |
/>
|
|
|
39 |
<DataTableFacetedFilter
|
40 |
-
title="類別"
|
41 |
options={categories}
|
42 |
selectedValues={selectedCategories}
|
43 |
onSelectionChange={setSelectedCategories}
|
44 |
/>
|
|
|
45 |
{isFiltered && (
|
46 |
<Button
|
47 |
variant="ghost"
|
@@ -49,7 +68,7 @@ export function SubjectFilter({
|
|
49 |
onClick={handleReset}
|
50 |
className="h-16 px-6 text-lg"
|
51 |
>
|
52 |
-
重設
|
53 |
<X className="ml-3 h-6 w-6" />
|
54 |
</Button>
|
55 |
)}
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// External dependencies
|
4 |
import * as React from "react";
|
5 |
+
import { X } from "lucide-react";
|
6 |
+
|
7 |
+
// Internal dependencies - data
|
8 |
import { categories, subjects } from "@/app/(audiences)/for-students/data/data";
|
9 |
+
// Internal dependencies - components
|
10 |
import { DataTableFacetedFilter } from "@/app/(audiences)/components/subject-faceted-filter";
|
11 |
import { Button } from "@/components/ui/button";
|
|
|
12 |
|
13 |
+
// Interface defining the required props for the SubjectFilter component
|
14 |
interface SubjectFilterProps {
|
15 |
+
// Array of currently selected category IDs/names
|
16 |
selectedCategories: string[];
|
17 |
+
// Callback to update selected categories
|
18 |
setSelectedCategories: (categories: string[]) => void;
|
19 |
+
// Array of currently selected subject IDs/names
|
20 |
selectedSubjects: string[];
|
21 |
+
// Callback to update selected subjects
|
22 |
setSelectedSubjects: (subjects: string[]) => void;
|
23 |
}
|
24 |
|
25 |
+
/**
|
26 |
+
* SubjectFilter Component
|
27 |
+
* Renders a filter interface for subjects and categories with reset functionality
|
28 |
+
* Used for filtering educational content based on subjects and categories
|
29 |
+
*/
|
30 |
export function SubjectFilter({
|
31 |
selectedCategories,
|
32 |
setSelectedCategories,
|
33 |
selectedSubjects,
|
34 |
setSelectedSubjects,
|
35 |
}: SubjectFilterProps) {
|
36 |
+
// Track if any filters are currently applied
|
37 |
const isFiltered =
|
38 |
selectedCategories.length > 0 || selectedSubjects.length > 0;
|
39 |
|
40 |
+
// Handler to reset all filters to their initial state
|
41 |
const handleReset = () => {
|
42 |
setSelectedCategories([]);
|
43 |
setSelectedSubjects([]);
|
|
|
46 |
return (
|
47 |
<div className="flex items-center justify-between">
|
48 |
<div className="flex flex-1 items-center space-x-6">
|
49 |
+
{/* Subject filter dropdown */}
|
50 |
<DataTableFacetedFilter
|
51 |
+
title="科目" // "Subject" in Chinese
|
52 |
options={subjects}
|
53 |
selectedValues={selectedSubjects}
|
54 |
onSelectionChange={setSelectedSubjects}
|
55 |
/>
|
56 |
+
{/* Category filter dropdown */}
|
57 |
<DataTableFacetedFilter
|
58 |
+
title="類別" // "Category" in Chinese
|
59 |
options={categories}
|
60 |
selectedValues={selectedCategories}
|
61 |
onSelectionChange={setSelectedCategories}
|
62 |
/>
|
63 |
+
{/* Reset button - only shown when filters are applied */}
|
64 |
{isFiltered && (
|
65 |
<Button
|
66 |
variant="ghost"
|
|
|
68 |
onClick={handleReset}
|
69 |
className="h-16 px-6 text-lg"
|
70 |
>
|
71 |
+
重設 {/* "Reset" in Chinese */}
|
72 |
<X className="ml-3 h-6 w-6" />
|
73 |
</Button>
|
74 |
)}
|
app/(audiences)/for-students/page.tsx
CHANGED
@@ -1,41 +1,55 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import * as React from "react";
|
4 |
import { Suspense } from "react";
|
|
|
|
|
5 |
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
6 |
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
7 |
import SearchBar from "@/app/(audiences)/components/search-bar";
|
8 |
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
9 |
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
10 |
|
|
|
|
|
|
|
|
|
|
|
11 |
export default function ForStudentsPage() {
|
|
|
12 |
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
13 |
[],
|
14 |
);
|
15 |
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
16 |
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
17 |
|
|
|
18 |
const handleSearch = (query: string) => {
|
19 |
setSearchQuery(query);
|
20 |
};
|
21 |
|
22 |
return (
|
23 |
<main className="min-h-screen bg-background-primary">
|
24 |
-
{/* Hero Section */}
|
25 |
<section className="mb-12 pt-16 text-center">
|
26 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
27 |
PlayGO for Students
|
28 |
</h1>
|
|
|
29 |
<p className="mx-auto max-w-2xl text-lg text-text-secondary">
|
30 |
探索適合學習的 AI 聊天機器人,提升學習效率!
|
31 |
</p>
|
32 |
</section>
|
33 |
|
34 |
-
{/*
|
35 |
<div className="container mx-auto px-4">
|
|
|
36 |
<div className="mb-8">
|
37 |
<SearchBar onSearch={handleSearch} />
|
38 |
</div>
|
|
|
|
|
39 |
<div className="mb-12">
|
40 |
<SubjectFilter
|
41 |
selectedCategories={selectedCategories}
|
@@ -45,7 +59,7 @@ export default function ForStudentsPage() {
|
|
45 |
/>
|
46 |
</div>
|
47 |
|
48 |
-
{/*
|
49 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
50 |
<ChatBotGrid
|
51 |
selectedCategories={selectedCategories}
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// External dependencies
|
4 |
import * as React from "react";
|
5 |
import { Suspense } from "react";
|
6 |
+
|
7 |
+
// Internal component imports
|
8 |
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
9 |
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
10 |
import SearchBar from "@/app/(audiences)/components/search-bar";
|
11 |
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
12 |
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
13 |
|
14 |
+
/**
|
15 |
+
* ForStudentsPage Component
|
16 |
+
* Main page component for the student-focused section of the application.
|
17 |
+
* Provides a searchable and filterable interface for students to discover AI chatbots.
|
18 |
+
*/
|
19 |
export default function ForStudentsPage() {
|
20 |
+
// State management for filters and search
|
21 |
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
22 |
[],
|
23 |
);
|
24 |
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
25 |
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
26 |
|
27 |
+
// Handler for search input updates
|
28 |
const handleSearch = (query: string) => {
|
29 |
setSearchQuery(query);
|
30 |
};
|
31 |
|
32 |
return (
|
33 |
<main className="min-h-screen bg-background-primary">
|
34 |
+
{/* Hero Section - Main title and description */}
|
35 |
<section className="mb-12 pt-16 text-center">
|
36 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
37 |
PlayGO for Students
|
38 |
</h1>
|
39 |
+
{/* Description in Traditional Chinese: "Explore AI chatbots suitable for learning and improve learning efficiency!" */}
|
40 |
<p className="mx-auto max-w-2xl text-lg text-text-secondary">
|
41 |
探索適合學習的 AI 聊天機器人,提升學習效率!
|
42 |
</p>
|
43 |
</section>
|
44 |
|
45 |
+
{/* Interactive Controls Section */}
|
46 |
<div className="container mx-auto px-4">
|
47 |
+
{/* Search functionality */}
|
48 |
<div className="mb-8">
|
49 |
<SearchBar onSearch={handleSearch} />
|
50 |
</div>
|
51 |
+
|
52 |
+
{/* Category and subject filtering system */}
|
53 |
<div className="mb-12">
|
54 |
<SubjectFilter
|
55 |
selectedCategories={selectedCategories}
|
|
|
59 |
/>
|
60 |
</div>
|
61 |
|
62 |
+
{/* Chatbot Display Section with loading state handling */}
|
63 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
64 |
<ChatBotGrid
|
65 |
selectedCategories={selectedCategories}
|
app/(audiences)/for-teachers/page.tsx
CHANGED
@@ -1,27 +1,45 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import * as React from "react";
|
4 |
import { Suspense } from "react";
|
|
|
|
|
5 |
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
6 |
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
7 |
import SearchBar from "@/app/(audiences)/components/search-bar";
|
8 |
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
|
|
|
|
9 |
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
export default function ForTeachersPage() {
|
|
|
12 |
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
13 |
[],
|
14 |
);
|
15 |
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
16 |
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
17 |
|
|
|
18 |
const handleSearch = (query: string) => {
|
19 |
setSearchQuery(query);
|
20 |
};
|
21 |
|
22 |
return (
|
23 |
<main className="min-h-screen bg-background-primary">
|
24 |
-
{/* Hero Section */}
|
25 |
<section className="mb-12 pt-16 text-center">
|
26 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
27 |
PlayGO for Teachers
|
@@ -31,11 +49,14 @@ export default function ForTeachersPage() {
|
|
31 |
</p>
|
32 |
</section>
|
33 |
|
34 |
-
{/*
|
35 |
<div className="container mx-auto px-4">
|
|
|
36 |
<div className="mb-8">
|
37 |
<SearchBar onSearch={handleSearch} />
|
38 |
</div>
|
|
|
|
|
39 |
<div className="mb-12">
|
40 |
<SubjectFilter
|
41 |
selectedCategories={selectedCategories}
|
@@ -45,7 +66,7 @@ export default function ForTeachersPage() {
|
|
45 |
/>
|
46 |
</div>
|
47 |
|
48 |
-
{/*
|
49 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
50 |
<ChatBotGrid
|
51 |
selectedCategories={selectedCategories}
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// React and core imports
|
4 |
import * as React from "react";
|
5 |
import { Suspense } from "react";
|
6 |
+
|
7 |
+
// Component imports
|
8 |
import ChatBotGrid from "@/app/(audiences)/components/chat-bot-grid";
|
9 |
import { SubjectFilter } from "@/app/(audiences)/components/subject-filter";
|
10 |
import SearchBar from "@/app/(audiences)/components/search-bar";
|
11 |
import ChatBotGridSkeleton from "@/app/(audiences)/components/chat-bot-grid-skeleton";
|
12 |
+
|
13 |
+
// Data fetching utilities
|
14 |
import { getPopularBots, getTrendingBots, getAllBots } from "./data/chatbots";
|
15 |
|
16 |
+
/**
|
17 |
+
* ForTeachersPage Component
|
18 |
+
*
|
19 |
+
* Main page component for the teachers' section that displays a searchable and filterable
|
20 |
+
* grid of AI chatbots specifically curated for educational purposes.
|
21 |
+
*
|
22 |
+
* Features:
|
23 |
+
* - Category and subject filtering
|
24 |
+
* - Search functionality
|
25 |
+
* - Responsive grid layout with loading skeleton
|
26 |
+
*/
|
27 |
export default function ForTeachersPage() {
|
28 |
+
// State management for filters and search
|
29 |
const [selectedCategories, setSelectedCategories] = React.useState<string[]>(
|
30 |
[],
|
31 |
);
|
32 |
const [selectedSubjects, setSelectedSubjects] = React.useState<string[]>([]);
|
33 |
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
34 |
|
35 |
+
// Handler for search input updates
|
36 |
const handleSearch = (query: string) => {
|
37 |
setSearchQuery(query);
|
38 |
};
|
39 |
|
40 |
return (
|
41 |
<main className="min-h-screen bg-background-primary">
|
42 |
+
{/* Hero Section - Main title and description in Chinese */}
|
43 |
<section className="mb-12 pt-16 text-center">
|
44 |
<h1 className="mb-4 text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
45 |
PlayGO for Teachers
|
|
|
49 |
</p>
|
50 |
</section>
|
51 |
|
52 |
+
{/* Interactive controls section */}
|
53 |
<div className="container mx-auto px-4">
|
54 |
+
{/* Search functionality */}
|
55 |
<div className="mb-8">
|
56 |
<SearchBar onSearch={handleSearch} />
|
57 |
</div>
|
58 |
+
|
59 |
+
{/* Category and subject filtering controls */}
|
60 |
<div className="mb-12">
|
61 |
<SubjectFilter
|
62 |
selectedCategories={selectedCategories}
|
|
|
66 |
/>
|
67 |
</div>
|
68 |
|
69 |
+
{/* Chatbot grid with loading fallback */}
|
70 |
<Suspense fallback={<ChatBotGridSkeleton />}>
|
71 |
<ChatBotGrid
|
72 |
selectedCategories={selectedCategories}
|
app/api/embed/route.ts
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextResponse } from 'next/server';
|
2 |
+
|
3 |
+
export async function GET() {
|
4 |
+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
5 |
+
|
6 |
+
// Return the actual embed.js content directly instead of loading it from a file
|
7 |
+
const embedScript = `
|
8 |
+
(function() {
|
9 |
+
console.log('Initializing PlayGo Chat Widget');
|
10 |
+
|
11 |
+
function createChatWidget(config) {
|
12 |
+
console.log('Creating chat widget with config:', config);
|
13 |
+
|
14 |
+
const iframe = document.createElement('iframe');
|
15 |
+
|
16 |
+
const iframeStyles = {
|
17 |
+
border: 'none',
|
18 |
+
position: 'fixed',
|
19 |
+
bottom: '0',
|
20 |
+
right: '0',
|
21 |
+
width: '380px',
|
22 |
+
height: '100px',
|
23 |
+
maxHeight: '600px',
|
24 |
+
zIndex: '9999',
|
25 |
+
background: 'none',
|
26 |
+
pointerEvents: 'all',
|
27 |
+
transition: 'height 0.5s ease-in-out',
|
28 |
+
overflow: 'hidden',
|
29 |
+
};
|
30 |
+
|
31 |
+
Object.assign(iframe.style, iframeStyles);
|
32 |
+
|
33 |
+
iframe.onload = function() {
|
34 |
+
const style = document.createElement('style');
|
35 |
+
style.textContent = \`
|
36 |
+
::-webkit-scrollbar {
|
37 |
+
display: none !important;
|
38 |
+
}
|
39 |
+
* {
|
40 |
+
-ms-overflow-style: none !important;
|
41 |
+
scrollbar-width: none !important;
|
42 |
+
}
|
43 |
+
\`;
|
44 |
+
iframe.contentDocument.head.appendChild(style);
|
45 |
+
};
|
46 |
+
|
47 |
+
const queryParams = new URLSearchParams(config).toString();
|
48 |
+
const chatUrl = '${baseUrl}/embed/chat?' + queryParams;
|
49 |
+
console.log('Chat URL:', chatUrl);
|
50 |
+
|
51 |
+
iframe.src = chatUrl;
|
52 |
+
|
53 |
+
window.addEventListener('message', function(event) {
|
54 |
+
console.log('Received message:', event.data);
|
55 |
+
if (event.data.type === 'CHAT_READY') {
|
56 |
+
console.log('Chat widget ready');
|
57 |
+
} else if (event.data.type === 'chatOpen') {
|
58 |
+
iframe.style.height = '600px';
|
59 |
+
} else if (event.data.type === 'chatClose') {
|
60 |
+
iframe.style.height = '100px';
|
61 |
+
}
|
62 |
+
});
|
63 |
+
|
64 |
+
return iframe;
|
65 |
+
}
|
66 |
+
|
67 |
+
window.playgo = function(action, config) {
|
68 |
+
console.log('PlayGo action called:', action, config);
|
69 |
+
|
70 |
+
if (action === 'init') {
|
71 |
+
try {
|
72 |
+
const existingWidget = document.getElementById('playgo-chat-widget');
|
73 |
+
if (existingWidget) {
|
74 |
+
existingWidget.remove();
|
75 |
+
}
|
76 |
+
|
77 |
+
const widget = createChatWidget(config);
|
78 |
+
widget.id = 'playgo-chat-widget';
|
79 |
+
document.body.appendChild(widget);
|
80 |
+
|
81 |
+
console.log('PlayGo chat initialized successfully');
|
82 |
+
} catch (error) {
|
83 |
+
console.error('Error initializing PlayGo chat:', error);
|
84 |
+
}
|
85 |
+
}
|
86 |
+
};
|
87 |
+
|
88 |
+
if (window.PlayGoChatWidget && window.PlayGoChatWidget.q) {
|
89 |
+
console.log('Processing queued commands:', window.PlayGoChatWidget.q);
|
90 |
+
window.PlayGoChatWidget.q.forEach(args => {
|
91 |
+
window.playgo.apply(null, args);
|
92 |
+
});
|
93 |
+
}
|
94 |
+
|
95 |
+
if (window.PlayGoChatWidget) {
|
96 |
+
window.PlayGoChatWidget.ready = true;
|
97 |
+
}
|
98 |
+
|
99 |
+
console.log('PlayGo Chat Widget initialization complete');
|
100 |
+
})();
|
101 |
+
`;
|
102 |
+
|
103 |
+
return new NextResponse(embedScript, {
|
104 |
+
headers: {
|
105 |
+
'Content-Type': 'application/javascript',
|
106 |
+
'Cache-Control': 'no-store, max-age=0',
|
107 |
+
},
|
108 |
+
});
|
109 |
+
}
|
app/components/embeddable-chat-bot.tsx
ADDED
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import { useState, useRef, useEffect } from "react";
|
4 |
+
import { useChat } from "ai/react";
|
5 |
+
import { XMarkIcon } from "@heroicons/react/24/outline";
|
6 |
+
|
7 |
+
type MessageWithLoading = {
|
8 |
+
content: string;
|
9 |
+
role: string;
|
10 |
+
isStreaming?: boolean;
|
11 |
+
};
|
12 |
+
|
13 |
+
// Define theme CSS variables
|
14 |
+
const themeStyles = {
|
15 |
+
light: {
|
16 |
+
'--bg-primary': '#ffffff',
|
17 |
+
'--bg-secondary': 'rgba(243, 244, 246, 0.7)',
|
18 |
+
'--text-primary': '#111827',
|
19 |
+
'--text-secondary': '#6B7280',
|
20 |
+
'--border-color': 'rgba(229, 231, 235, 0.5)',
|
21 |
+
'--shadow-color': 'rgba(0, 0, 0, 0.1)',
|
22 |
+
'--message-bg': 'rgba(243, 244, 246, 0.7)',
|
23 |
+
'--input-bg': 'rgba(255, 255, 255, 0.9)',
|
24 |
+
},
|
25 |
+
dark: {
|
26 |
+
'--bg-primary': '#1F2937',
|
27 |
+
'--bg-secondary': 'rgba(31, 41, 55, 0.7)',
|
28 |
+
'--text-primary': '#F9FAFB',
|
29 |
+
'--text-secondary': '#D1D5DB',
|
30 |
+
'--border-color': 'rgba(75, 85, 99, 0.5)',
|
31 |
+
'--shadow-color': 'rgba(0, 0, 0, 0.3)',
|
32 |
+
'--message-bg': 'rgba(55, 65, 81, 0.7)',
|
33 |
+
'--input-bg': 'rgba(31, 41, 55, 0.9)',
|
34 |
+
},
|
35 |
+
} as const;
|
36 |
+
|
37 |
+
// Inject required styles into iframe
|
38 |
+
const injectStyles = `
|
39 |
+
* {
|
40 |
+
margin: 0;
|
41 |
+
padding: 0;
|
42 |
+
box-sizing: border-box;
|
43 |
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
44 |
+
}
|
45 |
+
|
46 |
+
.playgo-chat {
|
47 |
+
background-color: var(--bg-primary);
|
48 |
+
color: var(--text-primary);
|
49 |
+
}
|
50 |
+
|
51 |
+
.playgo-chat-header {
|
52 |
+
background-color: var(--bg-secondary);
|
53 |
+
border-bottom: 1px solid var(--border-color);
|
54 |
+
}
|
55 |
+
|
56 |
+
.playgo-chat-messages {
|
57 |
+
background-color: var(--bg-primary);
|
58 |
+
}
|
59 |
+
|
60 |
+
.playgo-chat-input {
|
61 |
+
background-color: var(--bg-secondary);
|
62 |
+
border-top: 1px solid var(--border-color);
|
63 |
+
}
|
64 |
+
|
65 |
+
.playgo-chat-input-field {
|
66 |
+
background-color: var(--input-bg);
|
67 |
+
color: var(--text-primary);
|
68 |
+
border: 1px solid var(--border-color);
|
69 |
+
border-radius: 0.75rem;
|
70 |
+
padding: 0.75rem 3rem 0.75rem 0.75rem;
|
71 |
+
width: 100%;
|
72 |
+
transition: all 0.2s;
|
73 |
+
}
|
74 |
+
|
75 |
+
.playgo-chat-input-field:focus {
|
76 |
+
outline: none;
|
77 |
+
border-color: var(--primary-color);
|
78 |
+
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
79 |
+
}
|
80 |
+
|
81 |
+
.playgo-chat-input-field::placeholder {
|
82 |
+
color: var(--text-secondary);
|
83 |
+
}
|
84 |
+
|
85 |
+
.playgo-message-bubble {
|
86 |
+
box-shadow: 0 1px 2px var(--shadow-color);
|
87 |
+
max-width: 80%;
|
88 |
+
padding: 0.75rem;
|
89 |
+
border-radius: 0.75rem;
|
90 |
+
}
|
91 |
+
|
92 |
+
.playgo-message-assistant {
|
93 |
+
background-color: var(--message-bg);
|
94 |
+
color: var(--text-primary);
|
95 |
+
margin-right: 1rem;
|
96 |
+
}
|
97 |
+
|
98 |
+
.playgo-message-user {
|
99 |
+
background-color: var(--primary-color);
|
100 |
+
color: white;
|
101 |
+
margin-left: 1rem;
|
102 |
+
}
|
103 |
+
|
104 |
+
@media (prefers-color-scheme: dark) {
|
105 |
+
.playgo-chat[data-theme="system"] {
|
106 |
+
--bg-primary: #1F2937;
|
107 |
+
--bg-secondary: rgba(31, 41, 55, 0.7);
|
108 |
+
--text-primary: #F9FAFB;
|
109 |
+
--text-secondary: #D1D5DB;
|
110 |
+
--border-color: rgba(75, 85, 99, 0.5);
|
111 |
+
--shadow-color: rgba(0, 0, 0, 0.3);
|
112 |
+
--message-bg: rgba(55, 65, 81, 0.7);
|
113 |
+
--input-bg: rgba(31, 41, 55, 0.9);
|
114 |
+
}
|
115 |
+
}
|
116 |
+
`;
|
117 |
+
|
118 |
+
interface EmbeddableChatBotConfig {
|
119 |
+
apiUrl?: string;
|
120 |
+
height?: string | number;
|
121 |
+
width?: string | number;
|
122 |
+
theme?: 'light' | 'dark' | 'system';
|
123 |
+
primaryColor?: string;
|
124 |
+
placeholder?: string;
|
125 |
+
buttonText?: string;
|
126 |
+
}
|
127 |
+
|
128 |
+
// First, let's fix the CSS type error by declaring the CSS custom properties
|
129 |
+
declare module 'react' {
|
130 |
+
interface CSSProperties {
|
131 |
+
'--primary-color'?: string;
|
132 |
+
'--primary-rgb'?: string;
|
133 |
+
'--bg-primary'?: string;
|
134 |
+
'--bg-secondary'?: string;
|
135 |
+
'--text-primary'?: string;
|
136 |
+
'--text-secondary'?: string;
|
137 |
+
'--border-color'?: string;
|
138 |
+
'--shadow-color'?: string;
|
139 |
+
'--message-bg'?: string;
|
140 |
+
'--input-bg'?: string;
|
141 |
+
}
|
142 |
+
}
|
143 |
+
|
144 |
+
export default function EmbeddableChatBot({
|
145 |
+
apiUrl = "/api/landing_page_chat",
|
146 |
+
theme = 'system',
|
147 |
+
primaryColor = "#FF6B6B",
|
148 |
+
placeholder = "請問任何關於學習的問題...",
|
149 |
+
buttonText = "需要協助嗎?",
|
150 |
+
}: EmbeddableChatBotConfig) {
|
151 |
+
const chatContainerRef = useRef<HTMLDivElement>(null);
|
152 |
+
const [isOpen, setIsOpen] = useState(false);
|
153 |
+
const [messages, setMessages] = useState<MessageWithLoading[]>([]);
|
154 |
+
const [currentTheme, setCurrentTheme] = useState(theme);
|
155 |
+
|
156 |
+
// Convert hex color to RGB for CSS variables
|
157 |
+
const hexToRgb = (hex: string) => {
|
158 |
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
159 |
+
return result ?
|
160 |
+
`${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
161 |
+
: '255, 107, 107'; // fallback RGB for #FF6B6B
|
162 |
+
};
|
163 |
+
|
164 |
+
const {
|
165 |
+
input,
|
166 |
+
handleInputChange,
|
167 |
+
handleSubmit,
|
168 |
+
isLoading,
|
169 |
+
} = useChat({
|
170 |
+
api: apiUrl,
|
171 |
+
onError: (error) => {
|
172 |
+
console.error("Chat error:", error);
|
173 |
+
},
|
174 |
+
onFinish: () => {
|
175 |
+
setMessages((prev) =>
|
176 |
+
prev.map((msg) => ({ ...msg, isStreaming: false }))
|
177 |
+
);
|
178 |
+
},
|
179 |
+
});
|
180 |
+
|
181 |
+
// Handle system theme changes
|
182 |
+
useEffect(() => {
|
183 |
+
if (theme === 'system') {
|
184 |
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
185 |
+
const handleChange = (e: MediaQueryListEvent) => {
|
186 |
+
setCurrentTheme(e.matches ? 'dark' : 'light');
|
187 |
+
};
|
188 |
+
|
189 |
+
mediaQuery.addEventListener('change', handleChange);
|
190 |
+
setCurrentTheme(mediaQuery.matches ? 'dark' : 'light');
|
191 |
+
|
192 |
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
193 |
+
} else {
|
194 |
+
setCurrentTheme(theme);
|
195 |
+
}
|
196 |
+
}, [theme]);
|
197 |
+
|
198 |
+
useEffect(() => {
|
199 |
+
if (chatContainerRef.current) {
|
200 |
+
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
201 |
+
}
|
202 |
+
}, [messages]);
|
203 |
+
|
204 |
+
// Inject styles when component mounts
|
205 |
+
useEffect(() => {
|
206 |
+
if (isOpen && chatContainerRef.current) {
|
207 |
+
const styleSheet = document.createElement('style');
|
208 |
+
styleSheet.textContent = injectStyles;
|
209 |
+
chatContainerRef.current.appendChild(styleSheet);
|
210 |
+
}
|
211 |
+
}, [isOpen]);
|
212 |
+
|
213 |
+
const containerStyle = {
|
214 |
+
...(themeStyles[currentTheme === 'system' ? 'light' : currentTheme]),
|
215 |
+
'--primary-color': primaryColor,
|
216 |
+
'--primary-rgb': hexToRgb(primaryColor),
|
217 |
+
} as React.CSSProperties;
|
218 |
+
|
219 |
+
// Send message to parent when chat state changes
|
220 |
+
useEffect(() => {
|
221 |
+
if (typeof window !== 'undefined') {
|
222 |
+
window.parent.postMessage({
|
223 |
+
type: isOpen ? 'chatOpen' : 'chatClose'
|
224 |
+
}, '*');
|
225 |
+
}
|
226 |
+
}, [isOpen]);
|
227 |
+
|
228 |
+
if (!isOpen) {
|
229 |
+
return (
|
230 |
+
<div className="w-full h-full flex items-center justify-end">
|
231 |
+
<button
|
232 |
+
onClick={() => setIsOpen(true)}
|
233 |
+
className="bg-primary text-white rounded-full p-4
|
234 |
+
transition-colors duration-200
|
235 |
+
inline-flex items-center"
|
236 |
+
style={{ backgroundColor: primaryColor }}
|
237 |
+
>
|
238 |
+
<span className="flex items-center gap-2">
|
239 |
+
<svg
|
240 |
+
xmlns="http://www.w3.org/2000/svg"
|
241 |
+
fill="none"
|
242 |
+
viewBox="0 0 24 24"
|
243 |
+
strokeWidth={1.5}
|
244 |
+
stroke="currentColor"
|
245 |
+
className="w-6 h-6"
|
246 |
+
>
|
247 |
+
<path
|
248 |
+
strokeLinecap="round"
|
249 |
+
strokeLinejoin="round"
|
250 |
+
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
251 |
+
/>
|
252 |
+
</svg>
|
253 |
+
{buttonText}
|
254 |
+
</span>
|
255 |
+
</button>
|
256 |
+
</div>
|
257 |
+
);
|
258 |
+
}
|
259 |
+
|
260 |
+
return (
|
261 |
+
<div
|
262 |
+
ref={chatContainerRef}
|
263 |
+
className="w-full h-full rounded-2xl"
|
264 |
+
style={{
|
265 |
+
...containerStyle,
|
266 |
+
overflow: 'hidden',
|
267 |
+
}}
|
268 |
+
data-theme={theme}
|
269 |
+
>
|
270 |
+
<div
|
271 |
+
className="playgo-chat-container rounded-2xl bg-background-primary w-full h-full"
|
272 |
+
style={{
|
273 |
+
position: 'absolute',
|
274 |
+
top: 0,
|
275 |
+
left: 0,
|
276 |
+
right: 0,
|
277 |
+
bottom: 0,
|
278 |
+
overflow: 'hidden'
|
279 |
+
}}
|
280 |
+
>
|
281 |
+
<div className="flex flex-col h-full absolute inset-0">
|
282 |
+
<div className="playgo-chat-header p-4 flex justify-between items-center flex-shrink-0">
|
283 |
+
<h2 className="text-xl font-bold">
|
284 |
+
<span className="text-[#FF6B6B]">P</span>
|
285 |
+
<span className="text-[#4ECDC4]">l</span>
|
286 |
+
<span className="text-[#45B7D1]">a</span>
|
287 |
+
<span className="text-[#FDCB6E]">y</span>
|
288 |
+
<span className="text-[#FF6B6B]">G</span>
|
289 |
+
<span className="text-[#4ECDC4]">o</span>
|
290 |
+
<span className="ml-2 text-[#45B7D1]">A</span>
|
291 |
+
<span className="text-[#FDCB6E]">I</span>
|
292 |
+
</h2>
|
293 |
+
<button
|
294 |
+
onClick={() => setIsOpen(false)}
|
295 |
+
className="p-1 hover:bg-background-secondary/50 rounded-full transition-colors"
|
296 |
+
>
|
297 |
+
<XMarkIcon className="w-6 h-6 text-text-secondary" />
|
298 |
+
</button>
|
299 |
+
</div>
|
300 |
+
|
301 |
+
<div className="flex-1 relative">
|
302 |
+
<div
|
303 |
+
className="playgo-chat-messages absolute inset-0 overflow-y-auto p-4 space-y-4"
|
304 |
+
style={{
|
305 |
+
overscrollBehavior: 'contain',
|
306 |
+
WebkitOverflowScrolling: 'touch'
|
307 |
+
}}
|
308 |
+
>
|
309 |
+
{messages.map((message, index) => (
|
310 |
+
<div
|
311 |
+
key={index}
|
312 |
+
className={`flex ${
|
313 |
+
message.role === "user" ? "justify-end" : "justify-start"
|
314 |
+
}`}
|
315 |
+
>
|
316 |
+
<div
|
317 |
+
className={`playgo-message-bubble ${
|
318 |
+
message.role === "user"
|
319 |
+
? "playgo-message-user"
|
320 |
+
: "playgo-message-assistant"
|
321 |
+
}`}
|
322 |
+
>
|
323 |
+
{message.content}
|
324 |
+
{message.isStreaming && (
|
325 |
+
<span className="ml-1 animate-pulse">...</span>
|
326 |
+
)}
|
327 |
+
</div>
|
328 |
+
</div>
|
329 |
+
))}
|
330 |
+
</div>
|
331 |
+
</div>
|
332 |
+
|
333 |
+
<form
|
334 |
+
onSubmit={handleSubmit}
|
335 |
+
className="playgo-chat-input p-4 flex-shrink-0"
|
336 |
+
>
|
337 |
+
<div className="relative">
|
338 |
+
<input
|
339 |
+
type="text"
|
340 |
+
value={input}
|
341 |
+
onChange={handleInputChange}
|
342 |
+
placeholder={placeholder}
|
343 |
+
className="playgo-chat-input-field"
|
344 |
+
/>
|
345 |
+
<button
|
346 |
+
type="submit"
|
347 |
+
disabled={isLoading || !input.trim()}
|
348 |
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-2
|
349 |
+
text-primary hover:text-primary/80 disabled:opacity-50
|
350 |
+
transition-colors duration-200"
|
351 |
+
style={{ color: primaryColor }}
|
352 |
+
>
|
353 |
+
<svg
|
354 |
+
xmlns="http://www.w3.org/2000/svg"
|
355 |
+
fill="none"
|
356 |
+
viewBox="0 0 24 24"
|
357 |
+
strokeWidth={1.5}
|
358 |
+
stroke="currentColor"
|
359 |
+
className="w-6 h-6"
|
360 |
+
>
|
361 |
+
<path
|
362 |
+
strokeLinecap="round"
|
363 |
+
strokeLinejoin="round"
|
364 |
+
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
365 |
+
/>
|
366 |
+
</svg>
|
367 |
+
</button>
|
368 |
+
</div>
|
369 |
+
</form>
|
370 |
+
</div>
|
371 |
+
</div>
|
372 |
+
</div>
|
373 |
+
);
|
374 |
+
}
|
app/components/landing-page-chat-bot.tsx
CHANGED
@@ -1,7 +1,9 @@
|
|
1 |
"use client";
|
2 |
|
|
|
3 |
import { useState, useRef, useEffect } from "react";
|
4 |
import { useChat } from "ai/react";
|
|
|
5 |
import {
|
6 |
AcademicCapIcon,
|
7 |
ClockIcon,
|
@@ -10,15 +12,17 @@ import {
|
|
10 |
BriefcaseIcon,
|
11 |
} from "@heroicons/react/24/outline";
|
12 |
|
|
|
13 |
type MessageWithLoading = {
|
14 |
content: string;
|
15 |
role: string;
|
16 |
isStreaming?: boolean;
|
17 |
};
|
18 |
|
|
|
19 |
const EXAMPLE_PROMPTS = [
|
20 |
{
|
21 |
-
title: "教案規劃",
|
22 |
prompt: "我想規劃一堂關於數學的課程",
|
23 |
icon: AcademicCapIcon,
|
24 |
},
|
@@ -45,6 +49,7 @@ const EXAMPLE_PROMPTS = [
|
|
45 |
];
|
46 |
|
47 |
export default function LandingPageChatBot() {
|
|
|
48 |
const {
|
49 |
messages: rawMessages,
|
50 |
input,
|
@@ -60,6 +65,7 @@ export default function LandingPageChatBot() {
|
|
60 |
},
|
61 |
onFinish: (message) => {
|
62 |
console.log("Chat finished:", message);
|
|
|
63 |
setMessages((prev) =>
|
64 |
prev.map((msg) => ({ ...msg, isStreaming: false })),
|
65 |
);
|
@@ -67,10 +73,12 @@ export default function LandingPageChatBot() {
|
|
67 |
keepLastMessageOnError: true,
|
68 |
});
|
69 |
|
|
|
70 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
71 |
const [hasInteracted, setHasInteracted] = useState(false);
|
72 |
const [messages, setMessages] = useState<MessageWithLoading[]>([]);
|
73 |
|
|
|
74 |
useEffect(() => {
|
75 |
setMessages(
|
76 |
rawMessages.map((msg, index) => ({
|
@@ -83,6 +91,7 @@ export default function LandingPageChatBot() {
|
|
83 |
);
|
84 |
}, [rawMessages, isLoading]);
|
85 |
|
|
|
86 |
const scrollToBottom = () => {
|
87 |
if (chatContainerRef.current) {
|
88 |
const { scrollHeight, clientHeight } = chatContainerRef.current;
|
@@ -94,6 +103,7 @@ export default function LandingPageChatBot() {
|
|
94 |
scrollToBottom();
|
95 |
}, [messages]);
|
96 |
|
|
|
97 |
const sendMessage = async (text: string) => {
|
98 |
setHasInteracted(true);
|
99 |
await append({
|
@@ -110,8 +120,10 @@ export default function LandingPageChatBot() {
|
|
110 |
return (
|
111 |
<section className="w-full h-[1000px] bg-gradient-to-b from-background-secondary to-background-primary flex items-center justify-center px-4">
|
112 |
<div className="w-full max-w-5xl mx-auto h-full flex items-center">
|
|
|
113 |
{!hasInteracted && messages.length === 0 ? (
|
114 |
<div className="text-center space-y-8 w-full">
|
|
|
115 |
<div className="space-y-4">
|
116 |
<h2 className="text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
117 |
今天你想學什麼?
|
@@ -121,6 +133,7 @@ export default function LandingPageChatBot() {
|
|
121 |
</p>
|
122 |
</div>
|
123 |
|
|
|
124 |
<form onSubmit={onSubmit} className="max-w-3xl mx-auto">
|
125 |
<div className="relative flex items-center">
|
126 |
<input
|
@@ -158,6 +171,7 @@ export default function LandingPageChatBot() {
|
|
158 |
</div>
|
159 |
</form>
|
160 |
|
|
|
161 |
<div className="mt-12">
|
162 |
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
163 |
{EXAMPLE_PROMPTS.map((prompt, index) => (
|
@@ -195,17 +209,21 @@ export default function LandingPageChatBot() {
|
|
195 |
</div>
|
196 |
</div>
|
197 |
) : (
|
|
|
198 |
<div className="bg-background-primary/50 backdrop-blur-sm rounded-2xl shadow-lg border border-border/50 overflow-hidden w-full h-[700px] flex flex-col">
|
|
|
199 |
<div className="p-6 border-b border-border/50 bg-background-secondary/30">
|
200 |
<h2 className="text-xl font-semibold text-text-primary">
|
201 |
PlayGO 導覽員
|
202 |
</h2>
|
203 |
</div>
|
204 |
|
|
|
205 |
<div
|
206 |
ref={chatContainerRef}
|
207 |
className="flex-1 overflow-y-auto p-6 space-y-6"
|
208 |
>
|
|
|
209 |
{messages.map((message, index) => (
|
210 |
<div
|
211 |
key={index}
|
@@ -227,6 +245,7 @@ export default function LandingPageChatBot() {
|
|
227 |
))}
|
228 |
</div>
|
229 |
|
|
|
230 |
<div className="p-6 border-t border-border/50 bg-background-secondary/30">
|
231 |
<form onSubmit={onSubmit}>
|
232 |
<div className="relative flex items-center">
|
|
|
1 |
"use client";
|
2 |
|
3 |
+
// Core React and AI chat hooks
|
4 |
import { useState, useRef, useEffect } from "react";
|
5 |
import { useChat } from "ai/react";
|
6 |
+
// UI Icons for example prompts
|
7 |
import {
|
8 |
AcademicCapIcon,
|
9 |
ClockIcon,
|
|
|
12 |
BriefcaseIcon,
|
13 |
} from "@heroicons/react/24/outline";
|
14 |
|
15 |
+
// Extends the basic message type to include streaming state
|
16 |
type MessageWithLoading = {
|
17 |
content: string;
|
18 |
role: string;
|
19 |
isStreaming?: boolean;
|
20 |
};
|
21 |
|
22 |
+
// Predefined example prompts for users to quickly start conversations
|
23 |
const EXAMPLE_PROMPTS = [
|
24 |
{
|
25 |
+
title: "教案規劃", // Lesson Planning
|
26 |
prompt: "我想規劃一堂關於數學的課程",
|
27 |
icon: AcademicCapIcon,
|
28 |
},
|
|
|
49 |
];
|
50 |
|
51 |
export default function LandingPageChatBot() {
|
52 |
+
// Initialize chat functionality with error handling and streaming support
|
53 |
const {
|
54 |
messages: rawMessages,
|
55 |
input,
|
|
|
65 |
},
|
66 |
onFinish: (message) => {
|
67 |
console.log("Chat finished:", message);
|
68 |
+
// Reset streaming state when message is complete
|
69 |
setMessages((prev) =>
|
70 |
prev.map((msg) => ({ ...msg, isStreaming: false })),
|
71 |
);
|
|
|
73 |
keepLastMessageOnError: true,
|
74 |
});
|
75 |
|
76 |
+
// Refs and state management
|
77 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
78 |
const [hasInteracted, setHasInteracted] = useState(false);
|
79 |
const [messages, setMessages] = useState<MessageWithLoading[]>([]);
|
80 |
|
81 |
+
// Update messages with streaming state when new messages arrive
|
82 |
useEffect(() => {
|
83 |
setMessages(
|
84 |
rawMessages.map((msg, index) => ({
|
|
|
91 |
);
|
92 |
}, [rawMessages, isLoading]);
|
93 |
|
94 |
+
// Auto-scroll chat to bottom when new messages arrive
|
95 |
const scrollToBottom = () => {
|
96 |
if (chatContainerRef.current) {
|
97 |
const { scrollHeight, clientHeight } = chatContainerRef.current;
|
|
|
103 |
scrollToBottom();
|
104 |
}, [messages]);
|
105 |
|
106 |
+
// Message handling functions
|
107 |
const sendMessage = async (text: string) => {
|
108 |
setHasInteracted(true);
|
109 |
await append({
|
|
|
120 |
return (
|
121 |
<section className="w-full h-[1000px] bg-gradient-to-b from-background-secondary to-background-primary flex items-center justify-center px-4">
|
122 |
<div className="w-full max-w-5xl mx-auto h-full flex items-center">
|
123 |
+
{/* Initial landing view with example prompts */}
|
124 |
{!hasInteracted && messages.length === 0 ? (
|
125 |
<div className="text-center space-y-8 w-full">
|
126 |
+
{/* Hero section with title and input */}
|
127 |
<div className="space-y-4">
|
128 |
<h2 className="text-5xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
129 |
今天你想學什麼?
|
|
|
133 |
</p>
|
134 |
</div>
|
135 |
|
136 |
+
{/* Message input form */}
|
137 |
<form onSubmit={onSubmit} className="max-w-3xl mx-auto">
|
138 |
<div className="relative flex items-center">
|
139 |
<input
|
|
|
171 |
</div>
|
172 |
</form>
|
173 |
|
174 |
+
{/* Example prompts grid */}
|
175 |
<div className="mt-12">
|
176 |
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
177 |
{EXAMPLE_PROMPTS.map((prompt, index) => (
|
|
|
209 |
</div>
|
210 |
</div>
|
211 |
) : (
|
212 |
+
/* Chat interface after interaction */
|
213 |
<div className="bg-background-primary/50 backdrop-blur-sm rounded-2xl shadow-lg border border-border/50 overflow-hidden w-full h-[700px] flex flex-col">
|
214 |
+
{/* Chat header */}
|
215 |
<div className="p-6 border-b border-border/50 bg-background-secondary/30">
|
216 |
<h2 className="text-xl font-semibold text-text-primary">
|
217 |
PlayGO 導覽員
|
218 |
</h2>
|
219 |
</div>
|
220 |
|
221 |
+
{/* Messages container with auto-scroll */}
|
222 |
<div
|
223 |
ref={chatContainerRef}
|
224 |
className="flex-1 overflow-y-auto p-6 space-y-6"
|
225 |
>
|
226 |
+
{/* Message bubbles with different styles for user/assistant */}
|
227 |
{messages.map((message, index) => (
|
228 |
<div
|
229 |
key={index}
|
|
|
245 |
))}
|
246 |
</div>
|
247 |
|
248 |
+
{/* Input form at bottom of chat */}
|
249 |
<div className="p-6 border-t border-border/50 bg-background-secondary/30">
|
250 |
<form onSubmit={onSubmit}>
|
251 |
<div className="relative flex items-center">
|
app/embed/chat/page.tsx
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
|
3 |
+
import dynamic from 'next/dynamic';
|
4 |
+
import { useEffect } from 'react';
|
5 |
+
|
6 |
+
// Dynamically import the chat bot component with no SSR
|
7 |
+
const EmbeddableChatBot = dynamic(
|
8 |
+
() => import('@/app/components/embeddable-chat-bot'),
|
9 |
+
{ ssr: false }
|
10 |
+
);
|
11 |
+
|
12 |
+
export default function EmbedChatPage() {
|
13 |
+
useEffect(() => {
|
14 |
+
const params = new URLSearchParams(window.location.search);
|
15 |
+
const config = {
|
16 |
+
apiUrl: params.get('apiUrl') || undefined,
|
17 |
+
theme: params.get('theme') as 'light' | 'dark' || 'light',
|
18 |
+
primaryColor: params.get('primaryColor') || '#FF6B6B',
|
19 |
+
placeholder: params.get('placeholder') || undefined,
|
20 |
+
buttonText: params.get('buttonText') || undefined,
|
21 |
+
};
|
22 |
+
|
23 |
+
if (window.parent) {
|
24 |
+
window.parent.postMessage({ type: 'CHAT_READY', config }, '*');
|
25 |
+
}
|
26 |
+
}, []);
|
27 |
+
|
28 |
+
return <EmbeddableChatBot />;
|
29 |
+
}
|
app/embed/layout.tsx
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { Metadata } from "next";
|
2 |
+
|
3 |
+
export const metadata: Metadata = {
|
4 |
+
title: "PlayGo AI Chat",
|
5 |
+
description: "Embedded AI Chat Interface",
|
6 |
+
};
|
7 |
+
|
8 |
+
export default function EmbedLayout({
|
9 |
+
children,
|
10 |
+
}: {
|
11 |
+
children: React.ReactNode;
|
12 |
+
}) {
|
13 |
+
return (
|
14 |
+
<html lang="en">
|
15 |
+
<body style={{
|
16 |
+
margin: 0,
|
17 |
+
padding: 0,
|
18 |
+
background: 'transparent',
|
19 |
+
height: '100%',
|
20 |
+
width: '100%'
|
21 |
+
}}>
|
22 |
+
{children}
|
23 |
+
</body>
|
24 |
+
</html>
|
25 |
+
);
|
26 |
+
}
|
app/layout.tsx
CHANGED
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
|
|
3 |
import "./globals.css";
|
4 |
import { ThemeProvider } from "@/app/components/theme-provider";
|
5 |
import { TopNav } from "./components/top-nav";
|
|
|
6 |
|
7 |
const geistSans = localFont({
|
8 |
src: "./fonts/GeistVF.woff",
|
@@ -16,11 +17,19 @@ export const metadata: Metadata = {
|
|
16 |
"Transform education with AI-powered interactive learning experiences",
|
17 |
};
|
18 |
|
19 |
-
export default function RootLayout({
|
20 |
children,
|
21 |
}: {
|
22 |
children: React.ReactNode;
|
23 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
return (
|
25 |
<html
|
26 |
lang="en"
|
@@ -35,7 +44,9 @@ export default function RootLayout({
|
|
35 |
disableTransitionOnChange
|
36 |
>
|
37 |
<TopNav />
|
38 |
-
<main className="pt-14">
|
|
|
|
|
39 |
</ThemeProvider>
|
40 |
</body>
|
41 |
</html>
|
|
|
3 |
import "./globals.css";
|
4 |
import { ThemeProvider } from "@/app/components/theme-provider";
|
5 |
import { TopNav } from "./components/top-nav";
|
6 |
+
import { headers } from 'next/headers';
|
7 |
|
8 |
const geistSans = localFont({
|
9 |
src: "./fonts/GeistVF.woff",
|
|
|
17 |
"Transform education with AI-powered interactive learning experiences",
|
18 |
};
|
19 |
|
20 |
+
export default async function RootLayout({
|
21 |
children,
|
22 |
}: {
|
23 |
children: React.ReactNode;
|
24 |
}) {
|
25 |
+
const headersList = await headers();
|
26 |
+
const pathname = headersList.get("x-pathname") || "";
|
27 |
+
const isEmbed = pathname.startsWith("/embed");
|
28 |
+
|
29 |
+
if (isEmbed) {
|
30 |
+
return children;
|
31 |
+
}
|
32 |
+
|
33 |
return (
|
34 |
<html
|
35 |
lang="en"
|
|
|
44 |
disableTransitionOnChange
|
45 |
>
|
46 |
<TopNav />
|
47 |
+
<main className="pt-14">
|
48 |
+
{children}
|
49 |
+
</main>
|
50 |
</ThemeProvider>
|
51 |
</body>
|
52 |
</html>
|
app/types/chatbot.ts
DELETED
@@ -1,10 +0,0 @@
|
|
1 |
-
export interface ChatBot {
|
2 |
-
id: string;
|
3 |
-
title: string;
|
4 |
-
description: string;
|
5 |
-
subject: string;
|
6 |
-
icon: string;
|
7 |
-
category: string;
|
8 |
-
popular?: boolean;
|
9 |
-
trending?: boolean;
|
10 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
middleware.ts
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { NextResponse } from 'next/server';
|
2 |
+
import type { NextRequest } from 'next/server';
|
3 |
+
|
4 |
+
export function middleware(request: NextRequest) {
|
5 |
+
const response = NextResponse.next();
|
6 |
+
|
7 |
+
// Add pathname to headers for layout detection
|
8 |
+
response.headers.set('x-pathname', request.nextUrl.pathname);
|
9 |
+
|
10 |
+
// CORS handling
|
11 |
+
const origin = request.headers.get('origin');
|
12 |
+
|
13 |
+
if (process.env.NODE_ENV === 'development') {
|
14 |
+
response.headers.set('Access-Control-Allow-Origin', '*');
|
15 |
+
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
16 |
+
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
17 |
+
return response;
|
18 |
+
}
|
19 |
+
|
20 |
+
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
|
21 |
+
|
22 |
+
if (origin && allowedOrigins.includes(origin)) {
|
23 |
+
response.headers.set('Access-Control-Allow-Origin', origin);
|
24 |
+
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
25 |
+
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
26 |
+
}
|
27 |
+
|
28 |
+
return response;
|
29 |
+
}
|
30 |
+
|
31 |
+
export const config = {
|
32 |
+
matcher: [
|
33 |
+
'/api/:path*',
|
34 |
+
'/((?!_next/static|_next/image|favicon.ico).*)',
|
35 |
+
],
|
36 |
+
};
|
public/embed.js
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
(function() {
|
2 |
+
console.log('Initializing PlayGo Chat Widget');
|
3 |
+
|
4 |
+
function createChatWidget(config) {
|
5 |
+
console.log('Creating chat widget with config:', config);
|
6 |
+
|
7 |
+
// Create container for positioning
|
8 |
+
const container = document.createElement('div');
|
9 |
+
Object.assign(container.style, {
|
10 |
+
position: 'fixed',
|
11 |
+
bottom: '0',
|
12 |
+
right: '0',
|
13 |
+
zIndex: '9999',
|
14 |
+
padding: '20px',
|
15 |
+
transition: 'all 0.3s ease-in-out',
|
16 |
+
background: 'transparent',
|
17 |
+
width: '250px',
|
18 |
+
height: '120px',
|
19 |
+
display: 'flex',
|
20 |
+
alignItems: 'flex-end',
|
21 |
+
justifyContent: 'flex-end'
|
22 |
+
});
|
23 |
+
|
24 |
+
// Create iframe
|
25 |
+
const iframe = document.createElement('iframe');
|
26 |
+
Object.assign(iframe.style, {
|
27 |
+
border: 'none',
|
28 |
+
background: 'transparent',
|
29 |
+
width: '180px',
|
30 |
+
height: '60px',
|
31 |
+
transition: 'all 0.3s ease-in-out',
|
32 |
+
transform: 'scale(1)',
|
33 |
+
transformOrigin: 'bottom right'
|
34 |
+
});
|
35 |
+
|
36 |
+
// Set source with config parameters
|
37 |
+
const queryParams = new URLSearchParams(config).toString();
|
38 |
+
const chatUrl = `${window.location.origin}/embed/chat?${queryParams}`;
|
39 |
+
iframe.src = chatUrl;
|
40 |
+
|
41 |
+
// Handle messages from the iframe
|
42 |
+
window.addEventListener('message', function(event) {
|
43 |
+
if (event.data.type === 'chatOpen') {
|
44 |
+
container.style.padding = '20px';
|
45 |
+
container.style.width = '380px';
|
46 |
+
container.style.height = '600px';
|
47 |
+
iframe.style.width = '100%';
|
48 |
+
iframe.style.height = '100%';
|
49 |
+
} else if (event.data.type === 'chatClose') {
|
50 |
+
container.style.padding = '20px';
|
51 |
+
container.style.width = '250px';
|
52 |
+
container.style.height = '120px';
|
53 |
+
iframe.style.width = '180px';
|
54 |
+
iframe.style.height = '60px';
|
55 |
+
}
|
56 |
+
});
|
57 |
+
|
58 |
+
container.appendChild(iframe);
|
59 |
+
return container;
|
60 |
+
}
|
61 |
+
|
62 |
+
// Initialize the widget
|
63 |
+
window.playgo = function(action, config = {}) {
|
64 |
+
if (action === 'init') {
|
65 |
+
try {
|
66 |
+
const existingWidget = document.getElementById('playgo-chat-widget');
|
67 |
+
if (existingWidget) {
|
68 |
+
existingWidget.remove();
|
69 |
+
}
|
70 |
+
|
71 |
+
const widget = createChatWidget(config);
|
72 |
+
widget.id = 'playgo-chat-widget';
|
73 |
+
document.body.appendChild(widget);
|
74 |
+
|
75 |
+
console.log('PlayGo chat initialized successfully');
|
76 |
+
} catch (error) {
|
77 |
+
console.error('Error initializing PlayGo chat:', error);
|
78 |
+
}
|
79 |
+
}
|
80 |
+
};
|
81 |
+
|
82 |
+
// Process any queued commands
|
83 |
+
if (window.PlayGoChatWidget?.q) {
|
84 |
+
window.PlayGoChatWidget.q.forEach(args => window.playgo(...args));
|
85 |
+
}
|
86 |
+
})();
|
public/test-embed.html
ADDED
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Test Embedded Chat Bot</title>
|
7 |
+
<style>
|
8 |
+
* {
|
9 |
+
margin: 0;
|
10 |
+
padding: 0;
|
11 |
+
box-sizing: border-box;
|
12 |
+
}
|
13 |
+
|
14 |
+
body {
|
15 |
+
min-height: 100vh;
|
16 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
17 |
+
line-height: 1.6;
|
18 |
+
background-color: #fff;
|
19 |
+
color: #333;
|
20 |
+
}
|
21 |
+
|
22 |
+
.header {
|
23 |
+
background: #f3f4f6;
|
24 |
+
padding: 1rem 2rem;
|
25 |
+
border-bottom: 1px solid #e5e7eb;
|
26 |
+
position: sticky;
|
27 |
+
top: 0;
|
28 |
+
z-index: 10;
|
29 |
+
}
|
30 |
+
|
31 |
+
.content {
|
32 |
+
max-width: 1200px;
|
33 |
+
margin: 0 auto;
|
34 |
+
padding: 2rem;
|
35 |
+
}
|
36 |
+
|
37 |
+
.section {
|
38 |
+
margin-bottom: 5rem;
|
39 |
+
}
|
40 |
+
|
41 |
+
.section h2 {
|
42 |
+
font-size: 1.5rem;
|
43 |
+
margin-bottom: 1rem;
|
44 |
+
color: #111827;
|
45 |
+
}
|
46 |
+
|
47 |
+
.card-grid {
|
48 |
+
display: grid;
|
49 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
50 |
+
gap: 1.5rem;
|
51 |
+
margin-bottom: 2rem;
|
52 |
+
}
|
53 |
+
|
54 |
+
.card {
|
55 |
+
background: #fff;
|
56 |
+
padding: 1.5rem;
|
57 |
+
border-radius: 0.5rem;
|
58 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
59 |
+
border: 1px solid #e5e7eb;
|
60 |
+
min-height: 200px;
|
61 |
+
display: flex;
|
62 |
+
flex-direction: column;
|
63 |
+
justify-content: space-between;
|
64 |
+
}
|
65 |
+
|
66 |
+
.card h3 {
|
67 |
+
color: #111827;
|
68 |
+
margin-bottom: 0.5rem;
|
69 |
+
}
|
70 |
+
|
71 |
+
.card p {
|
72 |
+
color: #4b5563;
|
73 |
+
}
|
74 |
+
|
75 |
+
.footer {
|
76 |
+
background: #f3f4f6;
|
77 |
+
padding: 2rem;
|
78 |
+
text-align: center;
|
79 |
+
border-top: 1px solid #e5e7eb;
|
80 |
+
color: #6b7280;
|
81 |
+
margin-top: 5rem;
|
82 |
+
}
|
83 |
+
|
84 |
+
.spacer {
|
85 |
+
height: 50vh;
|
86 |
+
display: flex;
|
87 |
+
align-items: center;
|
88 |
+
justify-content: center;
|
89 |
+
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.05), transparent);
|
90 |
+
margin: 3rem 0;
|
91 |
+
font-size: 1.2rem;
|
92 |
+
color: #666;
|
93 |
+
font-style: italic;
|
94 |
+
}
|
95 |
+
|
96 |
+
@media (prefers-color-scheme: dark) {
|
97 |
+
body {
|
98 |
+
background-color: #1a1a1a;
|
99 |
+
color: #e5e5e5;
|
100 |
+
}
|
101 |
+
|
102 |
+
.header, .footer {
|
103 |
+
background: #2d2d2d;
|
104 |
+
border-color: #404040;
|
105 |
+
}
|
106 |
+
|
107 |
+
.card {
|
108 |
+
background: #2d2d2d;
|
109 |
+
border-color: #404040;
|
110 |
+
}
|
111 |
+
|
112 |
+
.card h3 {
|
113 |
+
color: #e5e5e5;
|
114 |
+
}
|
115 |
+
|
116 |
+
.card p {
|
117 |
+
color: #a3a3a3;
|
118 |
+
}
|
119 |
+
|
120 |
+
.spacer {
|
121 |
+
background: linear-gradient(to bottom, transparent, rgba(255,255,255,0.05), transparent);
|
122 |
+
color: #999;
|
123 |
+
}
|
124 |
+
}
|
125 |
+
</style>
|
126 |
+
</head>
|
127 |
+
<body>
|
128 |
+
<header class="header">
|
129 |
+
<h1>Test Page for Embedded Chat Bot</h1>
|
130 |
+
</header>
|
131 |
+
|
132 |
+
<main class="content">
|
133 |
+
<section class="section">
|
134 |
+
<h2>About This Test Page</h2>
|
135 |
+
<p>This is a full-page test environment for the PlayGO AI Chat Bot. The chat bot appears as a floating button at the bottom right corner of the page.</p>
|
136 |
+
</section>
|
137 |
+
|
138 |
+
<div class="spacer">
|
139 |
+
Scroll down to see more content...
|
140 |
+
</div>
|
141 |
+
|
142 |
+
<section class="section">
|
143 |
+
<h2>Sample Content</h2>
|
144 |
+
<div class="card-grid">
|
145 |
+
<div class="card">
|
146 |
+
<h3>Card 1</h3>
|
147 |
+
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
|
148 |
+
</div>
|
149 |
+
<div class="card">
|
150 |
+
<h3>Card 2</h3>
|
151 |
+
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
152 |
+
</div>
|
153 |
+
<div class="card">
|
154 |
+
<h3>Card 3</h3>
|
155 |
+
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
|
156 |
+
</div>
|
157 |
+
</div>
|
158 |
+
</section>
|
159 |
+
|
160 |
+
<div class="spacer">
|
161 |
+
Keep scrolling to test the chat bot's fixed position...
|
162 |
+
</div>
|
163 |
+
|
164 |
+
<section class="section">
|
165 |
+
<h2>More Content</h2>
|
166 |
+
<div class="card-grid">
|
167 |
+
<div class="card">
|
168 |
+
<h3>Card 4</h3>
|
169 |
+
<p>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
170 |
+
</div>
|
171 |
+
<div class="card">
|
172 |
+
<h3>Card 5</h3>
|
173 |
+
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
174 |
+
</div>
|
175 |
+
<div class="card">
|
176 |
+
<h3>Card 6</h3>
|
177 |
+
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur.</p>
|
178 |
+
</div>
|
179 |
+
</div>
|
180 |
+
</section>
|
181 |
+
|
182 |
+
<div class="spacer">
|
183 |
+
The chat bot should remain visible at all times...
|
184 |
+
</div>
|
185 |
+
|
186 |
+
<section class="section">
|
187 |
+
<h2>Final Section</h2>
|
188 |
+
<div class="card-grid">
|
189 |
+
<div class="card">
|
190 |
+
<h3>Card 7</h3>
|
191 |
+
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum.</p>
|
192 |
+
</div>
|
193 |
+
<div class="card">
|
194 |
+
<h3>Card 8</h3>
|
195 |
+
<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod.</p>
|
196 |
+
</div>
|
197 |
+
<div class="card">
|
198 |
+
<h3>Card 9</h3>
|
199 |
+
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates.</p>
|
200 |
+
</div>
|
201 |
+
</div>
|
202 |
+
</section>
|
203 |
+
</main>
|
204 |
+
|
205 |
+
<footer class="footer">
|
206 |
+
<p>Test Page Footer</p>
|
207 |
+
</footer>
|
208 |
+
|
209 |
+
<!-- Embed script -->
|
210 |
+
<script>
|
211 |
+
// Debug logging
|
212 |
+
console.log('Setting up PlayGo Chat Widget');
|
213 |
+
|
214 |
+
// Create queue before loading script
|
215 |
+
window.PlayGoChatWidget = {
|
216 |
+
q: [],
|
217 |
+
ready: false
|
218 |
+
};
|
219 |
+
|
220 |
+
// Initialize configuration
|
221 |
+
const config = {
|
222 |
+
buttonText: '需要協助嗎?',
|
223 |
+
theme: 'system',
|
224 |
+
primaryColor: '#FF6B6B',
|
225 |
+
placeholder: 'Type your message...'
|
226 |
+
};
|
227 |
+
|
228 |
+
// Add custom CSS for chat widget spacing
|
229 |
+
const style = document.createElement('style');
|
230 |
+
style.textContent = `
|
231 |
+
#playgo-chat-widget {
|
232 |
+
padding: 16px !important;
|
233 |
+
border-radius: 16px !important;
|
234 |
+
background: transparent !important;
|
235 |
+
}
|
236 |
+
|
237 |
+
#playgo-chat-widget iframe {
|
238 |
+
border-radius: 16px !important;
|
239 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.08) !important;
|
240 |
+
transition: height 0.5s ease-in-out, box-shadow 0.3s ease-in-out !important;
|
241 |
+
}
|
242 |
+
|
243 |
+
#playgo-chat-widget iframe:hover {
|
244 |
+
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.08) !important;
|
245 |
+
}
|
246 |
+
|
247 |
+
@media (prefers-color-scheme: dark) {
|
248 |
+
#playgo-chat-widget iframe {
|
249 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2) !important;
|
250 |
+
}
|
251 |
+
|
252 |
+
#playgo-chat-widget iframe:hover {
|
253 |
+
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.2) !important;
|
254 |
+
}
|
255 |
+
}
|
256 |
+
`;
|
257 |
+
document.head.appendChild(style);
|
258 |
+
|
259 |
+
// Add to queue
|
260 |
+
window.PlayGoChatWidget.q.push(['init', config]);
|
261 |
+
|
262 |
+
// Load script
|
263 |
+
(function() {
|
264 |
+
console.log('Loading embed script');
|
265 |
+
const script = document.createElement('script');
|
266 |
+
script.src = '/api/embed';
|
267 |
+
script.async = true;
|
268 |
+
script.onload = function() {
|
269 |
+
console.log('Embed script loaded');
|
270 |
+
};
|
271 |
+
script.onerror = function(error) {
|
272 |
+
console.error('Error loading embed script:', error);
|
273 |
+
};
|
274 |
+
document.body.appendChild(script);
|
275 |
+
})();
|
276 |
+
</script>
|
277 |
+
</body>
|
278 |
+
</html>
|
test-server.js
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const express = require('express');
|
2 |
+
const app = express();
|
3 |
+
const port = 3001;
|
4 |
+
|
5 |
+
app.get('/', (req, res) => {
|
6 |
+
res.send(`
|
7 |
+
<!DOCTYPE html>
|
8 |
+
<html>
|
9 |
+
<head>
|
10 |
+
<title>Cross-Origin Test</title>
|
11 |
+
</head>
|
12 |
+
<body>
|
13 |
+
<h1>Cross-Origin Test Page</h1>
|
14 |
+
<div id="playgo-chat-container"></div>
|
15 |
+
<script src="http://localhost:3000/api/embed"></script>
|
16 |
+
<script>
|
17 |
+
playgo('init', {
|
18 |
+
containerId: 'playgo-chat-container',
|
19 |
+
width: '400px',
|
20 |
+
height: '600px'
|
21 |
+
});
|
22 |
+
</script>
|
23 |
+
</body>
|
24 |
+
</html>
|
25 |
+
`);
|
26 |
+
});
|
27 |
+
|
28 |
+
app.listen(port, () => {
|
29 |
+
console.log(`Test server running at http://localhost:${port}`);
|
30 |
+
});
|