产品和提取页面

This commit is contained in:
2025-03-14 12:40:51 +08:00
parent e1ca9bfff0
commit c0f367954d
30 changed files with 2449 additions and 280 deletions

View File

@@ -1,227 +0,0 @@
import {ReactNode} from 'react'
import Link from 'next/link'
import Header from '@/app/(home)/header'
import Wrap from '@/components/wrap'
import Image from 'next/image'
import Footer from './footer'
export default function Home() {
return (
<div className={`overflow-auto flex flex-col items-stretch relative`}>
{/* 页头 */}
<Header/>
{/* 正文 */}
<main className={`flex flex-col gap-16 lg:gap-32 mb-16 lg:mb-32`}>
{/* banner */}
<section className={`w-full bg-[url('/banner.webp')] bg-cover bg-[center_right_40%]`}>
<Wrap className={`pt-64 pb-48 max-md:pt-32 max-md:pb-24`}>
<h1 className={`text-4xl font-medium`}></h1>
<p className={`mt-10 text-gray-500`}>IP代理服务</p>
<div className={`mt-24 max-md:mt-14 flex gap-8 max-md:flex-col`}>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>200+</span>
</p>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>300+</span>
</p>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>&</span>
</p>
</div>
<button
className={[
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`,
`bg-gradient-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl font-medium`,
].join(' ')}>
</button>
</Wrap>
</section>
{/* 数据展示 */}
<Section title={`覆盖全国的IP资源及超大的带宽线路`}>
<ul className={`shadow-[0_0_20px_4px] shadow-blue-50 p-8 flex max-lg:flex-col`}>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}>线</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>350+</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}>IP数量</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>1,350,129</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}></p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>26,578</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center`}>
<p className={`text-xl`}>IP可用率</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>99%</p>
</li>
</ul>
<img src={`/map.webp`} alt={`map`} className="w-[1200px]"/>
</Section>
{/* 优势 1 */}
<Section title={`HTTP安全合规的代理IP资源池`}>
<ul
className={[
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8`,
].join(' ')}>
<Sec3Item
icon={`s1-1`} title={`短期动态IP池`} terms={[
{icon: `s1-check`, text: `IP时效3-30分钟(可定制)`},
{icon: `s1-check`, text: `支持高并发提取`},
]}/>
<Sec3Item
icon={`s1-2`} title={`长期静态IP池`} terms={[
{icon: `s1-check`, text: `IP覆盖全国各地`},
{icon: `s1-check`, text: `平均响应时长0.03s`},
]}/>
<Sec3Item
icon={`s1-3`} title={`固定IP池`} terms={[
{icon: `s1-check`, text: `稳定长输不掉线`},
{icon: `s1-check`, text: `全国热门静态IP线路`},
]}/>
<Sec3Item
icon={`s1-4`} title={`企业级定制池`} terms={[
{icon: `s1-check`, text: `可视化监控设计`},
{icon: `s1-check`, text: `技术团队现场支持`},
]}/>
</ul>
</Section>
{/* 优势 2 */}
<Section title={`HTTP 产品优势`}>
<div className={`flex gap-36`}>
<ul className={`flex-1 flex flex-col gap-6`}>
<Sec4Item icon={`s4-1-1`} title={`安全合规`} description={`国内三大运营商支持`}/>
<Sec4Item icon={`s4-1-2`} title={`稳定链接`} description={`IP纯净度高达99.9%`}/>
<Sec4Item icon={`s4-1-3`} title={`超匿名性`} description={`稳定传输,保护隐私安全`}/>
</ul>
<img src={`/s4-1-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
</div>
<div className={`flex gap-36`}>
<img src={`/s4-2-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
<ul className={`flex-1 flex flex-col gap-6`}>
<Sec4Item icon={`s4-2-1`} title={`API接口文档`} description={`与第三方软件轻松集成`}/>
<Sec4Item icon={`s4-2-2`} title={`多种编程语言代码`} description={`C语言、GO语言、Python...`}/>
<Sec4Item icon={`s4-2-3`} title={`双重认证方式`} description={`API提取+账密认证`}/>
</ul>
</div>
</Section>
{/* 行业资讯 */}
<Section title={`行业资讯`}>
<div className={`flex gap-8 max-md:gap-4`}>
<button className={`px-4 max-md:-mx-4`}>
<img src={`/next.svg`} alt={`prev`} className={`rotate-180`}/>
</button>
<div
className={[
`shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`,
`flex p-14 md:gap-14 max-md:flex-col max-md:p-4`,
].join(' ')}>
<img src="/s3-main.webp" alt="tumb" className={`w-2/3 md:flex-1 md:w-0 object-cover max-md:self-center`}/>
<div className={`flex-2 flex flex-col justify-between gap-4`}>
<h3 className={`flex justify-between`}>
<span className={`text-xl font-medium`}></span>
<sub className={`text-sm text-gray-500`}>2025-03-04</sub>
</h3>
<p className={`text-gray-400 md:leading-12`}>
...
</p>
<div className={`flex justify-end`}>
<a href="#" className={`text-sm text-gray-500 flex items-center gap-4`}>
<img src={`/next.svg`} alt={`more`} className={`h-4 fill-gray-400`}/>
</a>
</div>
</div>
</div>
<button className={`px-4 max-md:-mx-4`}>
<img src={`/next.svg`} alt={`prev`}/>
</button>
</div>
</Section>
</main>
{/* 页脚 */}
<Footer/>
</div>
)
}
function Section(props: {
title: string
children: ReactNode
}) {
return (
<section>
<div className={`max-w-[1232px] mx-auto px-4 flex flex-col items-stretch`}>
<h2 className={`text-center text-3xl font-medium mb-8 lg:mb-24`}>{props.title}</h2>
{props.children}
</div>
</section>
)
}
function Sec3Item(props: {
icon: string,
title: string,
terms: {
icon: string,
text: string,
}[]
}) {
return (
<li
className={[
`p-8 flex flex-col gap-5 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white rounded-lg`,
`max-md:items-center`,
].join(' ')}>
<img src={`/${props.icon}.webp`} alt={`s1-1`} aria-hidden className="w-44 h-44 object-cover"/>
<h3 className={`text-xl font-medium`}>{props.title}</h3>
<div className={`flex flex-col gap-3`}>
{props.terms.map((item, index) => {
return (
<p key={index} className={`text-sm text-gray-500 flex gap-3 items-center`}>
<img src={`/${item.icon}.svg`} alt={`check`} aria-hidden className={`w-5 h-5`}/>
<span>{item.text}</span>
</p>
)
})}
</div>
</li>
)
}
function Sec4Item(props: {
icon: string
title: string
description: string
}) {
return (
<li className={`flex gap-8 items-center p-4 lg:p-8 shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`}>
<img src={`/${props.icon}.webp`} alt={`s2-1-1`} aria-hidden className="w-24 h-24 object-contain"/>
<div className={`flex flex-col gap-3`}>
<h3 className={`text-xl font-medium`}>{props.title}</h3>
<p>{props.description}</p>
</div>
</li>
)
}

View File

@@ -0,0 +1,311 @@
'use client'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
const schema = z.object({
type: z.enum([`num`, `time`]),
order: z.number(),
region: z.string(),
provider: z.string(),
proto: z.string(),
distinct: z.string(),
format: z.enum([`txt`, `json`]),
separator: z.string(),
count: z.number(),
})
type FormSectionProps = {}
export default function FormSection(props: FormSectionProps) {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
type: `num`,
order: 0,
region: ``,
provider: ``,
proto: ``,
distinct: ``,
format: `txt`,
separator: `,`,
count: 0,
},
})
const onSubmit = (values: z.infer<typeof schema>) => {
console.log(values)
// 在这里处理表单提交
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={`p-8 bg-white flex flex-col gap-4 rounded-lg`}>
<ul role={`tablist`} className={`p-2 w-fit flex gap-2 bg-gray-100 rounded-lg`}>
<li role={`tab`}>
<button type="button" className={`px-4 h-10 bg-white rounded-md shadow-sm`}>
IP提取
</button>
</li>
<li role={`tab`}>
<button type="button" className={`px-4 h-10 rounded-md`}>
IP提取
</button>
</li>
</ul>
<p className={`px-4 h-10 bg-orange-50 flex gap-3 items-center rounded-lg`}>
<img src={`/collect/warn.svg`} alt={`warn`} aria-hidden className={`w-5 h-5`} />
<span className={`text-sm`}>IP前需要将本机IP添加到白名单后才可使用</span>
</p>
<div className={`flex flex-col gap-y-4`}>
{/* 套餐类型 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
<RadioGroupItem value="num" id="num" className="mr-2" />
<label htmlFor="num"></label>
</div>
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
<RadioGroupItem value="time" id="time" className="mr-2" />
<label htmlFor="time"></label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 已购套餐 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="order"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select
onValueChange={value => field.onChange(Number(value))}
value={String(field.value)}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择您的套餐" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">IP套餐</SelectItem>
<SelectItem value="1">IP套餐</SelectItem>
<SelectItem value="2">IP套餐</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 地区筛选 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择地区" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cn"></SelectItem>
<SelectItem value="hk"></SelectItem>
<SelectItem value="us"></SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 运营商筛选 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择运营商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="telecom"></SelectItem>
<SelectItem value="mobile"></SelectItem>
<SelectItem value="unicom"></SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 协议类型 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="proto"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择协议类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 去重选项 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="distinct"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择去重方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="ip">IP去重</SelectItem>
<SelectItem value="domain"></SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 导出格式 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="format"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
<RadioGroupItem value="txt" id="txt" className="mr-2" />
<label htmlFor="txt">TXT格式</label>
</div>
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
<RadioGroupItem value="json" id="json" className="mr-2" />
<label htmlFor="json">JSON格式</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 分隔符 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="separator"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} className="h-10" placeholder="输入分隔符,默认为逗号" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* 提取数量 */}
<div className="flex items-center">
<FormLabel className="w-24 flex-shrink-0"></FormLabel>
<FormField
control={form.control}
name="count"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(Number(e.target.value))}
className="h-10"
placeholder="输入提取数量"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end mt-6">
<Button type="submit" className="w-32 h-10 bg-blue-500 text-white rounded-lg">IP</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,19 @@
import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap'
import FormSection from '@/app/(root)/collect/form-section'
export type CollectPageProps = {}
export default function CollectPage(props: CollectPageProps) {
return (
<main className={`mt-20 flex flex-col gap-4`}>
<Wrap className="flex flex-col py-8 gap-8">
<BreadCrumb items={[
{label: 'IP 提取', href: '/collect'},
]}/>
<h2 className={`text-3xl text-center`}> IP</h2>
<FormSection/>
</Wrap>
</main>
)
}

View File

@@ -134,7 +134,7 @@ export default function Header(props: HeaderProps) {
<Wrap className="h-20 max-md:h-16 flex justify-between">
<div className="flex justify-between gap-8">
{/* logo */}
<Link href="/" className={`flex items-center`}>
<Link href="/public" className={`flex items-center`}>
<img src={`/logo.svg`} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full bg-gray-100`}/>
</Link>

22
src/app/(root)/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Header from '@/app/(root)/header'
import Footer from '@/app/(root)/footer'
import {ReactNode} from 'react'
export type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout(props: RootLayoutProps) {
return (
<div className={`overflow-auto flex flex-col items-stretch relative`}>
{/* 页头 */}
<Header/>
{/* 正文 */}
{props.children}
{/* 页脚 */}
<Footer/>
</div>
)
}

215
src/app/(root)/page.tsx Normal file
View File

@@ -0,0 +1,215 @@
import {ReactNode} from 'react'
import Wrap from '@/components/wrap'
import Image from 'next/image'
export default function Home() {
return (
<main className={`flex flex-col gap-16 lg:gap-32 mb-16 lg:mb-32`}>
{/* banner */}
<section className={`w-full bg-[url('/banner.webp')] bg-cover bg-[center_right_40%]`}>
<Wrap className={`pt-64 pb-48 max-md:pt-32 max-md:pb-24`}>
<h1 className={`text-4xl font-medium`}></h1>
<p className={`mt-10 text-gray-500`}>IP代理服务</p>
<div className={`mt-24 max-md:mt-14 flex gap-8 max-md:flex-col`}>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>200+</span>
</p>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>300+</span>
</p>
<p className={`flex gap-4 items-center`}>
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
<span className={`lg:text-lg font-light`}>&</span>
</p>
</div>
<button
className={[
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`,
`bg-gradient-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl font-medium`,
].join(' ')}>
</button>
</Wrap>
</section>
{/* 数据展示 */}
<Section title={`覆盖全国的IP资源及超大的带宽线路`}>
<ul className={`shadow-[0_0_20px_4px] shadow-blue-50 p-8 flex max-lg:flex-col`}>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}>线</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>350+</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}>IP数量</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>1,350,129</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
<p className={`text-xl`}></p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>26,578</p>
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
</li>
<li className={`flex-1 flex flex-col items-center justify-center`}>
<p className={`text-xl`}>IP可用率</p>
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>99%</p>
</li>
</ul>
<img src={`/map.webp`} alt={`map`} className="w-[1200px]"/>
</Section>
{/* 优势 1 */}
<Section title={`HTTP安全合规的代理IP资源池`}>
<ul
className={[
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8`,
].join(' ')}>
<Sec3Item
icon={`s1-1`} title={`短期动态IP池`} terms={[
{icon: `s1-check`, text: `IP时效3-30分钟(可定制)`},
{icon: `s1-check`, text: `支持高并发提取`},
]}/>
<Sec3Item
icon={`s1-2`} title={`长期静态IP池`} terms={[
{icon: `s1-check`, text: `IP覆盖全国各地`},
{icon: `s1-check`, text: `平均响应时长0.03s`},
]}/>
<Sec3Item
icon={`s1-3`} title={`固定IP池`} terms={[
{icon: `s1-check`, text: `稳定长输不掉线`},
{icon: `s1-check`, text: `全国热门静态IP线路`},
]}/>
<Sec3Item
icon={`s1-4`} title={`企业级定制池`} terms={[
{icon: `s1-check`, text: `可视化监控设计`},
{icon: `s1-check`, text: `技术团队现场支持`},
]}/>
</ul>
</Section>
{/* 优势 2 */}
<Section title={`HTTP 产品优势`}>
<div className={`flex gap-36`}>
<ul className={`flex-1 flex flex-col gap-6`}>
<Sec4Item icon={`s4-1-1`} title={`安全合规`} description={`国内三大运营商支持`}/>
<Sec4Item icon={`s4-1-2`} title={`稳定链接`} description={`IP纯净度高达99.9%`}/>
<Sec4Item icon={`s4-1-3`} title={`超匿名性`} description={`稳定传输,保护隐私安全`}/>
</ul>
<img src={`/s4-1-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
</div>
<div className={`flex gap-36`}>
<img src={`/s4-2-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
<ul className={`flex-1 flex flex-col gap-6`}>
<Sec4Item icon={`s4-2-1`} title={`API接口文档`} description={`与第三方软件轻松集成`}/>
<Sec4Item icon={`s4-2-2`} title={`多种编程语言代码`} description={`C语言、GO语言、Python...`}/>
<Sec4Item icon={`s4-2-3`} title={`双重认证方式`} description={`API提取+账密认证`}/>
</ul>
</div>
</Section>
{/* 行业资讯 */}
<Section title={`行业资讯`}>
<div className={`flex gap-8 max-md:gap-4`}>
<button className={`px-4 max-md:-mx-4`}>
<img src={`/next.svg`} alt={`prev`} className={`rotate-180`}/>
</button>
<div
className={[
`shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`,
`flex p-14 md:gap-14 max-md:flex-col max-md:p-4`,
].join(' ')}>
<img src="/s3-main.webp" alt="tumb" className={`w-2/3 md:flex-1 md:w-0 object-cover max-md:self-center`}/>
<div className={`flex-2 flex flex-col justify-between gap-4`}>
<h3 className={`flex justify-between`}>
<span className={`text-xl font-medium`}></span>
<sub className={`text-sm text-gray-500`}>2025-03-04</sub>
</h3>
<p className={`text-gray-400 md:leading-12`}>
...
</p>
<div className={`flex justify-end`}>
<a href="#" className={`text-sm text-gray-500 flex items-center gap-4`}>
<img src={`/next.svg`} alt={`more`} className={`h-4 fill-gray-400`}/>
</a>
</div>
</div>
</div>
<button className={`px-4 max-md:-mx-4`}>
<img src={`/next.svg`} alt={`prev`}/>
</button>
</div>
</Section>
</main>
)
}
function Section(props: {
title: string
children: ReactNode
}) {
return (
<section>
<div className={`max-w-[1232px] mx-auto px-4 flex flex-col items-stretch`}>
<h2 className={`text-center text-3xl font-medium mb-8 lg:mb-24`}>{props.title}</h2>
{props.children}
</div>
</section>
)
}
function Sec3Item(props: {
icon: string,
title: string,
terms: {
icon: string,
text: string,
}[]
}) {
return (
<li
className={[
`p-8 flex flex-col gap-5 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white rounded-lg`,
`max-md:items-center`,
].join(' ')}>
<img src={`/${props.icon}.webp`} alt={`s1-1`} aria-hidden className="w-44 h-44 object-cover"/>
<h3 className={`text-xl font-medium`}>{props.title}</h3>
<div className={`flex flex-col gap-3`}>
{props.terms.map((item, index) => {
return (
<p key={index} className={`text-sm text-gray-500 flex gap-3 items-center`}>
<img src={`/${item.icon}.svg`} alt={`check`} aria-hidden className={`w-5 h-5`}/>
<span>{item.text}</span>
</p>
)
})}
</div>
</li>
)
}
function Sec4Item(props: {
icon: string
title: string
description: string
}) {
return (
<li className={`flex gap-8 items-center p-4 lg:p-8 shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`}>
<img src={`/${props.icon}.webp`} alt={`s2-1-1`} aria-hidden className="w-24 h-24 object-contain"/>
<div className={`flex flex-col gap-3`}>
<h3 className={`text-xl font-medium`}>{props.title}</h3>
<p>{props.description}</p>
</div>
</li>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import {useState} from 'react'
export function Combo(props: {
name: string
level?: {
number: number
discount: number
}[]
}) {
const [open, setOpen] = useState(false)
return (
<li>
<p className={`flex justify-between items-center`}>
<span>{props.name}</span>
<button
className={`text-gray-500 text-sm`}
onClick={() => setOpen(!open)}
>
{open ? '收起' : '展开'}
</button>
</p>
{props.level && (
<ul className={[
`flex flex-col gap-3 overflow-hidden`,
`transition-[opacity,padding,max-height] transition-discrete duration-200 ease-in-out`,
open
? 'delay-[0s, 0s] opacity-100 py-3 max-h-80'
: 'delay-[0s, 0.2s] opacity-0 p-0 max-h-0',
].join(' ')}>
{props.level.map((item, index) => (
<li key={index} className={`flex flex-row justify-between items-center`}>
<span className={`text-gray-500 text-sm`}>{item.number}</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}> {item.discount} %</span>
</li>
))}
</ul>
)}
</li>
)
}

View File

@@ -0,0 +1,259 @@
import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap'
import {Combo} from '@/app/(root)/product/combo'
export type ProductPageProps = {}
export default function ProductPage(props: ProductPageProps) {
return (
<main className={`mt-20`}>
<Wrap className="flex flex-col py-8 gap-8">
<BreadCrumb items={[
{label: '产品中心', href: '/product'},
]}/>
<h2 className={`text-3xl text-center`}></h2>
<ul role={`tablist`} className={`flex justify-center items-stretch bg-white rounded-lg`}>
<li role={`tab`}>
<button className={`h-14 px-8 text-lg`}></button>
</li>
<li role={`tab`}>
<button className={`h-14 px-8 text-lg`}></button>
</li>
<li role={`tab`}>
<button className={`h-14 px-8 text-lg`}></button>
</li>
<li role={`tab`}>
<button className={`h-14 px-8 text-lg`}></button>
</li>
</ul>
<section role={`tabpanel`} className={`flex flex-row bg-white`}>
<Left/>
<Center/>
<Right/>
</section>
</Wrap>
</main>
)
}
function Left() {
return (
<div className="flex-none basis-56 p-8 flex flex-col gap-4">
<img src={`/product/banner.webp`} alt={`banner`} className={`w-full`}/>
<h3 className={`text-lg`}></h3>
<ul className={`flex flex-col gap-3`}>
<Combo name={`3分钟`} level={[
{number: 30000, discount: 10},
{number: 80000, discount: 20},
{number: 200000, discount: 30},
{number: 450000, discount: 40},
{number: 1000000, discount: 50},
{number: 1600000, discount: 65},
]}/>
<Combo name={`5分钟`} level={[
{number: 30000, discount: 10},
{number: 80000, discount: 20},
{number: 200000, discount: 30},
{number: 450000, discount: 40},
{number: 1000000, discount: 50},
{number: 1600000, discount: 65},
]}/>
<Combo name={`10分钟`} level={[
{number: 30000, discount: 10},
{number: 80000, discount: 20},
{number: 200000, discount: 30},
{number: 450000, discount: 40},
{number: 1000000, discount: 50},
{number: 1600000, discount: 65},
]}/>
<Combo name={`15分钟`} level={[
{number: 30000, discount: 10},
{number: 80000, discount: 20},
{number: 200000, discount: 30},
{number: 450000, discount: 40},
{number: 1000000, discount: 50},
{number: 1600000, discount: 65},
]}/>
<Combo name={`30分钟`} level={[
{number: 30000, discount: 10},
{number: 80000, discount: 20},
{number: 200000, discount: 30},
{number: 450000, discount: 40},
{number: 1000000, discount: 50},
{number: 1600000, discount: 65},
]}/>
</ul>
<div className={`border-b border-gray-200`}></div>
<h3 className={`text-lg`}></h3>
<ul className={`flex flex-col gap-3`}>
<li className={`flex justify-between`}>
<span className={`text-sm text-gray-500`}>7</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>9</span>
</li>
<li className={`flex justify-between`}>
<span className={`text-sm text-gray-500`}>30</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>8</span>
</li>
<li className={`flex justify-between`}>
<span className={`text-sm text-gray-500`}>90</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>7</span>
</li>
<li className={`flex justify-between`}>
<span className={`text-sm text-gray-500`}>180</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>6</span>
</li>
<li className={`flex justify-between`}>
<span className={`text-sm text-gray-500`}>360</span>
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>5</span>
</li>
</ul>
</div>
)
}
function Center() {
return (
<div className={`flex-auto p-8 flex flex-col relative gap-4 `}>
<h3></h3>
<div className={`grid grid-cols-2 auto-cols-fr place-items-stretch gap-4`}>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
<h4></h4>
<p className={`text-sm text-gray-500`}></p>
</button>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
<h4></h4>
<p className={`text-sm text-gray-500`}></p>
</button>
</div>
<h3 className={`mt-2`}>IP </h3>
<div className={`grid grid-cols-5 auto-cols-fr place-items-stretch gap-4`}>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
<span>3 </span>
<span className={`text-sm text-gray-500`}>0.005/IP</span>
</button>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
<span>3 </span>
<span className={`text-sm text-gray-500`}>0.005/IP</span>
</button>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
<span>3 </span>
<span className={`text-sm text-gray-500`}>0.005/IP</span>
</button>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
<span>3 </span>
<span className={`text-sm text-gray-500`}>0.005/IP</span>
</button>
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
<span>3 </span>
<span className={`text-sm text-gray-500`}>0.005/IP</span>
</button>
</div>
{/* 赠送 IP 数 */}
<h3 className={`mt-2`}>IP总数</h3>
<div className={`flex gap-4`}>
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>-</button>
<input type="number" className={`w-40 h-10 border border-gray-200 rounded-sm`}/>
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>+</button>
</div>
{/* 产品特性 */}
<h3 className={`mt-2`}></h3>
<div className={`grid grid-cols-3 auto-rows-fr gap-y-4`}>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>API接口</span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>IP时效3-30()</span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>IP资源定期筛选</span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>API接口</span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>/</span>
</p>
<p className={`flex gap-2 items-center`}>
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>500</span>
</p>
</div>
{/* 左右的边框 */}
<div className={`absolute inset-0 my-8 border-l border-r border-gray-200 pointer-events-none`}></div>
</div>
)
}
function Right() {
return (
<div className={`flex-none basis-80 p-8 flex flex-col gap-4`}>
<h3></h3>
<ul className={`flex flex-col gap-4`}>
<li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}></span>
</li>
<li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}>3</span>
</li>
<li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}> IP </span>
<span className={`text-sm`}>1000</span>
</li>
<li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}> IP </span>
<span className={`text-sm`}>1000</span>
</li>
<li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}>50</span>
</li>
</ul>
<div className={`border-b border-gray-200`}></div>
<p className={`flex justify-between items-center`}>
<span></span>
<span className={`text-xl text-orange-500`}>50</span>
</p>
<div className={`flex gap-4`}>
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
<img src={`/product/alipay.svg`} alt={`alipay`} className={`w-5 h-5`}/>
<span className={`text-sm`}></span>
</button>
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
<img src={`/product/wechat.svg`} alt={`wechat`} className={`w-5 h-5`}/>
<span className={`text-sm`}></span>
</button>
</div>
<button className={`mt-4 h-12 bg-blue-500 text-white rounded-lg`}>
</button>
</div>
)
}

View File

@@ -1,5 +1,126 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
body {
color: hsl(0, 0%, 20%);
color: hsl(0, 0%, 10%);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -17,7 +17,7 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-Cn">
<body className={font.className}>
<body className={`${font.className} bg-blue-50`}>
{children}
</body>
</html>

View File

@@ -0,0 +1,63 @@
import { ReactNode } from 'react'
import Link from 'next/link'
export type BreadCrumbItem = {
label: string
href?: string
onClick?: () => void
}
export type BreadCrumbProps = {
items: BreadCrumbItem[]
divider?: ReactNode
showHomeIcon?: boolean
className?: string
}
export default function BreadCrumb({
items,
divider = '/',
showHomeIcon = true,
className = '',
}: BreadCrumbProps) {
return (
<nav className={`flex items-center text-sm ${className}`} aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
{showHomeIcon && (
<li className="flex items-center">
<Link href="/" className="text-gray-500 hover:text-gray-700 transition-colors">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</Link>
</li>
)}
{items.map((item, index) => (
<li key={index} className="flex items-center">
{index > 0 || showHomeIcon ? (
<span className="mx-2 text-gray-400">{divider}</span>
) : null}
{item.href ? (
<Link
href={item.href}
className="text-gray-600 hover:text-blue-600 transition-colors"
onClick={item.onClick}
>
{item.label}
</Link>
) : (
<span
className="text-gray-900 font-medium"
onClick={item.onClick}
>
{item.label}
</span>
)}
</li>
))}
</ol>
</nav>
)
}

View File

@@ -1,27 +0,0 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export const Dropdown = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{/* 菜单内容 */}
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

167
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}