From e5a3262091c1369631108bae2fe2d59d5e2d8e58 Mon Sep 17 00:00:00 2001 From: Mahen Date: Tue, 10 Feb 2026 07:55:38 +0700 Subject: [PATCH] feat: integrate word cloud get data endpoint --- src/app/api/review/sentiment-stats/route.ts | 1 - src/components/dashboards/WordCloud.tsx | 9 +-- src/components/dashboards/WordCloudItem.tsx | 22 +---- src/hooks/useWordCloud.ts | 90 ++++++++++++++++++++- src/types/index.ts | 51 ++++++++---- src/utils/datas.ts | 24 ++++++ 6 files changed, 154 insertions(+), 43 deletions(-) diff --git a/src/app/api/review/sentiment-stats/route.ts b/src/app/api/review/sentiment-stats/route.ts index e9d51a1..0bae249 100644 --- a/src/app/api/review/sentiment-stats/route.ts +++ b/src/app/api/review/sentiment-stats/route.ts @@ -1,4 +1,3 @@ -// app/api/reviews/sentiment-stats/route.ts import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; diff --git a/src/components/dashboards/WordCloud.tsx b/src/components/dashboards/WordCloud.tsx index 856015a..4c9efec 100644 --- a/src/components/dashboards/WordCloud.tsx +++ b/src/components/dashboards/WordCloud.tsx @@ -1,13 +1,10 @@ "use client"; -import { WordCloudProps } from "@/src/types"; -import WordCloudItem from "./WordCloudItem"; import { useWordCloud } from "@/src/hooks/useWordCloud"; +import WordCloudItem from "./WordCloudItem"; -export function WordCloud({ words }: WordCloudProps) { - const { mounted, maxValue, minValue, shuffledWords } = useWordCloud({ - words, - }); +export function WordCloud() { + const { mounted, maxValue, minValue, shuffledWords } = useWordCloud(); if (!mounted) { return ( diff --git a/src/components/dashboards/WordCloudItem.tsx b/src/components/dashboards/WordCloudItem.tsx index 08efd42..0f177dc 100644 --- a/src/components/dashboards/WordCloudItem.tsx +++ b/src/components/dashboards/WordCloudItem.tsx @@ -1,30 +1,14 @@ -"use client"; - import { cn } from "@/lib/utils"; import { WordCloudItemProps, WordItem } from "@/src/types"; +import { setWordCloud } from "@/src/utils/datas"; const WordCloudItem: React.FC = ({ word, index, - maxValue, minValue, + maxValue, }) => { - const getSize = (value: number) => { - if (maxValue === minValue) return 1.5; - const normalized = (value - minValue) / (maxValue - minValue); - return 0.75 + normalized * 1.5; - }; - - const getColor = (sentiment: WordItem["sentiment"]) => { - switch (sentiment) { - case "positive": - return "text-sentiment-positive hover:bg-sentiment-positive-light"; - case "negative": - return "text-sentiment-negative hover:bg-sentiment-negative-light"; - default: - return "text-sentiment-neutral hover:bg-sentiment-neutral-light"; - } - }; + const { getSize, getColor } = setWordCloud({ minValue, maxValue }); return ( { +export const useWordCloud = () => { const [mounted, setMounted] = useState(false); + const [words, setWords] = useState([]); useEffect(() => { setMounted(true); + + const fetchWords = async () => { + try { + const res = await fetch("/api/review"); + const result: unknown = await res.json(); + + if ( + typeof result !== "object" || + result === null || + !("data" in result) + ) { + console.error("Invalid response from /api/review"); + return; + } + + const reviewsData = (result as ReviewResponse).data || []; + const reviews: Review[] = reviewsData; + + const keywordMap: Record = reviews.reduce( + (acc, review) => { + const sentiment: Sentiment = [ + "positive", + "negative", + "neutral", + ].includes(review.sentiment) + ? review.sentiment + : "neutral"; + + (review.keywords || []).forEach((keyword) => { + const key = keyword.toLowerCase(); + + if (!acc[key]) { + acc[key] = { count: 0, positive: 0, negative: 0, neutral: 0 }; + } + + acc[key].count += 1; + acc[key][sentiment] += 1; + }); + + return acc; + }, + {} as Record, + ); + + const wordItems: WordItem[] = Object.entries(keywordMap) + .map(([text, data]) => { + let sentiment: Sentiment = "neutral"; + if ( + data.positive >= data.negative && + data.positive >= data.neutral + ) { + sentiment = "positive"; + } else if ( + data.negative >= data.positive && + data.negative >= data.neutral + ) { + sentiment = "negative"; + } + + return { text, value: data.count, sentiment }; + }) + .sort((a, b) => b.value - a.value) + .slice(0, WORD_LIMIT); + + setWords(wordItems); + } catch (error) { + console.error("Failed to fetch wordcloud data", error); + } + }; + + fetchWords(); }, []); const maxValue = Math.max(...words.map((w) => w.value), 1); @@ -15,5 +94,10 @@ export const useWordCloud = ({ words }: WordCloudProps) => { return [...words].sort(() => Math.random() - 0.5); }, [words]); - return { mounted, maxValue, minValue, shuffledWords }; + return { + mounted, + maxValue, + minValue, + shuffledWords, + }; }; diff --git a/src/types/index.ts b/src/types/index.ts index 283f73e..74021ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import { Sentiment, UserGender } from "@prisma/client"; +import { UserGender } from "@prisma/client"; import { LucideIcon } from "lucide-react"; export interface ModelDB { @@ -27,16 +27,17 @@ export interface BrandFilterProps { onSelect: (brand: string | null) => void; } -export interface Review { +export type Review = { id: number; - product: string; - brand: string; - review: string; - rating?: number | null; + createdAt: string; sentiment: Sentiment; - date: string; - confidence: number; -} + keywords: string[]; + content: string; + product: { + name: string; + brand: string; + }; +}; export interface ReviewTableProps { reviews: Review[]; @@ -94,11 +95,11 @@ export interface TrendChartTooltipProps { label?: string; } -export interface WordItem { - text: string; - value: number; - sentiment: "positive" | "negative" | "neutral"; -} +// export type WordItem = { +// text: string; +// value: number; +// sentiment: "positive" | "negative" | "neutral"; +// }; export interface WordCloudProps { words: WordItem[]; @@ -140,3 +141,25 @@ export interface ApiResponse { message: string; data: ReviewItem[]; } + +export type Sentiment = "positive" | "negative" | "neutral"; + +export type WordItem = { + text: string; + value: number; + sentiment: Sentiment; +}; + +export type KeywordStats = { + count: number; +} & Record; + +export type ReviewResponse = { + message: string; + data: Review[]; +}; + +export type WordCloudConfig = { + minValue: number; + maxValue: number; +}; diff --git a/src/utils/datas.ts b/src/utils/datas.ts index b3d2964..9379160 100644 --- a/src/utils/datas.ts +++ b/src/utils/datas.ts @@ -1,4 +1,5 @@ import { Frown, Meh, Smile } from "lucide-react"; +import { WordCloudConfig, WordCloudItemProps, WordItem } from "../types"; export const MODEL_OPTIONS = [ { @@ -49,3 +50,26 @@ export const getSentimentDisplay = (sentiment: string) => { }; } }; + +export const WORD_LIMIT = 20; + +export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => { + const getSize = (value: number) => { + if (maxValue === minValue) return 1.5; + const normalized = (value - minValue) / (maxValue - minValue); + return 0.75 + normalized * 1.5; + }; + + const getColor = (sentiment: WordItem["sentiment"]) => { + switch (sentiment) { + case "positive": + return "text-sentiment-positive hover:bg-sentiment-positive-light"; + case "negative": + return "text-sentiment-negative hover:bg-sentiment-negative-light"; + default: + return "text-sentiment-neutral hover:bg-sentiment-neutral-light"; + } + }; + + return { getSize, getColor }; +};