refactor: overhaul UI for streamlined user experience
Redesign navigation, home overview, user portrait, and valuation pages with improved functionality and responsive design. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
79
app/scenarios/new/steps/BasicSettings.tsx
Normal file
79
app/scenarios/new/steps/BasicSettings.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function BasicSettings({ onNext }: { onNext: () => void }) {
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [category, setCategory] = useState("")
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 保存基本设置数据的逻辑
|
||||
onNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基本设置</CardTitle>
|
||||
<CardDescription>配置场景的基本信息</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">场景名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="请输入场景名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">场景类别</Label>
|
||||
<Select value={category} onValueChange={setCategory} required>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue placeholder="选择场景类别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="marketing">营销场景</SelectItem>
|
||||
<SelectItem value="service">服务场景</SelectItem>
|
||||
<SelectItem value="operation">运营场景</SelectItem>
|
||||
<SelectItem value="sales">销售场景</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">场景描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="请输入场景描述"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" type="button">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">下一步</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
98
app/scenarios/new/steps/FriendRequestSettings.tsx
Normal file
98
app/scenarios/new/steps/FriendRequestSettings.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function FriendRequestSettings({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
const [enableFriendRequests, setEnableFriendRequests] = useState(false)
|
||||
const [requestMessage, setRequestMessage] = useState("")
|
||||
const [requestLimit, setRequestLimit] = useState("10")
|
||||
const [requestInterval, setRequestInterval] = useState("daily")
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 保存好友请求设置数据的逻辑
|
||||
onNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>好友请求设置</CardTitle>
|
||||
<CardDescription>配置场景中的好友请求功能</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-friend-requests" className="flex flex-col space-y-1">
|
||||
<span>启用好友请求</span>
|
||||
<span className="font-normal text-sm text-muted-foreground">启用后将允许系统自动发送好友请求</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="enable-friend-requests"
|
||||
checked={enableFriendRequests}
|
||||
onCheckedChange={setEnableFriendRequests}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enableFriendRequests && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="request-message">请求消息</Label>
|
||||
<Textarea
|
||||
id="request-message"
|
||||
value={requestMessage}
|
||||
onChange={(e) => setRequestMessage(e.target.value)}
|
||||
placeholder="请输入好友请求附带的消息"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="request-limit">请求限制</Label>
|
||||
<Input
|
||||
id="request-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
value={requestLimit}
|
||||
onChange={(e) => setRequestLimit(e.target.value)}
|
||||
placeholder="请求数量上限"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="request-interval">请求频率</Label>
|
||||
<Select value={requestInterval} onValueChange={setRequestInterval}>
|
||||
<SelectTrigger id="request-interval">
|
||||
<SelectValue placeholder="选择请求频率" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
<SelectItem value="weekly">每周</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" type="button" onClick={onBack}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button type="submit">下一步</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
128
app/scenarios/new/steps/MessageSettings.tsx
Normal file
128
app/scenarios/new/steps/MessageSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export function MessageSettings({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
const [enableMessages, setEnableMessages] = useState(false)
|
||||
const [messageType, setMessageType] = useState("text")
|
||||
const [messageContent, setMessageContent] = useState("")
|
||||
const [messageSchedule, setMessageSchedule] = useState("immediate")
|
||||
const [customTime, setCustomTime] = useState("")
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 保存消息设置数据的逻辑
|
||||
onNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>消息设置</CardTitle>
|
||||
<CardDescription>配置场景中的消息发送功能</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-messages" className="flex flex-col space-y-1">
|
||||
<span>启用消息发送</span>
|
||||
<span className="font-normal text-sm text-muted-foreground">启用后将允许系统自动发送消息</span>
|
||||
</Label>
|
||||
<Switch id="enable-messages" checked={enableMessages} onCheckedChange={setEnableMessages} />
|
||||
</div>
|
||||
|
||||
{enableMessages && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>消息类型</Label>
|
||||
<Tabs value={messageType} onValueChange={setMessageType}>
|
||||
<TabsList className="grid grid-cols-3">
|
||||
<TabsTrigger value="text">文本消息</TabsTrigger>
|
||||
<TabsTrigger value="image">图片消息</TabsTrigger>
|
||||
<TabsTrigger value="link">链接消息</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="text" className="mt-4">
|
||||
<Textarea
|
||||
value={messageContent}
|
||||
onChange={(e) => setMessageContent(e.target.value)}
|
||||
placeholder="请输入文本消息内容"
|
||||
rows={4}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="image" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<Input type="file" accept="image/*" />
|
||||
<Textarea
|
||||
value={messageContent}
|
||||
onChange={(e) => setMessageContent(e.target.value)}
|
||||
placeholder="请输入图片描述(可选)"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="link" className="mt-4 space-y-4">
|
||||
<Input
|
||||
value={messageContent}
|
||||
onChange={(e) => setMessageContent(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
/>
|
||||
<Textarea placeholder="请输入链接描述(可选)" rows={2} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label>发送时间</Label>
|
||||
<Select value={messageSchedule} onValueChange={setMessageSchedule}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择发送时间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">立即发送</SelectItem>
|
||||
<SelectItem value="scheduled">定时发送</SelectItem>
|
||||
<SelectItem value="triggered">触发发送</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{messageSchedule === "scheduled" && (
|
||||
<div className="pt-2">
|
||||
<Label htmlFor="custom-time">选择发送时间</Label>
|
||||
<Input
|
||||
id="custom-time"
|
||||
type="datetime-local"
|
||||
value={customTime}
|
||||
onChange={(e) => setCustomTime(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messageSchedule === "triggered" && (
|
||||
<div className="border rounded-md p-4 mt-2">
|
||||
<p className="text-sm text-muted-foreground">触发条件将在下一步配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" type="button" onClick={onBack}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button type="submit">下一步</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
478
app/scenarios/new/steps/PosterEditor.tsx
Normal file
478
app/scenarios/new/steps/PosterEditor.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Card, CardFooter } from "@/components/ui/card"
|
||||
import {
|
||||
Upload,
|
||||
PenTool,
|
||||
Type,
|
||||
QrCode,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Tablet,
|
||||
Save,
|
||||
Share2,
|
||||
Eye,
|
||||
} from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_TEMPLATES = [
|
||||
{
|
||||
id: "register",
|
||||
name: "点击报名",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8E%B6%E3%83%A5%E6%82%95-vJDCYhJ9ENr8jN3YGP9jVeQ5Ub3czl.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "claim",
|
||||
name: "点击领取",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%A3%B0%E5%97%97%E5%BD%871-cskUmYR6oO0n4uHdZVeB4naKUSUilb.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "consult",
|
||||
name: "点击咨询",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8D%9C%E3%84%A8%EE%87%97-OUtJxwRbr4ydYRjt8FLOCMELC16Vw6.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "checkin",
|
||||
name: "点击签到",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E7%BB%9B%E6%83%A7%E5%9F%8C-bYcTocSdNrcykfBXmt51q6D4Yzh26h.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "cooperation",
|
||||
name: "点击合作",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E9%8D%9A%E5%A0%9C%E7%B6%94-kisPT3kV9A0aB7YpxO6AHUZ8aHvFLT.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "learn",
|
||||
name: "点击了解",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%B5%9C%E5%97%9A%D0%92-iE654sFFuO1PuvwmccV67yVLQZoLcx.gif",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "claim_static",
|
||||
name: "点击领取(静态)",
|
||||
src: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%80%9B%E6%A8%BA%EE%85%B9%E7%80%B9%E6%BF%87%E6%8D%A3%E9%8E%B6_%E9%90%90%E7%91%B0%E5%9A%AE%E6%A3%B0%E5%97%97%E5%BD%87-jO6FPRCCzz6Irkm5suKeNkUDd98Y0f.png",
|
||||
color: "#d32121",
|
||||
textColor: "#ffffff",
|
||||
},
|
||||
]
|
||||
|
||||
export function PosterEditor({
|
||||
onChange,
|
||||
initialValue = null,
|
||||
}: {
|
||||
onChange: (value: any) => void
|
||||
initialValue?: any
|
||||
}) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(initialValue?.template || DEFAULT_TEMPLATES[0])
|
||||
const [customText, setCustomText] = useState(initialValue?.customText || selectedTemplate.name)
|
||||
const [mainColor, setMainColor] = useState(initialValue?.mainColor || selectedTemplate.color)
|
||||
const [textColor, setTextColor] = useState(initialValue?.textColor || selectedTemplate.textColor)
|
||||
const [hasQrCode, setHasQrCode] = useState(initialValue?.hasQrCode || false)
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState(initialValue?.qrCodeUrl || "")
|
||||
const [offerText, setOfferText] = useState(initialValue?.offerText || "")
|
||||
const [previewDevice, setPreviewDevice] = useState("mobile")
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 当任何相关状态变化时更新父组件
|
||||
useEffect(() => {
|
||||
onChange({
|
||||
template: selectedTemplate,
|
||||
customText,
|
||||
mainColor,
|
||||
textColor,
|
||||
hasQrCode,
|
||||
qrCodeUrl,
|
||||
offerText,
|
||||
})
|
||||
}, [selectedTemplate, customText, mainColor, textColor, hasQrCode, qrCodeUrl, offerText, onChange])
|
||||
|
||||
// 预览海报的渲染
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 获取原始海报图像
|
||||
const img = new Image()
|
||||
img.crossOrigin = "anonymous"
|
||||
img.src = selectedTemplate.src
|
||||
|
||||
img.onload = () => {
|
||||
// 绘制原始海报
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 自定义文本
|
||||
if (customText !== selectedTemplate.name) {
|
||||
// 首先绘制一个半透明背景,盖住原文字
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.85)"
|
||||
ctx.fillRect(20, 30, canvas.width - 40, 150)
|
||||
|
||||
// 绘制自定义文字
|
||||
ctx.fillStyle = mainColor
|
||||
ctx.font = "bold 42px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillText(customText, canvas.width / 2, 100)
|
||||
}
|
||||
|
||||
// 如果添加了二维码
|
||||
if (hasQrCode) {
|
||||
// 添加一个二维码占位背景
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.fillRect(canvas.width - 120, canvas.height - 120, 100, 100)
|
||||
ctx.strokeStyle = "#dddddd"
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(canvas.width - 120, canvas.height - 120, 100, 100)
|
||||
|
||||
// 添加二维码图标占位
|
||||
ctx.fillStyle = "#888888"
|
||||
ctx.fillRect(canvas.width - 100, canvas.height - 100, 60, 60)
|
||||
|
||||
// 添加"扫码获取"文本
|
||||
ctx.fillStyle = "#333333"
|
||||
ctx.font = "14px sans-serif"
|
||||
ctx.textAlign = "center"
|
||||
ctx.fillText("扫码获取", canvas.width - 70, canvas.height - 20)
|
||||
}
|
||||
|
||||
// 如果添加了优惠文本
|
||||
if (offerText) {
|
||||
// 添加一个醒目的优惠信息标签
|
||||
ctx.fillStyle = "#ffeb3b"
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, canvas.height - 200)
|
||||
ctx.lineTo(200, canvas.height - 200)
|
||||
ctx.lineTo(170, canvas.height - 150)
|
||||
ctx.lineTo(0, canvas.height - 150)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// 添加优惠文本
|
||||
ctx.fillStyle = "#d32121"
|
||||
ctx.font = "bold 18px sans-serif"
|
||||
ctx.textAlign = "left"
|
||||
ctx.fillText(offerText, 15, canvas.height - 175)
|
||||
}
|
||||
}
|
||||
}, [selectedTemplate, customText, mainColor, hasQrCode, offerText])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-lg font-medium">海报编辑器</h2>
|
||||
|
||||
<Tabs defaultValue="design" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="design" className="flex items-center gap-1">
|
||||
<PenTool className="h-4 w-4" />
|
||||
设计
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="content" className="flex items-center gap-1">
|
||||
<Type className="h-4 w-4" />
|
||||
内容
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="interactive" className="flex items-center gap-1">
|
||||
<QrCode className="h-4 w-4" />
|
||||
互动
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-1">
|
||||
<Eye className="h-4 w-4" />
|
||||
预览
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="design" className="space-y-4 pt-4">
|
||||
<div>
|
||||
<Label>选择模板</Label>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<div className="w-24 h-40 bg-gray-100 rounded relative overflow-hidden">
|
||||
{selectedTemplate && (
|
||||
<img
|
||||
src={selectedTemplate.src || "/placeholder.svg"}
|
||||
alt={selectedTemplate.name}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{selectedTemplate?.name || "请选择模板"}</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">点击右侧按钮选择其他模板</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="mt-2">
|
||||
更换模板
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择海报模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto p-2">
|
||||
{DEFAULT_TEMPLATES.map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={cn(
|
||||
"overflow-hidden cursor-pointer transition-all transform hover:scale-105",
|
||||
selectedTemplate.id === template.id && "ring-2 ring-blue-500",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template)
|
||||
setCustomText(template.name)
|
||||
setMainColor(template.color)
|
||||
setTextColor(template.textColor)
|
||||
setIsTemplateDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[9/16] bg-gray-100 relative">
|
||||
<img
|
||||
src={template.src || "/placeholder.svg"}
|
||||
alt={template.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardFooter className="p-2">
|
||||
<p className="text-sm text-center w-full">{template.name}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="overflow-hidden cursor-pointer transition-all transform hover:scale-105">
|
||||
<div className="aspect-[9/16] bg-gray-100 flex flex-col items-center justify-center text-gray-500">
|
||||
<Upload className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">上传自定义模板</p>
|
||||
</div>
|
||||
<CardFooter className="p-2">
|
||||
<p className="text-sm text-center w-full">自定义上传</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="mainColor">主色调</Label>
|
||||
<div className="flex mt-2">
|
||||
<div className="w-10 h-8 rounded-l border" style={{ backgroundColor: mainColor }}></div>
|
||||
<Input
|
||||
id="mainColor"
|
||||
type="text"
|
||||
value={mainColor}
|
||||
onChange={(e) => setMainColor(e.target.value)}
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="textColor">文字颜色</Label>
|
||||
<div className="flex mt-2">
|
||||
<div className="w-10 h-8 rounded-l border" style={{ backgroundColor: textColor }}></div>
|
||||
<Input
|
||||
id="textColor"
|
||||
type="text"
|
||||
value={textColor}
|
||||
onChange={(e) => setTextColor(e.target.value)}
|
||||
className="rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" size="sm">
|
||||
重置样式
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
保存为模板
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="content" className="space-y-4 pt-4">
|
||||
<div>
|
||||
<Label htmlFor="customText">海报文字</Label>
|
||||
<Input
|
||||
id="customText"
|
||||
value={customText}
|
||||
onChange={(e) => setCustomText(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="offerText">优惠信息(选填)</Label>
|
||||
<Input
|
||||
id="offerText"
|
||||
value={offerText}
|
||||
onChange={(e) => setOfferText(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="例如: 限时8.5折 | 新人专享"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">添加醒目的优惠信息,提升用户点击欲望</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>推荐措辞</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{["限时特惠", "新人专享", "首单立减", "买一送一", "折扣优惠", "免费领取"].map((tag) => (
|
||||
<Button key={tag} variant="outline" size="sm" onClick={() => setOfferText(tag)}>
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="interactive" className="space-y-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="qrcode-switch" className="cursor-pointer">
|
||||
添加二维码
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mt-1">添加二维码提高用户转化率</p>
|
||||
</div>
|
||||
<Switch id="qrcode-switch" checked={hasQrCode} onCheckedChange={setHasQrCode} />
|
||||
</div>
|
||||
|
||||
{hasQrCode && (
|
||||
<div>
|
||||
<Label htmlFor="qrcode-url">二维码链接(选填)</Label>
|
||||
<Input
|
||||
id="qrcode-url"
|
||||
value={qrCodeUrl}
|
||||
onChange={(e) => setQrCodeUrl(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder="输入链接或小程序路径"
|
||||
/>
|
||||
<div className="mt-3 flex justify-between items-center">
|
||||
<p className="text-sm">上传自定义二维码</p>
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="font-medium mb-2">点击行为</h4>
|
||||
<Select defaultValue="form">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择点击行为" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="form">填写表单</SelectItem>
|
||||
<SelectItem value="qrcode">扫码添加</SelectItem>
|
||||
<SelectItem value="call">拨打电话</SelectItem>
|
||||
<SelectItem value="link">访问链接</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500 mt-2">设置用户点击海报后的行为</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-4 pt-4">
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant={previewDevice === "mobile" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("mobile")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 mr-1" />
|
||||
手机
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewDevice === "tablet" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("tablet")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Tablet className="h-4 w-4 mr-1" />
|
||||
平板
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewDevice === "desktop" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPreviewDevice("desktop")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Monitor className="h-4 w-4 mr-1" />
|
||||
电脑
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gray-100 border flex items-center justify-center p-2",
|
||||
previewDevice === "mobile" && "w-[320px] h-[568px]",
|
||||
previewDevice === "tablet" && "w-[480px] h-[640px]",
|
||||
previewDevice === "desktop" && "w-[640px] h-[480px] max-w-full",
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={300}
|
||||
height={534}
|
||||
className={cn("w-full h-full object-contain", previewDevice === "desktop" && "max-h-[90%]")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
保存
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex items-center">
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
分享
|
||||
</Button>
|
||||
<Button className="flex items-center">
|
||||
确认使用
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
app/scenarios/new/steps/TagSettings.tsx
Normal file
104
app/scenarios/new/steps/TagSettings.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export function TagSettings({ onBack, onComplete }: { onBack: () => void; onComplete: () => void }) {
|
||||
const [enableTags, setEnableTags] = useState(false)
|
||||
const [tagInput, setTagInput] = useState("")
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !tags.includes(tagInput.trim())) {
|
||||
setTags([...tags, tagInput.trim()])
|
||||
setTagInput("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddTag()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((tag) => tag !== tagToRemove))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 保存标签设置数据的逻辑
|
||||
onComplete()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>标签设置</CardTitle>
|
||||
<CardDescription>配置场景的用户标签</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-tags" className="flex flex-col space-y-1">
|
||||
<span>启用用户标签</span>
|
||||
<span className="font-normal text-sm text-muted-foreground">启用后将为场景中的用户添加标签</span>
|
||||
</Label>
|
||||
<Switch id="enable-tags" checked={enableTags} onCheckedChange={setEnableTags} />
|
||||
</div>
|
||||
|
||||
{enableTags && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tag-input">添加标签</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="tag-input"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入标签名称并按回车"
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>已添加标签</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => (
|
||||
<Badge key={tag} className="px-3 py-1 flex items-center gap-1">
|
||||
{tag}
|
||||
<X className="h-3 w-3 cursor-pointer" onClick={() => handleRemoveTag(tag)} />
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无标签</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="outline" type="button" onClick={onBack}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button type="submit">完成</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
209
app/scenarios/new/steps/TrafficChannelSettings
Normal file
209
app/scenarios/new/steps/TrafficChannelSettings
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, Pencil, Trash2, Link2 } from "lucide-react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface Channel {
|
||||
id: string
|
||||
name: string
|
||||
type: "team" | "other"
|
||||
link?: string
|
||||
}
|
||||
|
||||
interface TrafficChannelSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}
|
||||
|
||||
function isValidUrl(string: string) {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function TrafficChannelSettings({ formData, onChange, onNext, onPrev }: TrafficChannelSettingsProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>(formData.channels || [])
|
||||
const [isAddChannelOpen, setIsAddChannelOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<Channel | null>(null)
|
||||
const [newChannel, setNewChannel] = useState<Partial<Channel>>({
|
||||
name: "",
|
||||
type: "team",
|
||||
link: "",
|
||||
})
|
||||
|
||||
const handleAddChannel = () => {
|
||||
if (!newChannel.name) return
|
||||
if (newChannel.link && !isValidUrl(newChannel.link)) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入有效的URL",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (editingChannel) {
|
||||
setChannels(
|
||||
channels.map((channel) => (channel.id === editingChannel.id ? { ...channel, ...newChannel } : channel)),
|
||||
)
|
||||
} else {
|
||||
setChannels([
|
||||
...channels,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newChannel.name,
|
||||
type: newChannel.type || "team",
|
||||
link: newChannel.link,
|
||||
} as Channel,
|
||||
])
|
||||
}
|
||||
|
||||
setIsAddChannelOpen(false)
|
||||
setNewChannel({ name: "", type: "team", link: "" })
|
||||
setEditingChannel(null)
|
||||
onChange({ ...formData, channels })
|
||||
}
|
||||
|
||||
const handleEditChannel = (channel: Channel) => {
|
||||
setEditingChannel(channel)
|
||||
setNewChannel(channel)
|
||||
setIsAddChannelOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteChannel = (channelId: string) => {
|
||||
setChannels(channels.filter((channel) => channel.id !== channelId))
|
||||
onChange({ ...formData, channels: channels.filter((channel) => channel.id !== channelId) })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">流量通道设置</h2>
|
||||
<Button onClick={() => setIsAddChannelOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加通道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>通道名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>链接</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.name}</TableCell>
|
||||
<TableCell>{channel.type === "team" ? "打粉团队" : "其他"}</TableCell>
|
||||
<TableCell>
|
||||
{channel.link && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="text-blue-600">{channel.link}</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditChannel(channel)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteChannel(channel.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>完成</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddChannelOpen} onOpenChange={setIsAddChannelOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑通道" : "添加通道"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>通道名称</Label>
|
||||
<Input
|
||||
value={newChannel.name}
|
||||
onChange={(e) => setNewChannel({ ...newChannel, name: e.target.value })}
|
||||
placeholder="请输入通道名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>通道类型</Label>
|
||||
<Select
|
||||
value={newChannel.type}
|
||||
onValueChange={(value) => setNewChannel({ ...newChannel, type: value as "team" | "other" })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team">打粉团队</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>通道链接(选填)</Label>
|
||||
<Input
|
||||
value={newChannel.link}
|
||||
onChange={(e) => setNewChannel({ ...newChannel, link: e.target.value })}
|
||||
placeholder="请输入通道链接"
|
||||
/>
|
||||
{newChannel.link && !isValidUrl(newChannel.link) && (
|
||||
<p className="text-sm text-red-500">请输入有效的URL</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddChannelOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddChannel}>{editingChannel ? "保存" : "添加"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user