ChenyuRabbitLove commited on
Commit
c4412d0
·
1 Parent(s): 2b235a9

feat: add embeddedable chat component

Browse files
app/(audiences)/components/chat-bot-card.tsx CHANGED
@@ -1,25 +1,40 @@
1
- import { Card, CardContent } from "@/components/ui/card";
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 WebP and PNG fallback */}
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
- {/* Content */}
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
- <div className="h-6 w-24 rounded-full bg-gray-200" />
 
 
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 the passed-in functions
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
- {/* Search and Filter Section */}
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
- {/* Chatbots Grid */}
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
- {/* Search and Filter Section */}
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
- {/* Chatbots Grid */}
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">{children}</main>
 
 
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
+ });