247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Loader2,
|
||
Users,
|
||
Plus,
|
||
X,
|
||
Check,
|
||
ArrowRight,
|
||
ArrowLeft,
|
||
Lightbulb,
|
||
} from "lucide-react";
|
||
import { api } from "@/lib/api";
|
||
import type { CompetitorRecommendation } from "@/types/onboarding";
|
||
|
||
interface Step2CompetitorsProps {
|
||
brandName: string;
|
||
initialCompetitors?: string[];
|
||
onNext: (competitors: string[]) => void;
|
||
onBack: () => void;
|
||
onSkip: () => void;
|
||
}
|
||
|
||
export function Step2Competitors({
|
||
brandName,
|
||
initialCompetitors = [],
|
||
onNext,
|
||
onBack,
|
||
onSkip,
|
||
}: Step2CompetitorsProps) {
|
||
const { data: session } = useSession();
|
||
const [recommendations, setRecommendations] = useState<
|
||
CompetitorRecommendation[]
|
||
>([]);
|
||
const [selectedCompetitors, setSelectedCompetitors] =
|
||
useState<string[]>(initialCompetitors);
|
||
const [customCompetitor, setCustomCompetitor] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const fetchRecommendations = async () => {
|
||
if (!session?.accessToken) return;
|
||
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const data = (await api.onboarding.getCompetitorRecommendations(
|
||
session.accessToken,
|
||
brandName,
|
||
)) as CompetitorRecommendation[];
|
||
setRecommendations(data || []);
|
||
} catch (err) {
|
||
setError("获取竞品推荐失败,请重试");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchRecommendations();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [session?.accessToken, brandName]);
|
||
|
||
const toggleCompetitor = (competitorName: string) => {
|
||
setSelectedCompetitors((prev) =>
|
||
prev.includes(competitorName)
|
||
? prev.filter((c) => c !== competitorName)
|
||
: [...prev, competitorName],
|
||
);
|
||
};
|
||
|
||
const handleAddCustomCompetitor = () => {
|
||
const trimmed = customCompetitor.trim();
|
||
if (trimmed && !selectedCompetitors.includes(trimmed)) {
|
||
setSelectedCompetitors((prev) => [...prev, trimmed]);
|
||
setCustomCompetitor("");
|
||
}
|
||
};
|
||
|
||
const handleRemoveCompetitor = (competitorName: string) => {
|
||
setSelectedCompetitors((prev) => prev.filter((c) => c !== competitorName));
|
||
};
|
||
|
||
const handleNext = () => {
|
||
onNext(selectedCompetitors);
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-8">
|
||
<div className="mb-6 text-center">
|
||
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
||
<Users className="h-8 w-8 text-blue-600" />
|
||
</div>
|
||
<h2 className="mb-2 text-2xl font-bold">确认竞品</h2>
|
||
<p className="text-muted-foreground">
|
||
我们为您推荐了与 “{brandName}” 相关的竞品,
|
||
您可以选择添加或跳过
|
||
</p>
|
||
</div>
|
||
|
||
<Card className="w-full max-w-2xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="flex items-center gap-2 text-lg">
|
||
<Lightbulb className="h-5 w-5 text-amber-500" />
|
||
系统推荐竞品
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||
</div>
|
||
) : error ? (
|
||
<div className="text-center py-8">
|
||
<p className="text-muted-foreground mb-4">{error}</p>
|
||
<div className="flex gap-3 justify-center">
|
||
<Button variant="outline" onClick={fetchRecommendations}>重新加载</Button>
|
||
<Button variant="ghost" onClick={onSkip}>跳过此步骤</Button>
|
||
</div>
|
||
</div>
|
||
) : recommendations.length > 0 ? (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{recommendations.map((comp) => {
|
||
const isSelected = selectedCompetitors.includes(comp.name);
|
||
return (
|
||
<button
|
||
key={comp.id}
|
||
type="button"
|
||
onClick={() => toggleCompetitor(comp.name)}
|
||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-all hover:border-primary/50 ${
|
||
isSelected
|
||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||
: "border-border bg-card"
|
||
}`}
|
||
>
|
||
<div
|
||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border ${
|
||
isSelected
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-muted-foreground/30"
|
||
}`}
|
||
>
|
||
{isSelected && <Check className="h-3 w-3" />}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium truncate">{comp.name}</p>
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
{comp.reason}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-center text-muted-foreground py-4">
|
||
暂未找到推荐竞品,您可以手动添加
|
||
</p>
|
||
)}
|
||
|
||
{/* 已选择的竞品 */}
|
||
{selectedCompetitors.length > 0 && (
|
||
<div className="border-t pt-4">
|
||
<Label className="text-sm text-muted-foreground mb-2 block">
|
||
已选择 ({selectedCompetitors.length})
|
||
</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedCompetitors.map((comp) => (
|
||
<Badge key={comp} variant="secondary" className="gap-1 pr-1">
|
||
{comp}
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveCompetitor(comp)}
|
||
className="ml-1 rounded-full hover:bg-muted p-0.5"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 手动添加竞品 */}
|
||
<div className="border-t pt-4">
|
||
<Label className="text-sm mb-2 block">手动添加竞品</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={customCompetitor}
|
||
onChange={(e) => setCustomCompetitor(e.target.value)}
|
||
placeholder="输入竞品名称"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
handleAddCustomCompetitor();
|
||
}
|
||
}}
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={handleAddCustomCompetitor}
|
||
disabled={!customCompetitor.trim()}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={onBack}
|
||
className="flex-1"
|
||
>
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
上一步
|
||
</Button>
|
||
<Button type="button" onClick={handleNext} className="flex-1">
|
||
继续
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<Button type="button" variant="ghost" onClick={onSkip} className="mt-2">
|
||
跳过此步骤
|
||
</Button>
|
||
|
||
<p className="mt-4 text-xs text-muted-foreground">
|
||
您可以随时在品牌详情页添加或修改竞品
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|