feat: add version history drawer with release notes
Introduce a footer-triggered Version History drawer with categorized release notes, hardcoded release data, and semver tracking at v0.3.0. Also ships blog blockquote/table styling, site-wide typography, home page image updates, header refinements, and related bug fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import type { BlockquoteVariant } from "@/lib/blog-blocks";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const STROKE = 0.9;
|
||||
const BASE = 12;
|
||||
|
||||
type ShapeType =
|
||||
| "cross"
|
||||
| "triangle"
|
||||
| "dot9"
|
||||
| "square"
|
||||
| "diamond"
|
||||
| "dot6"
|
||||
| "circle";
|
||||
|
||||
type LayoutEntry = {
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
type: ShapeType;
|
||||
/** 0.2 = کوچک … 0.55 = بزرگتر */
|
||||
scale: number;
|
||||
};
|
||||
|
||||
type DecorShape = {
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
node: ReactNode;
|
||||
};
|
||||
|
||||
function unit(scale: number) {
|
||||
return BASE * scale;
|
||||
}
|
||||
|
||||
function hollowCircle(r: number) {
|
||||
return (
|
||||
<circle cx="0" cy="0" r={r} fill="none" stroke="currentColor" strokeWidth={STROKE} />
|
||||
);
|
||||
}
|
||||
|
||||
function triangle(size: number) {
|
||||
return (
|
||||
<polygon
|
||||
points={`0,${-size} ${size},${size} ${-size},${size}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function cross(size: number) {
|
||||
return (
|
||||
<>
|
||||
<line
|
||||
x1={-size}
|
||||
y1={-size}
|
||||
x2={size}
|
||||
y2={size}
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1={size}
|
||||
y1={-size}
|
||||
x2={-size}
|
||||
y2={size}
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function square(size: number) {
|
||||
return (
|
||||
<rect
|
||||
x={-size}
|
||||
y={-size}
|
||||
width={size * 2}
|
||||
height={size * 2}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function diamond(size: number) {
|
||||
return (
|
||||
<rect
|
||||
x={-size}
|
||||
y={-size}
|
||||
width={size * 2}
|
||||
height={size * 2}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={STROKE}
|
||||
transform="rotate(45)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function dotPattern(cols: number, rows: number, gap: number, dotR: number) {
|
||||
const dots: ReactNode[] = [];
|
||||
const offsetX = ((cols - 1) * gap) / 2;
|
||||
const offsetY = ((rows - 1) * gap) / 2;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
dots.push(
|
||||
<circle
|
||||
key={`${row}-${col}`}
|
||||
cx={col * gap - offsetX}
|
||||
cy={row * gap - offsetY}
|
||||
r={dotR}
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{dots}</>;
|
||||
}
|
||||
|
||||
function buildShape(type: ShapeType, scale: number): ReactNode {
|
||||
const u = unit(scale);
|
||||
|
||||
switch (type) {
|
||||
case "circle":
|
||||
return hollowCircle(u * 0.85);
|
||||
case "cross":
|
||||
return cross(u * 0.78);
|
||||
case "triangle":
|
||||
return triangle(u * 0.82);
|
||||
case "square":
|
||||
return square(u * 0.72);
|
||||
case "diamond":
|
||||
return diamond(u * 0.68);
|
||||
case "dot9":
|
||||
return dotPattern(3, 3, u * 0.26, u * 0.075);
|
||||
case "dot6":
|
||||
return dotPattern(3, 2, u * 0.3, u * 0.085);
|
||||
}
|
||||
}
|
||||
|
||||
function toDecorShapes(entries: LayoutEntry[]): DecorShape[] {
|
||||
return entries.map((entry) => ({
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
color: entry.color,
|
||||
node: buildShape(entry.type, entry.scale),
|
||||
}));
|
||||
}
|
||||
|
||||
const SHAPE_ORDER: ShapeType[] = [
|
||||
"cross",
|
||||
"triangle",
|
||||
"dot9",
|
||||
"square",
|
||||
"diamond",
|
||||
"dot6",
|
||||
"circle",
|
||||
];
|
||||
|
||||
function swapTypes(entries: LayoutEntry[], offset: number): LayoutEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const index = SHAPE_ORDER.indexOf(entry.type);
|
||||
return {
|
||||
...entry,
|
||||
type: SHAPE_ORDER[(index + offset) % SHAPE_ORDER.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** چیدمان پایه (#2 سبز) — موقعیت، اندازه و رنگ */
|
||||
const layoutGreenBase: LayoutEntry[] = [
|
||||
{ x: 42, y: 11, color: "var(--primary)", type: "diamond", scale: 0.38 },
|
||||
{ x: 44, y: 12, color: "var(--accent)", type: "circle", scale: 0.3 },
|
||||
{ x: 72, y: 10, color: "var(--secondary)", type: "dot6", scale: 0.4 },
|
||||
{ x: 104, y: 13, color: "var(--muted-foreground)", type: "cross", scale: 0.32 },
|
||||
{ x: 149, y: 11, color: "var(--primary)", type: "dot9", scale: 0.36 },
|
||||
{ x: 186, y: 12, color: "var(--accent)", type: "triangle", scale: 0.42 },
|
||||
{ x: 198, y: 28, color: "var(--secondary)", type: "square", scale: 0.34 },
|
||||
|
||||
{ x: 40, y: 27, color: "var(--accent)", type: "triangle", scale: 0.36 },
|
||||
{ x: 38, y: 47, color: "var(--muted-foreground)", type: "dot9", scale: 0.32 },
|
||||
{ x: 41, y: 66, color: "var(--primary)", type: "circle", scale: 0.28 },
|
||||
{ x: 39, y: 87, color: "var(--secondary)", type: "cross", scale: 0.38 },
|
||||
{ x: 42, y: 105, color: "var(--accent)", type: "diamond", scale: 0.33 },
|
||||
|
||||
{ x: 200, y: 48, color: "var(--primary)", type: "square", scale: 0.36 },
|
||||
{ x: 203, y: 62, color: "var(--secondary)", type: "dot6", scale: 0.42 },
|
||||
{ x: 198, y: 78, color: "var(--muted-foreground)", type: "cross", scale: 0.26 },
|
||||
{ x: 201, y: 92, color: "var(--accent)", type: "dot9", scale: 0.35 },
|
||||
|
||||
{ x: 28, y: 109, color: "var(--secondary)", type: "cross", scale: 0.3 },
|
||||
{ x: 68, y: 108, color: "var(--primary)", type: "square", scale: 0.38 },
|
||||
{ x: 128, y: 109, color: "var(--accent)", type: "dot6", scale: 0.34 },
|
||||
{ x: 183, y: 107, color: "var(--muted-foreground)", type: "triangle", scale: 0.28 },
|
||||
{ x: 205, y: 109, color: "var(--primary)", type: "circle", scale: 0.4 },
|
||||
|
||||
{ x: 81, y: 41, color: "var(--primary)", type: "circle", scale: 0.22 },
|
||||
{ x: 167, y: 78, color: "var(--secondary)", type: "triangle", scale: 0.26 },
|
||||
];
|
||||
|
||||
const layoutGreen = toDecorShapes(layoutGreenBase);
|
||||
const layoutYellow = toDecorShapes(swapTypes(layoutGreenBase, 2));
|
||||
const layoutGray = toDecorShapes(swapTypes(layoutGreenBase, 4));
|
||||
|
||||
const DECORATION_LAYOUTS: Record<BlockquoteVariant, DecorShape[]> = {
|
||||
yellow: layoutYellow,
|
||||
green: layoutGreen,
|
||||
gray: layoutGray,
|
||||
};
|
||||
|
||||
interface BlogBlockquoteDecorationsProps {
|
||||
variant: BlockquoteVariant;
|
||||
}
|
||||
|
||||
export function BlogBlockquoteDecorations({
|
||||
variant,
|
||||
}: BlogBlockquoteDecorationsProps) {
|
||||
const shapes = DECORATION_LAYOUTS[variant];
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="blog-blockquote__decorations"
|
||||
viewBox="0 0 240 120"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
aria-hidden
|
||||
>
|
||||
{shapes.map((shape, index) => (
|
||||
<g
|
||||
key={index}
|
||||
transform={`translate(${shape.x}, ${shape.y})`}
|
||||
style={{ color: shape.color }}
|
||||
>
|
||||
{shape.node}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BlockquoteVariant } from "@/lib/blog-blocks";
|
||||
|
||||
export const blockquoteVariantClass: Record<BlockquoteVariant, string> = {
|
||||
yellow: "blog-blockquote--yellow",
|
||||
green: "blog-blockquote--green",
|
||||
gray: "blog-blockquote--gray",
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||
import { sanitizeBlockquoteContent } from "@/lib/blockquote-content";
|
||||
import { blockquoteVariantClass } from "@/components/blog-blockquote-styles";
|
||||
import { BlogBlockquoteDecorations } from "@/components/blog-blockquote-decorations";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Quote } from "lucide-react";
|
||||
|
||||
interface BlogBlockquoteProps {
|
||||
@@ -8,22 +10,23 @@ interface BlogBlockquoteProps {
|
||||
}
|
||||
|
||||
export function BlogBlockquote({ payload }: BlogBlockquoteProps) {
|
||||
const { content, citation } = payload;
|
||||
if (!content.trim()) return null;
|
||||
const { content, citation, variant = "gray" } = payload;
|
||||
const cleanContent = sanitizeBlockquoteContent(content);
|
||||
if (!cleanContent.trim()) return null;
|
||||
|
||||
return (
|
||||
<figure className="my-8 not-prose">
|
||||
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-2 pr-14 text-foreground shadow-sm">
|
||||
<Quote
|
||||
className="absolute right-4 top-4 size-8 text-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
<blockquote
|
||||
className={cn("blog-blockquote text-foreground", blockquoteVariantClass[variant])}
|
||||
>
|
||||
<BlogBlockquoteDecorations variant={variant} />
|
||||
<Quote className="blog-blockquote__icon" aria-hidden />
|
||||
<div
|
||||
className="blog-blockquote-content text-lg leading-relaxed text-justify [&_p]:my-2 [&_a]:text-primary [&_a]:underline hover:[&_a]:no-underline"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
className="blog-blockquote__content"
|
||||
dangerouslySetInnerHTML={{ __html: cleanContent }}
|
||||
/>
|
||||
{citation ? (
|
||||
<footer className="mt-4 border-t border-border/60 pt-4">
|
||||
<footer className="blog-blockquote__footer">
|
||||
<cite className="text-sm text-muted-foreground not-italic">
|
||||
{citation}
|
||||
</cite>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { CollapseBlockPayload } from "@/lib/blog-blocks";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface BlogCollapseSectionProps {
|
||||
@@ -23,9 +25,9 @@ export function BlogCollapseSection({
|
||||
if (!section.items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="my-8 space-y-4 border rounded-xl p-6">
|
||||
<div className="my-8 space-y-4 border rounded-blog p-6">
|
||||
{section.title ? (
|
||||
<h2 className="text-2xl font-bold !mt-0 mb-4 scroll-mt-24">
|
||||
<h2 className={cn(type.h2, "!mt-0 mb-4 scroll-mt-24")}>
|
||||
{section.title}
|
||||
</h2>
|
||||
) : null}
|
||||
@@ -36,7 +38,7 @@ export function BlogCollapseSection({
|
||||
value={`${idPrefix}-${i}`}
|
||||
className="border-none !m-0 hover:bg-muted has-[[data-state=open]]:bg-muted px-4 rounded-md"
|
||||
>
|
||||
<AccordionTrigger className="group/trigger text-right hover:no-underline cursor-pointer py-4 [&>svg]:shrink-0">
|
||||
<AccordionTrigger className={cn(type["list-title"], "group/trigger cursor-pointer py-4 text-right hover:no-underline [&>svg]:shrink-0")}>
|
||||
<span className="flex gap-1 items-center">
|
||||
<Icon
|
||||
icon="lucide:circle-small"
|
||||
@@ -49,7 +51,7 @@ export function BlogCollapseSection({
|
||||
{item.question}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground leading-relaxed text-justify">
|
||||
<AccordionContent className={cn(type.body, "text-justify text-muted-foreground [&_a]:text-accent [&_a]:underline hover:[&_a]:no-underline")}>
|
||||
<div dangerouslySetInnerHTML={{ __html: item.answer }} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { getFeaturedImageUrl, type WpPost } from "@/lib/wp-api";
|
||||
import { formatDateShamsi } from "@/lib/format-date";
|
||||
import { formatAuthorName } from "@/lib/blog-utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
@@ -49,10 +51,10 @@ export function BlogPostCard({
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 min-w-0 flex flex-col justify-center">
|
||||
<h4 className="text-base font-semibold leading-snug line-clamp-3">
|
||||
<h4 className={cn(type["card-title"], "line-clamp-3")}>
|
||||
{post.title.rendered}
|
||||
</h4>
|
||||
<div className="flex flex-wrap w-full items-center gap-x-4 gap-y-1 text-sm text-muted-foreground bg-muted inline-flex p-1.5 mt-3 rounded-sm">
|
||||
<div className={cn("mt-3 inline-flex w-full flex-wrap items-center gap-x-4 gap-y-1 rounded-sm bg-muted p-1.5", type["body-sm"], "text-muted-foreground")}>
|
||||
{authorName && (
|
||||
<span className="inline-flex flex-1 items-center gap-1.5 min-w-0">
|
||||
<User className="size-4 shrink-0" aria-hidden />
|
||||
@@ -97,10 +99,10 @@ export function BlogPostCard({
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<h4 className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
<h4 className={cn(type["body-sm"], "line-clamp-2 font-semibold leading-snug")}>
|
||||
{post.title.rendered}
|
||||
</h4>
|
||||
<div className="flex flex-wrap w-full items-center gap-x-4 gap-y-1 text-xs text-muted-foreground bg-muted inline-flex p-1 mt-2 rounded-sm">
|
||||
<div className={cn("mt-2 inline-flex w-full flex-wrap items-center gap-x-4 gap-y-1 rounded-sm bg-muted p-1", type.caption)}>
|
||||
{authorName && (
|
||||
<span className="inline-flex flex-1 items-center gap-1.5">
|
||||
<User className="size-3.5 shrink-0" aria-hidden />
|
||||
@@ -144,10 +146,10 @@ export function BlogPostCard({
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-bold leading-tight line-clamp-2 my-2">
|
||||
<CardTitle className="my-2 line-clamp-2">
|
||||
{post.title.rendered}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground bg-muted inline-flex p-1 rounded-sm">
|
||||
<div className={cn("inline-flex flex-wrap items-center gap-x-4 gap-y-1 rounded-sm bg-muted p-1", type.caption)}>
|
||||
{authorName && (
|
||||
<span className="inline-flex flex-1 items-center gap-1.5">
|
||||
<User className="size-3.5 shrink-0" aria-hidden />
|
||||
@@ -162,7 +164,7 @@ export function BlogPostCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed line-clamp-3">
|
||||
<p className={cn(type["card-desc"], "line-clamp-3")}>
|
||||
{excerpt}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
export interface BlogPostMetaProps {
|
||||
/** Path (e.g. /blog/slug). Full URL is built on client for share/copy. */
|
||||
@@ -88,7 +89,9 @@ export function BlogPostMeta({
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground",
|
||||
"flex flex-wrap items-center gap-x-5 gap-y-2",
|
||||
type["body-sm"],
|
||||
"text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||
import { sanitizeBlockquoteContent } from "@/lib/blockquote-content";
|
||||
import { blockquoteVariantClass } from "@/components/blog-blockquote-styles";
|
||||
import { BlogBlockquoteDecorations } from "@/components/blog-blockquote-decorations";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Quote } from "lucide-react";
|
||||
|
||||
interface BlogPullquoteProps {
|
||||
payload: BlockquoteBlockPayload;
|
||||
}
|
||||
|
||||
export function BlogPullquote({ payload }: BlogPullquoteProps) {
|
||||
const { content, citation } = payload;
|
||||
if (!content.trim()) return null;
|
||||
const { content, citation, variant = "gray" } = payload;
|
||||
const cleanContent = sanitizeBlockquoteContent(content);
|
||||
if (!cleanContent.trim()) return null;
|
||||
|
||||
return (
|
||||
<figure className="my-10 not-prose">
|
||||
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-5 text-center text-foreground shadow-sm">
|
||||
<blockquote
|
||||
className={cn(
|
||||
"blog-blockquote blog-pullquote text-foreground",
|
||||
blockquoteVariantClass[variant]
|
||||
)}
|
||||
>
|
||||
<BlogBlockquoteDecorations variant={variant} />
|
||||
<Quote className="blog-blockquote__icon" aria-hidden />
|
||||
<div
|
||||
className=""
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
className="blog-blockquote__content"
|
||||
dangerouslySetInnerHTML={{ __html: cleanContent }}
|
||||
/>
|
||||
{citation ? (
|
||||
<footer className="mt-4">
|
||||
<footer className="blog-blockquote__footer">
|
||||
<cite className="text-sm text-muted-foreground not-italic">
|
||||
{citation}
|
||||
</cite>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TableVariant } from "@/lib/blog-blocks";
|
||||
|
||||
export const tableVariantClass: Record<TableVariant, string> = {
|
||||
one: "blog-table--one",
|
||||
two: "blog-table--two",
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TableBlockPayload } from "@/lib/blog-blocks";
|
||||
import { tableVariantClass } from "@/components/blog-table-styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BlogTableProps {
|
||||
payload: TableBlockPayload;
|
||||
}
|
||||
|
||||
/** Glass shell — matches HeroHeader bar styling. */
|
||||
const tableGlassClass =
|
||||
"rounded-blog border border-border/10 bg-background/30 backdrop-blur-md shadow-lg overflow-hidden transition-[box-shadow,background-color] duration-300";
|
||||
|
||||
export function BlogTable({ payload }: BlogTableProps) {
|
||||
const { headers, rows, caption, variant = "one" } = payload;
|
||||
if (!headers.length && !rows.length) return null;
|
||||
|
||||
return (
|
||||
<figure
|
||||
className={cn(
|
||||
"blog-table not-prose my-8 text-foreground",
|
||||
tableGlassClass,
|
||||
tableVariantClass[variant]
|
||||
)}
|
||||
>
|
||||
<div className="blog-table__scroll">
|
||||
<table className="blog-table__grid">
|
||||
{headers.length > 0 ? (
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
dangerouslySetInnerHTML={{ __html: header }}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
) : null}
|
||||
{rows.length > 0 ? (
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => {
|
||||
const Tag = cellIndex === 0 && headers.length === 0 ? "th" : "td";
|
||||
return (
|
||||
<Tag
|
||||
key={cellIndex}
|
||||
{...(Tag === "th" ? { scope: "row" as const } : {})}
|
||||
dangerouslySetInnerHTML={{ __html: cell }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
{caption ? (
|
||||
<figcaption className="blog-table__caption">{caption}</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function BlogTip({ payload }: BlogTipProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"my-6 flex gap-3 rounded-xl border p-4 pr-4 shadow-sm",
|
||||
"my-6 flex gap-3 rounded-blog border p-4 pr-4 shadow-sm",
|
||||
config.className
|
||||
)}
|
||||
role="note"
|
||||
@@ -49,7 +49,7 @@ export function BlogTip({ payload }: BlogTipProps) {
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="blog-tip-content min-w-0 text-sm leading-relaxed [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_a]:text-primary [&_a]:underline hover:[&_a]:no-underline"
|
||||
className="blog-tip-content min-w-0 text-sm leading-relaxed [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_a]:text-accent [&_a]:underline hover:[&_a]:no-underline"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { type, typeSpacing } from '@/lib/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function CallToAction() {
|
||||
return (
|
||||
<section className="py-16">
|
||||
<div className="mx-auto max-w-5xl rounded-3xl border px-6 py-12 md:py-20 lg:py-32">
|
||||
<div className="text-center">
|
||||
<h2 className="text-balance text-4xl font-semibold lg:text-5xl">Start Building</h2>
|
||||
<p className="mt-4">Libero sapiente aliquam quibusdam aspernatur.</p>
|
||||
<h2 className={type["cta-title"]}>Start Building</h2>
|
||||
<p className={cn(type.body, "mt-4")}>Libero sapiente aliquam quibusdam aspernatur.</p>
|
||||
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg">
|
||||
<Link href="/">
|
||||
<Link href="/solutions">
|
||||
<span>Get Started</span>
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -22,7 +24,7 @@ export default function CallToAction() {
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline">
|
||||
<Link href="/">
|
||||
<Link href="/consulting#consulting-form">
|
||||
<span>Book Demo</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
function formatInlineMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
}
|
||||
|
||||
export function ChangelogItem({ text }: { text: string }) {
|
||||
return (
|
||||
<li
|
||||
className="list-disc text-[13px] leading-relaxed text-muted-foreground marker:text-muted-foreground/50 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[11px] [&_strong]:font-medium [&_strong]:text-foreground/90"
|
||||
dangerouslySetInnerHTML={{ __html: formatInlineMarkdown(text) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ChangelogVersionCard } from "@/components/changelog-version-card";
|
||||
import type { ChangelogRelease } from "@/lib/changelog-types";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
type ChangelogPanelProps = {
|
||||
releases: ChangelogRelease[];
|
||||
};
|
||||
|
||||
export function ChangelogPanel({ releases }: ChangelogPanelProps) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="مشاهده تاریخچه تغییرات"
|
||||
className={cn(
|
||||
type.caption,
|
||||
"mt-2 block text-center transition-colors hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
نسخه {APP_VERSION}
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent
|
||||
side="left"
|
||||
closePosition="start"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-lg"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-4 text-start">
|
||||
<SheetTitle className={type.h4}>تاریخچه نسخهها</SheetTitle>
|
||||
<SheetDescription className={type.caption}>
|
||||
نسخه فعلی:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
v{APP_VERSION}
|
||||
</span>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{releases.map((release) => (
|
||||
<ChangelogVersionCard key={release.version} release={release} />
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
CHANGELOG_SECTION_LABELS,
|
||||
type ChangelogRelease,
|
||||
} from "@/lib/changelog-types";
|
||||
import {
|
||||
buildReleaseParagraph,
|
||||
formatReleaseDate,
|
||||
shouldUseParagraphSummary,
|
||||
} from "@/lib/changelog-display";
|
||||
import { ChangelogItem } from "@/components/changelog-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
type ChangelogVersionCardProps = {
|
||||
release: ChangelogRelease;
|
||||
};
|
||||
|
||||
export function ChangelogVersionCard({ release }: ChangelogVersionCardProps) {
|
||||
const isCurrent = release.version === APP_VERSION;
|
||||
const useParagraph =
|
||||
Boolean(release.summary) ||
|
||||
shouldUseParagraphSummary(release.sections);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-left shadow-sm",
|
||||
isCurrent && "border-primary/30 ring-1 ring-primary/15"
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-mono text-base font-semibold tracking-tight text-foreground">
|
||||
v{release.version}
|
||||
</h2>
|
||||
{isCurrent && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
Current Version
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<time
|
||||
className="mt-1.5 block text-xs text-muted-foreground"
|
||||
dateTime={release.date}
|
||||
>
|
||||
{formatReleaseDate(release.date)}
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="my-4 h-px bg-border/70"
|
||||
role="separator"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="text-left text-[13px] leading-relaxed text-muted-foreground">
|
||||
{release.summary ? (
|
||||
<p>{release.summary}</p>
|
||||
) : useParagraph ? (
|
||||
<p>{buildReleaseParagraph(release.sections)}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{release.sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="mb-1 text-[13px] font-medium text-foreground/80">
|
||||
{CHANGELOG_SECTION_LABELS[section.title]}
|
||||
</h3>
|
||||
<ul className="space-y-1 ps-4">
|
||||
{section.items.map((item, index) => (
|
||||
<ChangelogItem key={index} text={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,24 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
import FooterSection from '@/components/footer'
|
||||
import { HeroHeader } from '@/components/header'
|
||||
import type { ChangelogRelease } from '@/lib/changelog-types'
|
||||
|
||||
export function ConditionalLayout({ children }: { children: React.ReactNode }) {
|
||||
export function ConditionalLayout({
|
||||
children,
|
||||
changelogReleases,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
changelogReleases: ChangelogRelease[]
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const isLoginPage = pathname === '/login'
|
||||
const showChrome = !isLoginPage
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isLoginPage && <HeroHeader />}
|
||||
{showChrome && <HeroHeader />}
|
||||
{children}
|
||||
{!isLoginPage && <FooterSection />}
|
||||
{showChrome && <FooterSection releases={changelogReleases} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+28
-13
@@ -1,49 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import { getHomeImages } from "@/lib/home-images";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ContentSection() {
|
||||
const { ecosystem } = getHomeImages();
|
||||
|
||||
return (
|
||||
<section className="bg-background py-16 md:py-32">
|
||||
<div className="mx-auto max-w-5xl space-y-8 px-6 md:space-y-16">
|
||||
<h2 className="relative z-10 max-w-xl text-2xl font-medium md:text-3xl lg:text-4xl leading-relaxed tracking-normal">
|
||||
<h2 className={cn("relative z-10 max-w-xl", type.h2)}>
|
||||
اکوسیستم فیپ: راهکارهای یکپارچه برای کسبوکارهای هوشمند
|
||||
</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:gap-12 lg:gap-24">
|
||||
<div className="relative mb-6 sm:mb-0">
|
||||
<div className="bg-linear-to-b aspect-76/59 relative rounded-2xl from-zinc-300 to-transparent p-px dark:from-zinc-700">
|
||||
<div className="relative aspect-[1207/929] w-full overflow-hidden rounded-[15px] bg-muted flex items-center justify-center">
|
||||
<div className="bg-linear-to-b aspect-[3/2] relative overflow-hidden rounded-2xl from-zinc-300 to-transparent p-px dark:from-zinc-700">
|
||||
<div className="relative size-full overflow-hidden rounded-[15px] bg-muted">
|
||||
<Image
|
||||
src="/assets/Echosystem.png"
|
||||
className="rounded-[15px] object-cover"
|
||||
alt="اکوسیستم یکپارچه فیپ"
|
||||
width={1207}
|
||||
height={929}
|
||||
unoptimized
|
||||
src={ecosystem.light.src}
|
||||
className="size-full object-cover dark:hidden"
|
||||
alt="مرکز کنترل گردش کار هوشمند"
|
||||
width={ecosystem.light.width}
|
||||
height={ecosystem.light.height}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
<Image
|
||||
src={ecosystem.dark.src}
|
||||
className="hidden size-full object-cover dark:block"
|
||||
alt="مرکز کنترل گردش کار هوشمند"
|
||||
width={ecosystem.dark.width}
|
||||
height={ecosystem.dark.height}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-4">
|
||||
<p className="text-muted-foreground leading-relaxed tracking-normal text-justify sm:text-start">
|
||||
<p className={cn(type.body, "text-muted-foreground text-justify sm:text-start")}>
|
||||
فیپ یک گروه متخصص است که در حوزه اینتگریشن و روشهای نوین یکپارچهسازی فعالیت میکند.{" "}
|
||||
<span className="text-primary font-bold">
|
||||
ما با استفاده از تکنولوژیهای پیشرفته و رویکردهای نوآورانه
|
||||
</span>{" "}
|
||||
به کسبوکارها کمک میکنیم تا سیستمهای خود را به شکلی کارآمد و قابلاعتماد بهم متصل کنند.
|
||||
</p>
|
||||
<p className="text-muted-foreground leading-relaxed tracking-normal text-justify sm:text-start">
|
||||
<p className={cn(type.body, "text-muted-foreground text-justify sm:text-start")}>
|
||||
ما در زمینه یکپارچهسازی سیستمها، اتوماسیون فرآیندها، ارتباطات چندکاناله و مدیریت دادهها تخصص داریم. با استفاده از روشهای نوین و ابزارهای پیشرفته، راهکارهای سفارشی برای هر کسبوکار طراحی و پیادهسازی میکنیم که به آنها امکان میدهد تا از قدرت اتوماسیون و ارتباطات هوشمند بهرهمند شوند.
|
||||
</p>
|
||||
|
||||
<div className="pt-6">
|
||||
<blockquote className="border-s-4 border-primary/30 ps-4">
|
||||
<p className="leading-relaxed tracking-normal text-foreground">
|
||||
<p className={type.body}>
|
||||
ماموریت ما این است که با استفاده از روشهای نوین اینتگریشن، به کسبوکارها کمک کنیم تا
|
||||
بدون پیچیدگیهای فنی، سیستمهای خود را بهم متصل کنند و به اهداف خود برسند.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="block font-medium not-italic text-foreground">ماموریت فیپ</div>
|
||||
<div className={cn(type.emphasis, "not-italic")}>ماموریت فیپ</div>
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import Link from 'next/link'
|
||||
import { type, typeSpacing } from '@/lib/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function FAQsFour() {
|
||||
const faqItems = [
|
||||
@@ -36,8 +38,8 @@ export default function FAQsFour() {
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<h2 className="text-balance text-3xl font-bold md:text-4xl lg:text-5xl">Frequently Asked Questions</h2>
|
||||
<p className="text-muted-foreground mt-4 text-balance">Discover quick and comprehensive answers to common questions about our platform, services, and features.</p>
|
||||
<h2 className={type.h2}>Frequently Asked Questions</h2>
|
||||
<p className={cn(type.body, "text-muted-foreground mt-4 text-balance")}>Discover quick and comprehensive answers to common questions about our platform, services, and features.</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-xl">
|
||||
@@ -52,9 +54,9 @@ export default function FAQsFour() {
|
||||
<AccordionItem
|
||||
value={item.id}
|
||||
className="data-[state=open]:bg-card dark:data-[state=open]:bg-muted peer rounded-xl border-none px-7 py-1 data-[state=open]:border-none data-[state=open]:shadow-sm">
|
||||
<AccordionTrigger className="cursor-pointer text-base hover:no-underline">{item.question}</AccordionTrigger>
|
||||
<AccordionTrigger className={cn(type["list-title"], "cursor-pointer hover:no-underline")}>{item.question}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="text-base">{item.answer}</p>
|
||||
<p className={type.body}>{item.answer}</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<hr className="mx-7 border-dashed group-last:hidden peer-data-[state=open]:opacity-0" />
|
||||
@@ -62,11 +64,11 @@ export default function FAQsFour() {
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<p className="text-muted-foreground mt-6 px-8">
|
||||
<p className={cn(type.body, "text-muted-foreground mt-6 px-8")}>
|
||||
Can't find what you're looking for? Contact our{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="text-primary font-medium hover:underline">
|
||||
href="/consulting#consulting-form"
|
||||
className="text-accent font-medium hover:underline">
|
||||
customer support team
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { getFeaturedImageUrl, type WpPost } from "@/lib/wp-api";
|
||||
import { formatDateShamsi } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
@@ -58,14 +59,14 @@ function PostCard({
|
||||
<h3
|
||||
className={cn(
|
||||
"font-bold leading-tight line-clamp-2 drop-shadow-sm",
|
||||
size === "large" ? "text-lg md:text-xl" : "text-base"
|
||||
size === "large" ? cn(type.h3, "text-white") : cn(type["card-title"], "text-white")
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-white/90 leading-relaxed line-clamp-2",
|
||||
size === "large" ? "text-sm md:text-base line-clamp-3" : "text-sm"
|
||||
size === "large" ? cn(type.body, "line-clamp-3 text-white/90") : cn(type["body-sm"], "text-white/90")
|
||||
)}
|
||||
>
|
||||
{excerpt}
|
||||
@@ -90,7 +91,7 @@ function PostCardRow({ post }: { post: WpPost }) {
|
||||
{/* نیمه عنوان + نویسنده */}
|
||||
<div className="flex flex-1 flex-col justify-center p-4">
|
||||
<h3
|
||||
className="font-bold leading-tight text-foreground line-clamp-2 text-base"
|
||||
className={cn(type["card-title"], "line-clamp-2 text-foreground")}
|
||||
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
|
||||
/>
|
||||
</div>
|
||||
@@ -131,10 +132,10 @@ export function FeaturedPostsGrid({
|
||||
<section className="bg-background py-16 md:py-24" aria-label={title}>
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<header className="mb-10 text-center md:mb-12">
|
||||
<h2 className="text-2xl font-bold text-foreground md:text-3xl">
|
||||
<h2 className={type.h2}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl mx-auto text-muted-foreground text-sm leading-relaxed md:text-base">
|
||||
<p className={cn(type["body-sm"], "mx-auto mt-3 max-w-2xl text-muted-foreground md:text-base")}>
|
||||
{caption}
|
||||
</p>
|
||||
</header>
|
||||
@@ -160,7 +161,7 @@ export function FeaturedPostsGrid({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-col items-center justify-center gap-3 text-center md:mt-16">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className={type.caption}>
|
||||
مطالب بیشتر در دانشنامه
|
||||
</p>
|
||||
<Button asChild size="lg" variant="outline" className="rounded-full px-6 font-medium shadow-sm transition-all hover:shadow-md">
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Link as LinkIcon,
|
||||
BarChart3,
|
||||
Share2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { BorderBeam } from "./ui/border-beam";
|
||||
import type { FeatureImageKey, FeatureImageSet } from "@/lib/home-images";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FeaturesClientProps = {
|
||||
images: FeatureImageSet;
|
||||
};
|
||||
|
||||
export function FeaturesClient({ images }: FeaturesClientProps) {
|
||||
const [activeItem, setActiveItem] = useState<FeatureImageKey>("item-1");
|
||||
const active = images[activeItem];
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20 lg:py-32">
|
||||
<div className="bg-linear-to-b absolute inset-0 -z-10 sm:inset-6 sm:rounded-b-3xl dark:block dark:to-[color-mix(in_oklab,var(--color-zinc-900)_75%,var(--color-background))]"></div>
|
||||
<div className="mx-auto max-w-5xl space-y-8 px-6 md:space-y-16 lg:space-y-20 dark:[--color-border:color-mix(in_oklab,var(--color-white)_10%,transparent)]">
|
||||
<div className={cn("relative z-10 mx-auto max-w-2xl text-center", typeSpacing.sectionStack)}>
|
||||
<h2 className={type.h2}>
|
||||
پایه و اساس ارتباطات هوشمند
|
||||
</h2>
|
||||
<p className={type.body}>
|
||||
راهحلهای یکپارچهسازی پیشرفته برای اتصال سیستمها، اتوماسیون فرآیندها، تعامل با شبکههای اجتماعی و تحلیل دادهها. پلتفرم ما به شما امکان میدهد تا سیستمهای مختلف را به راحتی بهم متصل کنید، فرآیندهای کسبوکار را خودکارسازی کنید و از قدرت دادهها برای تصمیمگیری بهتر استفاده کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-12 sm:px-12 md:grid-cols-2 lg:gap-20 lg:px-0">
|
||||
<Accordion
|
||||
type="single"
|
||||
value={activeItem}
|
||||
onValueChange={(value) => setActiveItem(value as FeatureImageKey)}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>
|
||||
<div className={cn("flex items-center gap-2", type["list-title"])}>
|
||||
<LinkIcon className="size-4" />
|
||||
یکپارچهسازی سیستمها
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className={type.body}>
|
||||
اتصال و هماهنگسازی سیستمها و سرویسهای مختلف برای ایجاد جریان یکپارچه داده و فرآیند. ما به شما امکان میدهیم تا به سرویسها و APIهای مختلف متصل شوید، دادهها را به صورت خودکار بین سیستمها همگامسازی کنید و workflowهای یکپارچه ایجاد کنید.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>
|
||||
<div className={cn("flex items-center gap-2", type["list-title"])}>
|
||||
<BarChart3 className="size-4" />
|
||||
پنلهای مدیریتی قدرتمند
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className={type.body}>
|
||||
ارائه پنلهای کاربری پیشرفته برای نمایش آمار، تحلیل دادهها و مدیریت اطلاعات با رابط کاربری حرفهای. داشبوردهای تعاملی و قابل تنظیم، نمایش آمار و گزارشهای زنده، و امکان ویرایش و مدیریت اطلاعات از طریق UI قدرتمند.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>
|
||||
<div className={cn("flex items-center gap-2", type["list-title"])}>
|
||||
<Share2 className="size-4" />
|
||||
تعامل با شبکههای اجتماعی
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className={type.body}>
|
||||
ایجاد ارتباطات قدرتمند و خودکار با پلتفرمهای مختلف شبکههای اجتماعی برای مدیریت و تعامل بهتر. اتصال به پلتفرمهای مختلف اجتماعی، مدیریت و ارسال محتوا به صورت خودکار، نظارت و تحلیل تعاملات، و همگامسازی اطلاعات بین پلتفرمها.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger>
|
||||
<div className={cn("flex items-center gap-2", type["list-title"])}>
|
||||
<Zap className="size-4" />
|
||||
اتوماسیون فرآیندها
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className={type.body}>
|
||||
ایجاد کارهای خودکار برای اتوماتیکسازی فرآیندهای کسبوکار و کاهش کارهای دستی. تعریف و اجرای workflowهای خودکار، اتوماسیون بر اساس رویداد و زمان، زمانبندی و اجرای کارهای تکراری، و اعلانها و هشدارهای خودکار.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="relative rounded-3xl bg-linear-to-br from-primary/50 via-accent/40 to-secondary/50 p-px shadow-lg shadow-primary/10">
|
||||
<div className="bg-background relative flex overflow-hidden rounded-[calc(1.5rem-1px)] m-0 p-0">
|
||||
<div className="w-15 absolute inset-0 right-0 ml-auto border-l border-primary/15 bg-[repeating-linear-gradient(-45deg,color-mix(in_oklab,var(--color-primary)_25%,var(--color-border)),color-mix(in_oklab,var(--color-primary)_25%,var(--color-border))_1px,transparent_1px,transparent_8px)]"></div>
|
||||
<div
|
||||
className="bg-background relative w-full rounded-[calc(1.5rem-1px)] m-0 p-0"
|
||||
style={{ aspectRatio: `${active.image.width} / ${active.image.height}` }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${activeItem}-id`}
|
||||
initial={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="size-full overflow-hidden rounded-[calc(1.5rem-1px)] m-0 p-0"
|
||||
>
|
||||
<Image
|
||||
src={active.image.src}
|
||||
className="size-full object-cover dark:hidden"
|
||||
alt={active.alt}
|
||||
width={active.image.width}
|
||||
height={active.image.height}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
<Image
|
||||
src={active.imageDark.src}
|
||||
className="hidden size-full object-cover dark:block"
|
||||
alt={active.alt}
|
||||
width={active.imageDark.width}
|
||||
height={active.imageDark.height}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<BorderBeam
|
||||
duration={6}
|
||||
size={200}
|
||||
borderWidth={1}
|
||||
colorFrom="#FCC61D"
|
||||
colorTo="#01796F"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+4
-167
@@ -1,170 +1,7 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Link as LinkIcon,
|
||||
BarChart3,
|
||||
Share2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { BorderBeam } from "./ui/border-beam";
|
||||
import { FeaturesClient } from "@/components/features-12-client";
|
||||
import { getHomeImages } from "@/lib/home-images";
|
||||
|
||||
export default function Features() {
|
||||
type ImageKey = "item-1" | "item-2" | "item-3" | "item-4";
|
||||
const [activeItem, setActiveItem] = useState<ImageKey>("item-1");
|
||||
|
||||
const sectionImage = (filename: string) =>
|
||||
`/assets/main-pic/4section/${encodeURIComponent(filename)}`;
|
||||
|
||||
const images = {
|
||||
"item-1": {
|
||||
image: sectionImage("Systems Integration.jpg"),
|
||||
imageDark: sectionImage("Systems Integration-dark.jpg"),
|
||||
alt: "یکپارچهسازی سیستمها",
|
||||
},
|
||||
"item-2": {
|
||||
image: sectionImage("Powerful Management panels.jpg"),
|
||||
imageDark: sectionImage("Powerful Management panels-dark.jpg"),
|
||||
alt: "پنلهای مدیریتی قدرتمند",
|
||||
},
|
||||
"item-3": {
|
||||
image: sectionImage("Interaction with Social Networks.jpg"),
|
||||
imageDark: sectionImage("Interaction with Social Networks_dark.jpg"),
|
||||
alt: "تعامل با شبکههای اجتماعی",
|
||||
},
|
||||
"item-4": {
|
||||
image: sectionImage("Process Automation.jpg"),
|
||||
imageDark: sectionImage("Process Automation-dark.jpg"),
|
||||
alt: "اتوماسیون فرآیندها",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20 lg:py-32">
|
||||
<div className="bg-linear-to-b absolute inset-0 -z-10 sm:inset-6 sm:rounded-b-3xl dark:block dark:to-[color-mix(in_oklab,var(--color-zinc-900)_75%,var(--color-background))]"></div>
|
||||
<div className="mx-auto max-w-5xl space-y-8 px-6 md:space-y-16 lg:space-y-20 dark:[--color-border:color-mix(in_oklab,var(--color-white)_10%,transparent)]">
|
||||
<div className="relative z-10 mx-auto max-w-2xl space-y-6 text-center">
|
||||
<h2 className="text-balance text-2xl font-semibold md:text-3xl lg:text-4xl leading-relaxed tracking-normal">
|
||||
پایه و اساس ارتباطات هوشمند
|
||||
</h2>
|
||||
<p className="leading-relaxed tracking-normal">
|
||||
راهحلهای یکپارچهسازی پیشرفته برای اتصال سیستمها، اتوماسیون فرآیندها، تعامل با شبکههای اجتماعی و تحلیل دادهها. پلتفرم ما به شما امکان میدهد تا سیستمهای مختلف را به راحتی بهم متصل کنید، فرآیندهای کسبوکار را خودکارسازی کنید و از قدرت دادهها برای تصمیمگیری بهتر استفاده کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-12 sm:px-12 md:grid-cols-2 lg:gap-20 lg:px-0">
|
||||
<Accordion
|
||||
type="single"
|
||||
value={activeItem}
|
||||
onValueChange={(value) => setActiveItem(value as ImageKey)}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<LinkIcon className="size-4" />
|
||||
یکپارچهسازی سیستمها
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="leading-relaxed tracking-normal">
|
||||
اتصال و هماهنگسازی سیستمها و سرویسهای مختلف برای ایجاد جریان یکپارچه داده و فرآیند. ما به شما امکان میدهیم تا به سرویسها و APIهای مختلف متصل شوید، دادهها را به صورت خودکار بین سیستمها همگامسازی کنید و workflowهای یکپارچه ایجاد کنید.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<BarChart3 className="size-4" />
|
||||
پنلهای مدیریتی قدرتمند
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="leading-relaxed tracking-normal">
|
||||
ارائه پنلهای کاربری پیشرفته برای نمایش آمار، تحلیل دادهها و مدیریت اطلاعات با رابط کاربری حرفهای. داشبوردهای تعاملی و قابل تنظیم، نمایش آمار و گزارشهای زنده، و امکان ویرایش و مدیریت اطلاعات از طریق UI قدرتمند.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<Share2 className="size-4" />
|
||||
تعامل با شبکههای اجتماعی
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="leading-relaxed tracking-normal">
|
||||
ایجاد ارتباطات قدرتمند و خودکار با پلتفرمهای مختلف شبکههای اجتماعی برای مدیریت و تعامل بهتر. اتصال به پلتفرمهای مختلف اجتماعی، مدیریت و ارسال محتوا به صورت خودکار، نظارت و تحلیل تعاملات، و همگامسازی اطلاعات بین پلتفرمها.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<Zap className="size-4" />
|
||||
اتوماسیون فرآیندها
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p className="leading-relaxed tracking-normal">
|
||||
ایجاد کارهای خودکار برای اتوماتیکسازی فرآیندهای کسبوکار و کاهش کارهای دستی. تعریف و اجرای workflowهای خودکار، اتوماسیون بر اساس رویداد و زمان، زمانبندی و اجرای کارهای تکراری، و اعلانها و هشدارهای خودکار.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="relative rounded-3xl bg-linear-to-br from-primary/50 via-accent/40 to-secondary/50 p-px shadow-lg shadow-primary/10">
|
||||
<div className="bg-background relative flex overflow-hidden rounded-[calc(1.5rem-1px)] m-0 p-0">
|
||||
<div className="w-15 absolute inset-0 right-0 ml-auto border-l border-primary/15 bg-[repeating-linear-gradient(-45deg,color-mix(in_oklab,var(--color-primary)_25%,var(--color-border)),color-mix(in_oklab,var(--color-primary)_25%,var(--color-border))_1px,transparent_1px,transparent_8px)]"></div>
|
||||
<div className="aspect-76/59 bg-background relative w-full rounded-[calc(1.5rem-1px)] m-0 p-0">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${activeItem}-id`}
|
||||
initial={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="size-full overflow-hidden rounded-[calc(1.5rem-1px)] m-0 p-0"
|
||||
>
|
||||
<Image
|
||||
src={images[activeItem].image}
|
||||
className="size-full object-cover dark:hidden"
|
||||
alt={images[activeItem].alt}
|
||||
width={1207}
|
||||
height={929}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
<Image
|
||||
src={images[activeItem].imageDark}
|
||||
className="hidden size-full object-cover dark:block"
|
||||
alt={images[activeItem].alt}
|
||||
width={1207}
|
||||
height={929}
|
||||
quality={100}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<BorderBeam
|
||||
duration={6}
|
||||
size={200}
|
||||
borderWidth={1}
|
||||
colorFrom="#FCC61D"
|
||||
colorTo="#01796F"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const { features } = getHomeImages();
|
||||
return <FeaturesClient images={features} />;
|
||||
}
|
||||
|
||||
+12
-3
@@ -1,5 +1,9 @@
|
||||
import { Logo } from "@/components/logo";
|
||||
import Link from "next/link";
|
||||
import { ChangelogPanel } from "@/components/changelog-panel";
|
||||
import type { ChangelogRelease } from "@/lib/changelog-types";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const links = [
|
||||
{
|
||||
@@ -16,13 +20,17 @@ const links = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function FooterSection() {
|
||||
export default function FooterSection({
|
||||
releases,
|
||||
}: {
|
||||
releases: ChangelogRelease[];
|
||||
}) {
|
||||
return (
|
||||
<footer className="py-16 md:py-16 bg-muted border-t">
|
||||
<div className="mx-auto max-w-5xl px-6 flex items-center flex-col">
|
||||
<Logo hasCaption className="mx-auto" />
|
||||
|
||||
<div className="my-4 flex flex-wrap justify-center gap-6 text-sm">
|
||||
<div className={cn("my-4 flex flex-wrap justify-center gap-6", type.link)}>
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
@@ -33,9 +41,10 @@ export default function FooterSection() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted-foreground block text-center text-sm">
|
||||
<span className={cn(type.link, "block text-center")}>
|
||||
© 1404 فیپ، تمامی حقوق محفوظ است
|
||||
</span>
|
||||
<ChangelogPanel releases={releases} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
+82
-49
@@ -6,6 +6,7 @@ import { Menu, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type } from '@/lib/typography'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
|
||||
const menuItems = [
|
||||
@@ -14,9 +15,16 @@ const menuItems = [
|
||||
{ name: 'راه حلها', href: '/solutions' },
|
||||
{ name: 'مشاوره سازمانی', href: '/consulting' },
|
||||
// { name: 'قیمتگذاری', href: '/pricing' },
|
||||
// { name: 'آزمایشگاه فیپ', href: '/playground' },
|
||||
{ name: 'درباره ما', href: '/about' },
|
||||
]
|
||||
|
||||
/** Outer gutter — matches hero image wrapper (`px-6 md:px-2`). */
|
||||
const siteGutterClass = 'px-6 md:px-2 lg:px-6'
|
||||
|
||||
/** Bar width — matches hero image inner container (`max-w-5xl md:max-w-4xl`). */
|
||||
const headerBarClass = 'mx-auto w-full max-w-5xl md:max-w-4xl lg:max-w-6xl'
|
||||
|
||||
function isLinkActive(pathname: string, href: string): boolean {
|
||||
if (href === '/') return pathname === '/'
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
@@ -34,27 +42,36 @@ export const HeroHeader = () => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
return (
|
||||
<header>
|
||||
<nav
|
||||
data-state={menuState && 'active'}
|
||||
className="fixed z-20 w-full px-2">
|
||||
<div className={cn('mx-auto mt-2 max-w-6xl px-2 transition-all duration-300 lg:px-2 bg-background/30 backdrop-blur-md rounded-md border border-border/10', isScrolled && 'bg-background/20 max-w-4xl shadow-lg lg:px-5')}>
|
||||
<div className="relative flex flex-wrap items-center justify-between gap-2 lg:gap-0">
|
||||
<div className="flex w-full justify-between lg:w-auto my-2">
|
||||
<Logo />
|
||||
<button
|
||||
onClick={() => setMenuState(!menuState)}
|
||||
aria-label={menuState == true ? 'بستن منو' : 'باز کردن منو'}
|
||||
className="relative z-20 -m-2.5 -mr-4 block cursor-pointer p-2.5 lg:hidden">
|
||||
<Menu className="in-data-[state=active]:rotate-180 in-data-[state=active]:scale-0 in-data-[state=active]:opacity-0 m-auto size-4 duration-200" />
|
||||
<X className="in-data-[state=active]:rotate-0 in-data-[state=active]:scale-100 in-data-[state=active]:opacity-100 absolute inset-0 m-auto size-4 -rotate-180 scale-0 opacity-0 duration-200" />
|
||||
</button>
|
||||
|
||||
React.useEffect(() => {
|
||||
document.body.style.overflow = menuState ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [menuState])
|
||||
|
||||
return (
|
||||
<header className="contents">
|
||||
<nav
|
||||
data-state={menuState ? 'active' : undefined}
|
||||
aria-label="ناوبری اصلی"
|
||||
className={cn(
|
||||
'fixed inset-x-0 top-0 z-20 w-full pt-[max(0.5rem,env(safe-area-inset-top,0px))]',
|
||||
siteGutterClass
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
headerBarClass,
|
||||
'mt-2 rounded-md border border-border/10 bg-background/30 backdrop-blur-md transition-[max-width,box-shadow,background-color] duration-300',
|
||||
isScrolled && 'bg-background/20 shadow-lg lg:max-w-4xl'
|
||||
)}>
|
||||
<div className="relative flex h-14 items-center justify-between gap-3 px-3 sm:gap-4 sm:px-4 lg:h-16">
|
||||
<div className="flex min-w-0 shrink items-center">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 m-auto hidden size-fit lg:flex items-center">
|
||||
<ul className="flex gap-2 text-sm">
|
||||
<div className="pointer-events-none absolute inset-x-0 hidden lg:flex lg:items-center lg:justify-center">
|
||||
<ul className={cn("pointer-events-auto flex gap-1", type.nav)}>
|
||||
{menuItems.map((item, index) => {
|
||||
const active = isLinkActive(pathname ?? '', item.href)
|
||||
return (
|
||||
@@ -62,49 +79,65 @@ export const HeroHeader = () => {
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-all duration-300 relative group p-2 px-4 rounded-md',
|
||||
active && 'text-foreground bg-accent/40'
|
||||
'relative rounded-md p-2 px-4 text-muted-foreground transition-all duration-300 hover:bg-accent/20 hover:text-foreground',
|
||||
active && 'bg-accent/40 text-foreground'
|
||||
)}>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="in-data-[state=active]:block lg:in-data-[state=active]:flex mb-3 hidden w-full flex-wrap items-center justify-end space-y-8 md:flex-nowrap lg:m-0 lg:flex lg:w-fit lg:gap-6 lg:space-y-0 lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none dark:shadow-none dark:lg:bg-transparent">
|
||||
<div className="lg:hidden w-full">
|
||||
|
||||
<ul className="text-base flex flex-col items-stretch">
|
||||
{menuItems.map((item, index) => {
|
||||
const active = isLinkActive(pathname ?? '', item.href)
|
||||
return (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setMenuState(false)}
|
||||
className={cn(
|
||||
'text-muted-foreground block w-full hover:text-foreground hover:bg-accent/20 transition-all duration-300 relative group p-2 px-4 rounded-md',
|
||||
active && 'text-foreground bg-accent/40'
|
||||
)}>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className={`flex justify-end max-md:absolute top-0.5 left-8 in-data-[state=active]:block`}>
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
onClick={() => setMenuState((open) => !open)}
|
||||
aria-expanded={menuState}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={menuState ? 'بستن منو' : 'باز کردن منو'}
|
||||
className="relative rounded-lg lg:hidden">
|
||||
<Menu
|
||||
aria-hidden
|
||||
className="size-5 transition-all duration-200 in-data-[state=active]:rotate-180 in-data-[state=active]:scale-0 in-data-[state=active]:opacity-0"
|
||||
/>
|
||||
<X
|
||||
aria-hidden
|
||||
className="absolute inset-0 m-auto size-5 -rotate-180 scale-0 opacity-0 transition-all duration-200 in-data-[state=active]:rotate-0 in-data-[state=active]:scale-100 in-data-[state=active]:opacity-100"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mobile-nav"
|
||||
className={cn(
|
||||
'overflow-hidden border-t border-border/10 transition-[max-height,opacity] duration-300 lg:hidden',
|
||||
menuState ? 'max-h-[min(70vh,28rem)] opacity-100' : 'max-h-0 border-transparent opacity-0'
|
||||
)}>
|
||||
<ul className={cn("flex flex-col px-3 py-2 sm:px-4", type["nav-mobile"])}>
|
||||
{menuItems.map((item, index) => {
|
||||
const active = isLinkActive(pathname ?? '', item.href)
|
||||
return (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setMenuState(false)}
|
||||
className={cn(
|
||||
'block w-full rounded-md p-2 px-4 text-muted-foreground transition-all duration-300 hover:bg-accent/20 hover:text-foreground',
|
||||
active && 'bg-accent/40 text-foreground'
|
||||
)}>
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
+52
-30
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { TextEffect } from "@/components/ui/text-effect";
|
||||
import { AnimatedGroup } from "@/components/ui/animated-group";
|
||||
import { HeroHeader } from "./header";
|
||||
import { LogoItem } from "@/components/ui/logo-item";
|
||||
import { getHomeImages } from "@/lib/home-images";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
|
||||
const transitionVariants = {
|
||||
item: {
|
||||
@@ -29,6 +31,8 @@ const transitionVariants = {
|
||||
};
|
||||
|
||||
export default function HeroSection() {
|
||||
const { hero } = getHomeImages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="overflow-hidden">
|
||||
@@ -70,11 +74,11 @@ export default function HeroSection() {
|
||||
className="mask-b-from-35% mask-b-to-90% absolute inset-0 top-56 -z-20 lg:top-32"
|
||||
>
|
||||
<Image
|
||||
src="/assets/main-pic/main-pic.jpg"
|
||||
src={hero.backgroundDark.src}
|
||||
alt="background"
|
||||
className="hidden size-full object-cover dark:block"
|
||||
width={1150}
|
||||
height={613}
|
||||
width={hero.backgroundDark.width}
|
||||
height={hero.backgroundDark.height}
|
||||
quality={100}
|
||||
sizes="100vw"
|
||||
/>
|
||||
@@ -110,7 +114,7 @@ export default function HeroSection() {
|
||||
preset="fade-in-blur"
|
||||
speedSegment={0.3}
|
||||
as="h1"
|
||||
className="mx-auto mt-8 max-w-4xl text-balance text-3xl max-md:font-semibold md:text-4xl lg:mt-16 lg:text-5xl leading-relaxed tracking-normal"
|
||||
className={cn("mx-auto mt-8 max-w-4xl", type.display, "max-md:font-semibold lg:mt-16")}
|
||||
>
|
||||
راهحلهای مدرن برای اتصال هوشمند
|
||||
|
||||
@@ -121,7 +125,7 @@ export default function HeroSection() {
|
||||
speedSegment={0.3}
|
||||
delay={0.5}
|
||||
as="p"
|
||||
className="mx-auto mt-8 max-w-2xl text-balance text-base md:text-lg leading-relaxed tracking-normal"
|
||||
className={cn("mx-auto mt-8 max-w-2xl text-balance", type["body-lg"])}
|
||||
>
|
||||
با ابزارهای یکپارچهسازی، اتوماسیون و ارتباطات ما، دادهها، کاربران و فرآیندهای کسبوکارتان را از هر سیستم یا پلتفرمی، از وبسایت و CRM تا تلگرام و سرویسهای سفارشی، دقیق، سریع و قابل اعتماد به هم متصل کنید.
|
||||
</TextEffect>
|
||||
@@ -145,9 +149,9 @@ export default function HeroSection() {
|
||||
className="bg-foreground/10 rounded-[calc(var(--radius-xl)+0.125rem)] border p-0.5"
|
||||
>
|
||||
<Button asChild size="lg">
|
||||
<Link href="#link">
|
||||
<Link href="/solutions">
|
||||
<span className="text-nowrap">مشاهده راهکارها</span>
|
||||
<ChevronDown />
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -168,33 +172,51 @@ export default function HeroSection() {
|
||||
...transitionVariants,
|
||||
}}
|
||||
>
|
||||
<div className="relative mt-8 overflow-visible px-2 sm:mt-12 md:mt-20 p-2">
|
||||
<div className="relative max-w-4xl mx-auto flex items-center justify-center">
|
||||
<div className="relative rounded-2xl overflow-hidden">
|
||||
<div className="relative mt-8 overflow-visible px-6 sm:mt-12 md:mt-20 md:p-2 md:px-2">
|
||||
<div className="relative w-full max-w-5xl md:max-w-4xl mx-auto">
|
||||
<div className="relative w-full rounded-2xl">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-pink-500/20 blur-3xl -z-10 scale-110 animate-pulse" />
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-400/30 via-purple-400/30 to-pink-400/30 blur-2xl -z-10 scale-105" />
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 blur-3xl -z-10 scale-110 animate-pulse" />
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-blue-400/15 via-purple-400/15 to-pink-400/15 blur-2xl -z-10 scale-105" />
|
||||
|
||||
<div className="relative bg-background rounded-2xl border ring-1 ring-border/50 shadow-2xl shadow-blue-500/10 dark:shadow-blue-500/20">
|
||||
<div className="relative w-full aspect-[3/2] overflow-hidden bg-background rounded-2xl border ring-1 ring-border/50 shadow-2xl shadow-blue-500/10 dark:shadow-blue-500/20 md:aspect-auto">
|
||||
<Image
|
||||
src="/assets/main-pic/1.jpg"
|
||||
src={hero.main.src}
|
||||
alt="یکپارچهسازی سیستمها و شبکه دیجیتال"
|
||||
width={1920}
|
||||
height={1280}
|
||||
className="object-contain rounded-2xl w-full h-auto max-h-[600px] md:max-h-[700px] dark:hidden"
|
||||
fill
|
||||
className="object-cover rounded-2xl md:hidden dark:hidden"
|
||||
quality={100}
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<Image
|
||||
src="/assets/main-pic/1dark.jpg"
|
||||
src={hero.mainDark.src}
|
||||
alt="یکپارچهسازی سیستمها و شبکه دیجیتال"
|
||||
width={1920}
|
||||
height={1280}
|
||||
className="hidden object-contain rounded-2xl w-full h-auto max-h-[600px] md:max-h-[700px] dark:block"
|
||||
fill
|
||||
className="hidden object-cover rounded-2xl md:hidden dark:block"
|
||||
quality={100}
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<Image
|
||||
src={hero.main.src}
|
||||
alt="یکپارچهسازی سیستمها و شبکه دیجیتال"
|
||||
width={hero.main.width}
|
||||
height={hero.main.height}
|
||||
className="max-md:hidden object-contain rounded-2xl w-full h-auto max-h-[700px] dark:hidden"
|
||||
quality={100}
|
||||
priority
|
||||
sizes="(max-width: 1200px) 90vw, 1200px"
|
||||
/>
|
||||
<Image
|
||||
src={hero.mainDark.src}
|
||||
alt="یکپارچهسازی سیستمها و شبکه دیجیتال"
|
||||
width={hero.mainDark.width}
|
||||
height={hero.mainDark.height}
|
||||
className="max-md:hidden hidden object-contain rounded-2xl w-full h-auto max-h-[700px] dark:block"
|
||||
quality={100}
|
||||
priority
|
||||
sizes="(max-width: 1200px) 90vw, 1200px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,10 +228,10 @@ export default function HeroSection() {
|
||||
<section className="bg-background pb-16 pt-16 md:pb-32">
|
||||
<div className="m-auto max-w-5xl px-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
<p className={type.eyebrow}>
|
||||
یکپارچه با ابزارهای محبوب
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold md:text-xl">
|
||||
<h2 className={cn(typeSpacing.eyebrowToHeading, type.h4)}>
|
||||
اتصال به سایر سیستمها
|
||||
</h2>
|
||||
</div>
|
||||
@@ -243,11 +265,11 @@ export default function HeroSection() {
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm text-muted-foreground transition-colors hover:text-accent"
|
||||
href="/solutions"
|
||||
className={cn("inline-flex items-center gap-1 transition-colors hover:text-accent", type.link)}
|
||||
>
|
||||
<span>مشاهده همه یکپارچهسازیها</span>
|
||||
<ChevronRight className="ml-1 inline-block size-3" />
|
||||
<ChevronLeft className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-10
@@ -1,4 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -21,13 +23,13 @@ export function LoginForm({
|
||||
<form className="p-4 sm:p-6 md:p-8">
|
||||
<FieldGroup className="gap-4 sm:gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">خوش آمدید</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground text-balance">
|
||||
<h1 className={type.h2}>خوش آمدید</h1>
|
||||
<p className={cn(type["body-sm"], "text-muted-foreground text-balance md:text-base")}>
|
||||
به حساب فیپ خود وارد شوید
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email" className="text-sm sm:text-base">
|
||||
<FieldLabel htmlFor="email" className={type.label}>
|
||||
ایمیل
|
||||
</FieldLabel>
|
||||
<Input
|
||||
@@ -42,16 +44,16 @@ export function LoginForm({
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-0">
|
||||
<FieldLabel
|
||||
htmlFor="password"
|
||||
className="text-sm sm:text-base"
|
||||
className={type.label}
|
||||
>
|
||||
رمز عبور
|
||||
</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="text-xs sm:text-sm sm:ml-auto underline-offset-2 hover:underline"
|
||||
<Link
|
||||
href="/consulting#consulting-form"
|
||||
className={cn(type.caption, "sm:ms-auto underline-offset-2 hover:underline")}
|
||||
>
|
||||
رمز عبور را فراموش کردهاید؟
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -95,8 +97,8 @@ export function LoginForm({
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-4 sm:px-6 text-xs sm:text-sm text-center">
|
||||
با کلیک روی ادامه، شما با <a href="#">شرایط استفاده</a> و{" "}
|
||||
<a href="#">سیاست حریم خصوصی</a> ما موافقت میکنید.
|
||||
با کلیک روی ادامه، شما با <Link href="/about" className="underline-offset-2 hover:underline">شرایط استفاده</Link> و{" "}
|
||||
<Link href="/about" className="underline-offset-2 hover:underline">سیاست حریم خصوصی</Link> ما موافقت میکنید.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "./ui/separator";
|
||||
@@ -66,14 +67,14 @@ export const Logo = ({
|
||||
uniColor?: boolean;
|
||||
hasCaption?: boolean;
|
||||
}) => {
|
||||
const _feep = <span className="font-bold text-xl leading-tight"><LogoType /></span>
|
||||
const _feep = <span className={cn(type.h4, "font-bold leading-tight")}><LogoType /></span>
|
||||
return (
|
||||
<Link href="/" className={`flex items-center ${hasCaption ? 'flex-col' : 'gap-2'}`}>
|
||||
<LogoIcon className={cn(className, 'h-7 text-primary')} uniColor={uniColor} />
|
||||
{hasCaption ?
|
||||
<span className="flex flex-col items-center">
|
||||
{_feep}
|
||||
<span className="text-xs text-muted-foreground font-normal leading-tight">
|
||||
<span className={cn(type.caption, "font-normal leading-tight")}>
|
||||
دادهها فهمیده میشوند
|
||||
</span>
|
||||
<Separator className="mt-2 max-w-10" />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
|
||||
/** Smooth ease-out curve for agent UI transitions */
|
||||
export const agentEase = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
export const agentSpring = {
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 32,
|
||||
} as const satisfies Transition;
|
||||
|
||||
export const agentSoftSpring = {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 28,
|
||||
} as const satisfies Transition;
|
||||
|
||||
export const agentPanelTransition: Transition = {
|
||||
duration: 0.48,
|
||||
ease: agentEase,
|
||||
};
|
||||
|
||||
export const agentLayoutTransition: Transition = {
|
||||
layout: { duration: 0.52, ease: agentEase },
|
||||
};
|
||||
|
||||
export const agentFadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: agentPanelTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -14,
|
||||
transition: { duration: 0.32, ease: agentEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentFadeIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.98 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: agentPanelTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.97,
|
||||
transition: { duration: 0.3, ease: agentEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentSlideInEnd: Variants = {
|
||||
hidden: { opacity: 0, x: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: agentSoftSpring,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 28,
|
||||
transition: { duration: 0.32, ease: agentEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentStaggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.07, delayChildren: 0.06 },
|
||||
},
|
||||
exit: {
|
||||
transition: { staggerChildren: 0.04, staggerDirection: -1 },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentStaggerFast: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06, delayChildren: 0.12 },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentCardVariant: Variants = {
|
||||
hidden: { opacity: 0, y: 20, scale: 0.96 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: agentSoftSpring,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
scale: 0.94,
|
||||
transition: { duration: 0.28, ease: agentEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const agentThreadItem: Variants = {
|
||||
hidden: { opacity: 0, y: 14 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: agentPanelTransition,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Mail, Plus, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AGENT_AUDIENCE_TAGS,
|
||||
AGENT_PREVIEW_TAGS,
|
||||
AGENT_WORKSPACE_TITLE,
|
||||
} from "./playground-content";
|
||||
import {
|
||||
agentFadeUp,
|
||||
agentLayoutTransition,
|
||||
agentSlideInEnd,
|
||||
agentStaggerContainer,
|
||||
} from "./agent-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
interface AgentPreviewPanelProps {
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function TagChip({ label }: { label: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-muted/60 px-2 py-1",
|
||||
type.caption
|
||||
)}
|
||||
variants={agentFadeUp}
|
||||
custom={index}
|
||||
>
|
||||
{label}
|
||||
<X className="size-3 opacity-50" aria-hidden />
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function AudienceSection({
|
||||
title,
|
||||
tags,
|
||||
}: {
|
||||
title: string;
|
||||
tags: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<motion.div className="space-y-2" variants={agentFadeUp}>
|
||||
<p className={cn(type.caption, "font-medium uppercase tracking-wide text-muted-foreground")}>
|
||||
{title}
|
||||
</p>
|
||||
<motion.div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
variants={agentStaggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{tags.map((tag, index) => (
|
||||
<TagChip key={tag} label={tag} />
|
||||
))}
|
||||
<motion.button
|
||||
type="button"
|
||||
variants={agentFadeUp}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md border border-dashed text-muted-foreground hover:bg-muted/50"
|
||||
aria-label={`افزودن ${title}`}
|
||||
whileHover={{ scale: 1.06 }}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPreviewPanel({ className, onClose }: AgentPreviewPanelProps) {
|
||||
return (
|
||||
<motion.aside
|
||||
layout
|
||||
className={cn(
|
||||
"flex w-full shrink-0 flex-col overflow-hidden border-s bg-card lg:w-72 xl:w-80",
|
||||
className
|
||||
)}
|
||||
variants={agentSlideInEnd}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<h2 className={type["card-title"]}>پیشنمایش سناریو</h2>
|
||||
{onClose && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
aria-label="بستن پیشنمایش"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex-1 space-y-5 overflow-y-auto p-4"
|
||||
variants={agentStaggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.div className="space-y-2" variants={agentFadeUp}>
|
||||
<p className={cn(type.caption, "font-medium uppercase tracking-wide text-muted-foreground")}>
|
||||
سناریو
|
||||
</p>
|
||||
<h3 className={type.h4}>{AGENT_WORKSPACE_TITLE}</h3>
|
||||
<p className={cn(type["body-sm"], "text-muted-foreground")}>
|
||||
سناریوی آزمایشی برای طراحی workflow پشتیبانی و پاسخگویی خودکار به
|
||||
مشتریان.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{AGENT_PREVIEW_TAGS.map((tag, index) => (
|
||||
<motion.span
|
||||
key={tag}
|
||||
className={cn(
|
||||
"rounded-md bg-primary/10 px-2 py-0.5 text-primary",
|
||||
type.caption
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.15 + index * 0.06 }}
|
||||
>
|
||||
{tag}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div className="space-y-2" variants={agentFadeUp}>
|
||||
<p className={cn(type.caption, "font-medium uppercase tracking-wide text-muted-foreground")}>
|
||||
قالب
|
||||
</p>
|
||||
<div className="flex items-center justify-between rounded-xl border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span className={type["body-sm"]}>پاسخگویی خودکار</span>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
||||
تغییر
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className={cn(type.caption, "font-medium uppercase tracking-wide text-muted-foreground")}>
|
||||
نمای کلی مخاطب
|
||||
</p>
|
||||
<AudienceSection title="نوع شرکت" tags={AGENT_AUDIENCE_TAGS.companyType} />
|
||||
<AudienceSection title="مرحله" tags={AGENT_AUDIENCE_TAGS.stage} />
|
||||
<AudienceSection title="اندازه شرکت" tags={AGENT_AUDIENCE_TAGS.companySize} />
|
||||
<AudienceSection title="منطقه" tags={AGENT_AUDIENCE_TAGS.region} />
|
||||
</div>
|
||||
|
||||
<motion.div className="space-y-2" variants={agentFadeUp}>
|
||||
<p className={cn(type.caption, "font-medium uppercase tracking-wide text-muted-foreground")}>
|
||||
ساختار سناریو
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-xl border bg-muted/30 px-3 py-2.5">
|
||||
<Mail className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span className={type["body-sm"]}>پیام ۱</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex gap-2 border-t p-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
زمانبندی
|
||||
</Button>
|
||||
<Button type="button" className="flex-1">
|
||||
اجرای سناریو
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSphereProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentSphere({ className }: AgentSphereProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative flex size-16 items-center justify-center sm:size-20",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: reduceMotion ? 0 : [0, -5, 0],
|
||||
}}
|
||||
transition={{
|
||||
opacity: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||
scale: { type: "spring", stiffness: 320, damping: 24 },
|
||||
y: reduceMotion
|
||||
? undefined
|
||||
: { duration: 4, repeat: Infinity, ease: "easeInOut" },
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-primary/15 blur-xl"
|
||||
animate={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: { scale: [1, 1.12, 1], opacity: [0.6, 0.9, 0.6] }
|
||||
}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<div
|
||||
className="relative size-full rounded-full shadow-lg"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 30% 25%, oklch(0.75 0.12 165), #01796F 45%, oklch(0.35 0.08 175) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-[18%] rounded-full bg-white/20 blur-sm" />
|
||||
<div className="absolute left-[22%] top-[18%] size-[28%] rounded-full bg-white/35 blur-[2px]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Sparkles,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AGENT_AUDIENCE_TAGS,
|
||||
AGENT_PROGRESS_STAGES,
|
||||
} from "./playground-content";
|
||||
import {
|
||||
agentEase,
|
||||
agentPanelTransition,
|
||||
agentSoftSpring,
|
||||
agentStaggerFast,
|
||||
agentThreadItem,
|
||||
} from "./agent-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
interface AgentThreadProps {
|
||||
messages: ChatMessage[];
|
||||
isTyping?: boolean;
|
||||
bottomRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function ProgressCard() {
|
||||
const activeStage = 2;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-2xl border bg-card p-4 shadow-sm"
|
||||
variants={agentThreadItem}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={type["card-title"]}>تولید سناریو آغاز شد</h3>
|
||||
<span className={cn(type.caption, "text-muted-foreground")}>۵ دقیقه</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{AGENT_PROGRESS_STAGES.map((stage, index) => {
|
||||
const isComplete = index < activeStage;
|
||||
const isActive = index === activeStage;
|
||||
|
||||
return (
|
||||
<div key={stage} className="flex-1 space-y-1.5">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{
|
||||
scaleX: isComplete || isActive ? 1 : 0,
|
||||
opacity: isComplete || isActive ? 1 : 0,
|
||||
}}
|
||||
style={{ transformOrigin: "right center" }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0 : 0.55,
|
||||
delay: reduceMotion ? 0 : index * 0.1,
|
||||
ease: agentEase,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<motion.p
|
||||
className={cn(
|
||||
type.caption,
|
||||
"text-center text-[10px] uppercase tracking-wide",
|
||||
isComplete || isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: reduceMotion ? 0 : 0.15 + index * 0.06 }}
|
||||
>
|
||||
{stage}
|
||||
</motion.p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
title,
|
||||
status,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
index = 0,
|
||||
}: {
|
||||
title: string;
|
||||
status: "done" | "active" | "pending";
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
children?: React.ReactNode;
|
||||
index?: number;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="overflow-hidden rounded-xl border bg-card"
|
||||
variants={agentThreadItem}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ ...agentPanelTransition, delay: reduceMotion ? 0 : 0.08 + index * 0.07 }}
|
||||
layout
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-start"
|
||||
>
|
||||
<motion.span
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
status === "done" && "bg-primary/15 text-primary",
|
||||
status === "active" && "bg-primary text-primary-foreground",
|
||||
status === "pending" && "border text-muted-foreground"
|
||||
)}
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={agentSoftSpring}
|
||||
>
|
||||
{status === "done" ? (
|
||||
<Check className="size-3" aria-hidden />
|
||||
) : status === "active" ? (
|
||||
<Loader2 className="size-3 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/50" />
|
||||
)}
|
||||
</motion.span>
|
||||
<span className={cn("flex-1", type["body-sm"], status === "pending" && "text-muted-foreground")}>
|
||||
{title}
|
||||
</span>
|
||||
{onToggle && (
|
||||
<motion.span
|
||||
animate={{ rotate: expanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.28, ease: agentEase }}
|
||||
>
|
||||
<ChevronDown
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</motion.span>
|
||||
)}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && children && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: reduceMotion ? 0 : 0.32, ease: agentEase }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t px-4 pb-4 pt-3">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudienceTags() {
|
||||
const allTags = [
|
||||
...AGENT_AUDIENCE_TAGS.companyType,
|
||||
...AGENT_AUDIENCE_TAGS.stage,
|
||||
...AGENT_AUDIENCE_TAGS.region,
|
||||
];
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map((tag, index) => (
|
||||
<motion.span
|
||||
key={tag}
|
||||
className={cn(
|
||||
"rounded-md border bg-muted/50 px-2.5 py-1 text-foreground",
|
||||
type.caption
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.85, y: 6 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{
|
||||
...agentSoftSpring,
|
||||
delay: reduceMotion ? 0 : index * 0.05,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBubble({ content }: { content: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-2xl border bg-card p-4 shadow-sm"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={agentPanelTransition}
|
||||
>
|
||||
<p className={cn(type.chat, "whitespace-pre-wrap break-words text-card-foreground")}>
|
||||
{content}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentThread({
|
||||
messages,
|
||||
isTyping = false,
|
||||
bottomRef,
|
||||
}: AgentThreadProps) {
|
||||
const [expandedTask, setExpandedTask] = useState(true);
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||
const assistantMessages = messages.filter((m) => m.role === "assistant");
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<motion.div
|
||||
className="flex flex-col gap-4 p-4 sm:p-5"
|
||||
variants={agentStaggerFast}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{lastUserMessage && (
|
||||
<motion.div
|
||||
key="user-prompt"
|
||||
className="rounded-2xl bg-muted/50 px-4 py-3.5"
|
||||
layout
|
||||
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={agentPanelTransition}
|
||||
>
|
||||
<p className={cn(type["body-sm"], "whitespace-pre-wrap break-words text-foreground")}>
|
||||
{lastUserMessage.content}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{(isTyping || assistantMessages.length > 0) && (
|
||||
<motion.div
|
||||
key="agent-workflow"
|
||||
className="space-y-4"
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: agentEase }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isTyping && (
|
||||
<motion.div
|
||||
key="progress"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.38, ease: agentEase }}
|
||||
>
|
||||
<ProgressCard />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TaskRow title="تحلیل نیازمندیها" status="done" index={0} />
|
||||
<TaskRow
|
||||
title="کشف حسابها و سیگنالها"
|
||||
status={isTyping ? "active" : "done"}
|
||||
expanded={expandedTask}
|
||||
onToggle={() => setExpandedTask((v) => !v)}
|
||||
index={1}
|
||||
>
|
||||
<AudienceTags />
|
||||
</TaskRow>
|
||||
<TaskRow
|
||||
title="ساخت پیامهای شخصیسازیشده"
|
||||
status={isTyping ? "pending" : "done"}
|
||||
index={2}
|
||||
/>
|
||||
<TaskRow
|
||||
title="تنظیم دنباله ارتباطی"
|
||||
status="pending"
|
||||
index={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isTyping && lastAssistant && (
|
||||
<motion.div
|
||||
key="assistant"
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={agentPanelTransition}
|
||||
>
|
||||
<AssistantBubble content={lastAssistant.content} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isTyping && assistantMessages.length > 0 && (
|
||||
<motion.div
|
||||
key="ready-banner"
|
||||
className="flex items-center justify-between rounded-xl bg-muted/40 px-4 py-2.5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ...agentPanelTransition, delay: 0.1 }}
|
||||
>
|
||||
<span className={cn(type["body-sm"], "text-muted-foreground")}>
|
||||
سناریوی شما آماده است!
|
||||
</span>
|
||||
<motion.button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-lg bg-foreground px-3 py-1.5 text-background",
|
||||
type.caption
|
||||
)}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
پیشنمایش
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div ref={bottomRef} className="h-px shrink-0" aria-hidden />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureCardIcon({
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
icon: (typeof import("./playground-content").AGENT_FEATURE_CARDS)[number]["icon"];
|
||||
className?: string;
|
||||
}) {
|
||||
const iconClass = cn("size-4", className);
|
||||
|
||||
switch (icon) {
|
||||
case "megaphone":
|
||||
return <Megaphone className={iconClass} aria-hidden />;
|
||||
case "document":
|
||||
return <FileText className={iconClass} aria-hidden />;
|
||||
case "sparkles":
|
||||
return <Sparkles className={iconClass} aria-hidden />;
|
||||
case "chart":
|
||||
return <BarChart3 className={iconClass} aria-hidden />;
|
||||
case "target":
|
||||
return <Target className={iconClass} aria-hidden />;
|
||||
case "steps":
|
||||
return <GitBranch className={iconClass} aria-hidden />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AtSign,
|
||||
CheckSquare,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Play,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { agentEase, agentPanelTransition } from "./agent-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
interface ChatComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
className,
|
||||
}: ChatComposerProps) {
|
||||
const canSubmit = value.trim().length > 0 && !disabled && !isSending;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("shrink-0 p-3 sm:p-4", className)}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={agentPanelTransition}
|
||||
>
|
||||
<motion.div
|
||||
className="rounded-2xl border bg-card p-3 shadow-md sm:p-4"
|
||||
layout
|
||||
transition={agentPanelTransition}
|
||||
whileHover={
|
||||
reduceMotion ? undefined : { boxShadow: "0 8px 30px -8px rgba(1, 121, 111, 0.12)" }
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="امتحان کنید: «یک سناریو پاسخگویی خودکار برای مشتریان طراحی کن»..."
|
||||
disabled={disabled || isSending}
|
||||
rows={2}
|
||||
className={cn(
|
||||
"min-h-[4.5rem] resize-none border-0 bg-transparent px-1 py-1 text-start shadow-none transition-[min-height] duration-300 ease-out focus-visible:ring-0",
|
||||
type["body-sm"]
|
||||
)}
|
||||
aria-label="دستور عامل"
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2 border-t pt-2">
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="پیوست"
|
||||
>
|
||||
<Paperclip className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="اشاره"
|
||||
>
|
||||
<AtSign className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="وظایف"
|
||||
>
|
||||
<CheckSquare className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Select defaultValue="overall">
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 gap-1 border-0 bg-transparent px-2 shadow-none"
|
||||
>
|
||||
<Shield className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="overall">کلی</SelectItem>
|
||||
<SelectItem value="focused">متمرکز</SelectItem>
|
||||
<SelectItem value="creative">خلاقانه</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="ضبط صدا"
|
||||
>
|
||||
<Mic className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<motion.div
|
||||
whileHover={reduceMotion ? undefined : { scale: 1.04 }}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.96 }}
|
||||
transition={{ duration: 0.18, ease: agentEase }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="h-8 gap-1.5 rounded-full px-4"
|
||||
>
|
||||
<Play className="size-3.5 fill-current" aria-hidden />
|
||||
<span className={type.caption}>اجرا</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { AGENT_FEATURE_CARDS, AGENT_WELCOME } from "./playground-content";
|
||||
import { AgentSphere } from "./agent-sphere";
|
||||
import {
|
||||
agentCardVariant,
|
||||
agentEase,
|
||||
agentFadeUp,
|
||||
agentStaggerContainer,
|
||||
} from "./agent-motion";
|
||||
import { FeatureCardIcon } from "./agent-thread";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
onSelectStarter: (id: string, prompt: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatEmptyState({
|
||||
onSelectStarter,
|
||||
disabled = false,
|
||||
className,
|
||||
}: ChatEmptyStateProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center overflow-y-auto px-4 py-6 sm:px-6 sm:py-8",
|
||||
className
|
||||
)}
|
||||
variants={agentStaggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<AgentSphere />
|
||||
|
||||
<motion.h2
|
||||
className={cn(type.h3, "mt-5 text-center")}
|
||||
variants={agentFadeUp}
|
||||
>
|
||||
{AGENT_WELCOME.greeting}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
className={cn(type["body-sm"], "mt-1.5 max-w-md text-center text-muted-foreground")}
|
||||
variants={agentFadeUp}
|
||||
>
|
||||
{AGENT_WELCOME.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-8 grid w-full max-w-2xl gap-3 sm:grid-cols-2 lg:max-w-3xl"
|
||||
variants={agentStaggerContainer}
|
||||
>
|
||||
{AGENT_FEATURE_CARDS.map((card) => (
|
||||
<motion.button
|
||||
key={card.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
variants={agentCardVariant}
|
||||
onClick={() => onSelectStarter(card.id, card.prompt)}
|
||||
whileHover={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: { y: -3, scale: 1.015, transition: { duration: 0.22, ease: agentEase } }
|
||||
}
|
||||
whileTap={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: { scale: 0.97, transition: { duration: 0.14 } }
|
||||
}
|
||||
className={cn(
|
||||
"group rounded-2xl border bg-card p-4 text-start shadow-sm",
|
||||
"hover:border-primary/30 hover:shadow-md disabled:opacity-50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="flex size-8 items-center justify-center rounded-lg bg-muted text-foreground"
|
||||
whileHover={reduceMotion ? undefined : { scale: 1.08 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 22 }}
|
||||
>
|
||||
<FeatureCardIcon icon={card.icon} />
|
||||
</motion.div>
|
||||
<h3 className={cn(type["card-title"], "mt-3")}>{card.title}</h3>
|
||||
<p className={cn(type["card-desc"], "mt-1")}>{card.description}</p>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, User } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isTyping?: boolean;
|
||||
bottomRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("fa-IR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex max-w-[85%] items-center gap-2 rounded-2xl rounded-br-md border bg-card px-4 py-3 shadow-sm sm:max-w-[75%]">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Bot className="size-4" aria-hidden />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 py-1" aria-label="در حال نوشتن">
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:0ms]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:150ms]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
isTyping = false,
|
||||
bottomRef,
|
||||
}: ChatMessageListProps) {
|
||||
return (
|
||||
<ScrollArea className="min-h-0 flex-1 pe-1">
|
||||
<div className="flex flex-col gap-4 p-4 sm:p-5">
|
||||
{messages.map((message) => {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn("flex", isUser ? "justify-start" : "justify-end")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[92%] gap-2.5 sm:max-w-[80%]",
|
||||
isUser ? "flex-row" : "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full sm:size-8",
|
||||
isUser
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Bot className="size-4" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("min-w-0", typeSpacing.chatMeta)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-4 py-3 shadow-sm",
|
||||
type.chat,
|
||||
isUser
|
||||
? "rounded-tr-md bg-primary text-primary-foreground"
|
||||
: "rounded-bl-md border bg-card text-card-foreground"
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
type.caption,
|
||||
isUser ? "text-start" : "text-end"
|
||||
)}
|
||||
>
|
||||
{isUser ? "شما" : "دستیار"} · {formatTime(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isTyping && <TypingIndicator />}
|
||||
<div ref={bottomRef} className="h-px shrink-0" aria-hidden />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Braces,
|
||||
ChevronLeft,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Settings2,
|
||||
Share2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AgentPreviewPanel } from "./agent-preview-panel";
|
||||
import { AgentThread } from "./agent-thread";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { ChatEmptyState } from "./chat-empty-state";
|
||||
import {
|
||||
agentFadeIn,
|
||||
agentLayoutTransition,
|
||||
} from "./agent-motion";
|
||||
import {
|
||||
AGENT_BREADCRUMB,
|
||||
AGENT_WORKSPACE_TITLE,
|
||||
} from "./playground-content";
|
||||
import type { ChatMessage } from "./types";
|
||||
import type { ToolsTab } from "./playground-tools-sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStarterSelect: (id: string, prompt: string) => void;
|
||||
onOpenTools: (tab: ToolsTab) => void;
|
||||
isSending?: boolean;
|
||||
isTyping?: boolean;
|
||||
bottomRef?: React.RefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AgentHeader({
|
||||
onOpenTools,
|
||||
}: {
|
||||
onOpenTools: (tab: ToolsTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="shrink-0 border-b bg-card">
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 sm:px-5">
|
||||
<span className={cn(type.caption, "text-muted-foreground")}>
|
||||
{AGENT_BREADCRUMB.parent}
|
||||
</span>
|
||||
<ChevronLeft className="size-3 rotate-180 text-muted-foreground/60" aria-hidden />
|
||||
<span className={cn(type.caption, "font-medium text-foreground")}>
|
||||
{AGENT_BREADCRUMB.current}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t bg-muted/20 px-4 py-2.5 sm:px-5">
|
||||
<h2 className={cn("min-w-0 truncate", type["card-title"])}>
|
||||
{AGENT_WORKSPACE_TITLE}
|
||||
</h2>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="اشتراکگذاری"
|
||||
>
|
||||
<Share2 className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="گزینههای بیشتر"
|
||||
>
|
||||
<MoreVertical className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 hidden h-4 w-px bg-border sm:block" aria-hidden />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 gap-1.5 px-2 sm:inline-flex"
|
||||
onClick={() => onOpenTools("scenarios")}
|
||||
aria-label="سناریوها"
|
||||
>
|
||||
<Wand2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className={type.caption}>سناریوها</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="sm:hidden"
|
||||
onClick={() => onOpenTools("scenarios")}
|
||||
aria-label="سناریوها"
|
||||
>
|
||||
<Wand2 className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 gap-1.5 px-2 sm:inline-flex"
|
||||
onClick={() => onOpenTools("settings")}
|
||||
aria-label="تنظیمات"
|
||||
>
|
||||
<Settings2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className={type.caption}>تنظیمات</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="sm:hidden"
|
||||
onClick={() => onOpenTools("settings")}
|
||||
aria-label="تنظیمات"
|
||||
>
|
||||
<Settings2 className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 gap-1.5 px-2 sm:inline-flex"
|
||||
onClick={() => onOpenTools("output")}
|
||||
aria-label="خروجی"
|
||||
>
|
||||
<Braces className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className={type.caption}>خروجی</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="sm:hidden"
|
||||
onClick={() => onOpenTools("output")}
|
||||
aria-label="خروجی"
|
||||
>
|
||||
<Braces className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
messages,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onStarterSelect,
|
||||
onOpenTools,
|
||||
isSending = false,
|
||||
isTyping = false,
|
||||
bottomRef,
|
||||
className,
|
||||
}: ChatPanelProps) {
|
||||
const showEmptyState = messages.length === 0 && !isTyping;
|
||||
const showPreview = messages.length > 0 || isTyping;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex min-h-[min(70dvh,42rem)] flex-col gap-0 overflow-hidden border-border/80 bg-muted/30 py-0 shadow-sm md:min-h-[min(75dvh,48rem)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AgentHeader onOpenTools={onOpenTools} />
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
className="flex min-h-0 flex-1 flex-col lg:flex-row"
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<motion.div
|
||||
layout
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col bg-background/60"
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<AnimatePresence mode="sync">
|
||||
{showEmptyState ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="absolute inset-0 flex flex-col"
|
||||
variants={agentFadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<ChatEmptyState
|
||||
onSelectStarter={onStarterSelect}
|
||||
disabled={isSending}
|
||||
className="flex-1"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="thread"
|
||||
className="absolute inset-0 flex flex-col"
|
||||
variants={agentFadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<AgentThread
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
bottomRef={bottomRef}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div layout transition={agentLayoutTransition}>
|
||||
<ChatComposer
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onSubmit={onSubmit}
|
||||
disabled={false}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showPreview && (
|
||||
<AgentPreviewPanel key="preview" className="hidden lg:flex" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ChatMessage, SendMessageOptions } from "./types";
|
||||
|
||||
const MOCK_RESPONSES = [
|
||||
"درخواست شما دریافت شد. این پاسخ آزمایشی است و پس از اتصال به سرویس واقعی، خروجی دقیقتری دریافت خواهید کرد.",
|
||||
"بر اساس متن ارسالی، میتوانم یک خلاصه کوتاه ارائه دهم. در نسخه فعلی، این بخش بهصورت شبیهسازیشده پاسخ میدهد.",
|
||||
"سناریوی انتخابشده برای آزمایش مناسب است. میتوانید تنظیمات مدل و حالت پاسخ را از پنل کناری تغییر دهید.",
|
||||
];
|
||||
|
||||
function pickMockResponse(userText: string): string {
|
||||
const lower = userText.toLowerCase();
|
||||
|
||||
if (lower.includes("خلاصه") || lower.includes("summary")) {
|
||||
return "خلاصه آزمایشی: متن شما شامل چند نکته کلیدی است که میتوان آنها را در قالب گزارش کوتاه ارائه داد. پس از اتصال API، این بخش با مدل واقعی تکمیل میشود.";
|
||||
}
|
||||
|
||||
if (lower.includes("ایمیل") || lower.includes("email")) {
|
||||
return "پیشنویس ایمیل آزمایشی:\n\nبا سلام و احترام،\n\nاز پیام شما سپاسگزاریم. در حال بررسی درخواست هستیم و بهزودی پاسخ کامل ارسال خواهد شد.\n\nبا تشکر";
|
||||
}
|
||||
|
||||
if (lower.includes("پشتیبانی") || lower.includes("مشتری")) {
|
||||
return "پاسخ پیشنهادی پشتیبانی: مشکل شما ثبت شد. لطفاً شماره پیگیری را نگه دارید. تیم ما در اسرع وقت با شما تماس خواهد گرفت.";
|
||||
}
|
||||
|
||||
const index = userText.length % MOCK_RESPONSES.length;
|
||||
return MOCK_RESPONSES[index]!;
|
||||
}
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AI handler — swap this function body for a real API call later.
|
||||
* Example future signature: POST /api/playground/chat with messages + options.
|
||||
*/
|
||||
export async function sendMessageToAI(
|
||||
messages: ChatMessage[],
|
||||
options?: SendMessageOptions
|
||||
): Promise<string> {
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||
const userText = lastUserMessage?.content ?? "";
|
||||
|
||||
const baseDelay = 700 + Math.min(userText.length * 8, 1200);
|
||||
const temperature = options?.settings?.temperature ?? 0.7;
|
||||
const adjustedDelay = Math.round(baseDelay * (0.8 + temperature * 0.4));
|
||||
|
||||
await delay(adjustedDelay, options?.signal);
|
||||
|
||||
return pickMockResponse(userText);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export const AGENT_BREADCRUMB = {
|
||||
parent: "عاملها",
|
||||
current: "دستیار هوشمند",
|
||||
} as const;
|
||||
|
||||
export const AGENT_WORKSPACE_TITLE = "طراحی workflow پشتیبانی — نمونه Q1";
|
||||
|
||||
export const AGENT_WELCOME = {
|
||||
greeting: "سلام! به دستیار فیپ خوش آمدید",
|
||||
subtitle: "دستیار هوشمند شما برای اتوماسیون و بهینهسازی فرایندها",
|
||||
} as const;
|
||||
|
||||
export const AGENT_FEATURE_CARDS = [
|
||||
{
|
||||
id: "workflow",
|
||||
icon: "megaphone" as const,
|
||||
title: "طراحی workflow",
|
||||
description: "امتحان کنید: «یک workflow پاسخگویی خودکار طراحی کن»",
|
||||
prompt: "یک workflow پاسخگویی خودکار برای مشتریان طراحی کن.",
|
||||
},
|
||||
{
|
||||
id: "report",
|
||||
icon: "document" as const,
|
||||
title: "گزارش حساب",
|
||||
description: "گزارشها را در یک دقیقه، خودکار دریافت کنید!",
|
||||
prompt: "یک گزارش خلاصه از وضعیت حسابهای فعال تهیه کن.",
|
||||
},
|
||||
{
|
||||
id: "brief",
|
||||
icon: "sparkles" as const,
|
||||
title: "خلاصه قرارداد",
|
||||
description: "خلاصه قرارداد قدرتمند را فوراً از دستیار دریافت کنید",
|
||||
prompt: "یک خلاصه قرارداد حرفهای برای پروژه جدید بنویس.",
|
||||
},
|
||||
{
|
||||
id: "forecast",
|
||||
icon: "chart" as const,
|
||||
title: "پیشبینی خط لوله",
|
||||
description: "تأثیر سناریو را پیش از اجرا پیشبینی کنید.",
|
||||
prompt: "تأثیر اجرای این سناریو بر خط لوله فروش را پیشبینی کن.",
|
||||
},
|
||||
{
|
||||
id: "personalize",
|
||||
icon: "target" as const,
|
||||
title: "موتور شخصیسازی",
|
||||
description: "بینشهای واقعی را به هر پیام اضافه کنید.",
|
||||
prompt: "این پیام را با بینشهای شخصیسازیشده غنی کن.",
|
||||
},
|
||||
{
|
||||
id: "sequence",
|
||||
icon: "steps" as const,
|
||||
title: "بهینهساز دنباله",
|
||||
description: "پیگیریها را خودکار بهینه کنید.",
|
||||
prompt: "دنباله پیگیریهای این سناریو را بهینه کن.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const AGENT_PROGRESS_STAGES = [
|
||||
"محتوا",
|
||||
"مخاطب",
|
||||
"پیام",
|
||||
"ساختار",
|
||||
"بازبینی",
|
||||
] as const;
|
||||
|
||||
export const AGENT_PREVIEW_TAGS = [
|
||||
"پشتیبانی",
|
||||
"سناریوی آزمایشی",
|
||||
] as const;
|
||||
|
||||
export const AGENT_AUDIENCE_TAGS = {
|
||||
companyType: ["استارتآپ AI", "SaaS"],
|
||||
stage: ["مرحله A", "رشد"],
|
||||
companySize: ["۱۰–۵۰ نفر"],
|
||||
region: ["ایران", "خاورمیانه"],
|
||||
} as const;
|
||||
|
||||
export const GUIDE_USE_CASES = [
|
||||
{
|
||||
title: "آزمایش جریان پرامپت",
|
||||
description: "پرامپتها و دستورالعملها را قبل از استقرار در محصول واقعی بسنجید.",
|
||||
},
|
||||
{
|
||||
title: "تعامل مبتنی بر هوش مصنوعی",
|
||||
description: "نحوه پاسخدهی دستیار را در سناریوهای مختلف کشف و مقایسه کنید.",
|
||||
},
|
||||
{
|
||||
title: "پیشنویس پاسخها",
|
||||
description: "پاسخهای پشتیبانی، ایمیل و پیامهای سازمانی را سریعتر آماده کنید.",
|
||||
},
|
||||
{
|
||||
title: "بررسی خروجی ساختاریافته",
|
||||
description: "نتیجه تعاملات را در قالب لاگ و JSON برای تحلیل بعدی ببینید.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const GUIDE_SCENARIOS = [
|
||||
{
|
||||
title: "پاسخگویی به مشتری",
|
||||
description: "تبدیل پیام مشتری به پاسخ حرفهای و سازگار با برند.",
|
||||
},
|
||||
{
|
||||
title: "خلاصهسازی",
|
||||
description: "استخراج نکات کلیدی از متنها، گزارشها و یادداشتها.",
|
||||
},
|
||||
{
|
||||
title: "بازنویسی محتوا",
|
||||
description: "بهبود لحن، وضوح و ساختار متنهای آماده.",
|
||||
},
|
||||
{
|
||||
title: "ایدهپردازی workflow",
|
||||
description: "طراحی اولیه جریانهای کاری و اتوماسیون مرتبط.",
|
||||
},
|
||||
{
|
||||
title: "استخراج ساختاریافته",
|
||||
description: "آزمایش استخراج داده و فیلدها از متن آزاد.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const GUIDE_BENEFITS = [
|
||||
"ایدهها را پیش از پیادهسازی واقعی آزمایش کنید.",
|
||||
"کیفیت پاسخهای هوش مصنوعی را در سناریوهای مختلف ارزیابی کنید.",
|
||||
"سناریوهای اتوماسیون و دستیار را بدون ریسک محصولی بررسی کنید.",
|
||||
"با تیم خود جریانهای تعاملی آینده را سریعتر طراحی کنید.",
|
||||
] as const;
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
GUIDE_BENEFITS,
|
||||
GUIDE_SCENARIOS,
|
||||
GUIDE_USE_CASES,
|
||||
} from "./playground-content";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function PlaygroundGuide() {
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<section className="py-12 md:py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className={cn("mx-auto max-w-3xl text-center", typeSpacing.sectionStack)}>
|
||||
<h2 className={type.h2}>
|
||||
در آزمایشگاه فیپ چه کارهایی میتوانید انجام دهید؟
|
||||
</h2>
|
||||
<p className={cn(type["body-sm"], "text-muted-foreground md:text-base")}>
|
||||
این بخش برای آشنایی با قابلیتهای آزمایشگاه فیپ است — نه بخشی از
|
||||
تاریخچه گفتگو.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{GUIDE_USE_CASES.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-2xl border bg-card/80 p-5"
|
||||
>
|
||||
<h3 className={type["card-title"]}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={cn(type["card-desc"], "mt-2")}>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t bg-background py-12 md:py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className={cn("mx-auto max-w-3xl text-center", typeSpacing.sectionStack)}>
|
||||
<h2 className={type.h2}>
|
||||
سناریوهای قابل آزمایش
|
||||
</h2>
|
||||
<p className={cn(type["body-sm"], "text-muted-foreground md:text-base")}>
|
||||
نمونههایی از حوزههایی که میتوانید در گفتگو امتحان کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{GUIDE_SCENARIOS.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-xl border bg-muted/30 p-4"
|
||||
>
|
||||
<h3 className={type["card-title"]}>{item.title}</h3>
|
||||
<p className={cn(type["body-sm"], "mt-1.5 text-muted-foreground")}>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t bg-muted/30 py-12 md:py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border bg-card p-6 md:p-8">
|
||||
<h2 className={type.h2}>
|
||||
چرا آزمایشگاه فیپ مهم است؟
|
||||
</h2>
|
||||
<p className={cn(type["body-sm"], "mt-3 text-muted-foreground md:text-base")}>
|
||||
قبل از اتصال به سیستمهای واقعی، تیمها میتوانند ایدهها و پاسخها
|
||||
را در فضایی امن و کنترلشده ارزیابی کنند.
|
||||
</p>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{GUIDE_BENEFITS.map((benefit) => (
|
||||
<li
|
||||
key={benefit}
|
||||
className={cn("flex gap-2", type.body, "text-muted-foreground md:text-base")}
|
||||
>
|
||||
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Braces } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PlaygroundOutputPanel } from "./playground-output-panel";
|
||||
import type { InteractionLog } from "./types";
|
||||
|
||||
interface PlaygroundOutputCardProps {
|
||||
latestLog: InteractionLog | null;
|
||||
logs: InteractionLog[];
|
||||
}
|
||||
|
||||
export function PlaygroundOutputCard({ latestLog, logs }: PlaygroundOutputCardProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Braces className="size-4" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>خروجی ساختاریافته</CardTitle>
|
||||
<CardDescription>
|
||||
پیشنمایش آخرین تعامل و لاگ اجرا
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<PlaygroundOutputPanel latestLog={latestLog} logs={logs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Terminal } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InteractionLog } from "./types";
|
||||
|
||||
interface PlaygroundOutputPanelProps {
|
||||
latestLog: InteractionLog | null;
|
||||
logs: InteractionLog[];
|
||||
}
|
||||
|
||||
export function PlaygroundOutputPanel({ latestLog, logs }: PlaygroundOutputPanelProps) {
|
||||
const preview = latestLog
|
||||
? {
|
||||
interactionId: latestLog.id,
|
||||
model: latestLog.model,
|
||||
mode: latestLog.responseMode,
|
||||
durationMs: latestLog.durationMs,
|
||||
user: latestLog.userMessage,
|
||||
assistant: latestLog.assistantMessage,
|
||||
timestamp: latestLog.timestamp,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border bg-muted/30 p-3">
|
||||
<div className={cn("mb-2 flex items-center gap-2", type.caption)}>
|
||||
<Terminal className="size-3.5" aria-hidden />
|
||||
<span>آخرین پاسخ</span>
|
||||
</div>
|
||||
{preview ? (
|
||||
<pre className={cn("max-h-52 overflow-auto rounded-lg bg-background/80 p-3 text-foreground", type.code)}>
|
||||
{JSON.stringify(preview, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className={cn("rounded-lg bg-background/80 p-3", type.caption)}>
|
||||
هنوز تعاملی ثبت نشده است. یک پیام ارسال کنید تا خروجی ساختاریافته اینجا
|
||||
نمایش داده شود.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className={cn(type.caption, "font-medium")}>لاگ اجرا</p>
|
||||
<ScrollArea className="h-48 rounded-xl border bg-muted/20 sm:h-56">
|
||||
<div className="space-y-2 p-3">
|
||||
{logs.length === 0 ? (
|
||||
<p className={type.caption}>لاگی برای نمایش وجود ندارد.</p>
|
||||
) : (
|
||||
logs
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn("rounded-lg border bg-background/70 px-3 py-2", type.code)}
|
||||
>
|
||||
<p className="font-medium text-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString("fa-IR")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
مدل: {log.model} · مدت: {log.durationMs}ms
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 break-words text-foreground/80">
|
||||
{log.userMessage}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const PROMPT_STARTERS = [
|
||||
{
|
||||
id: "auto-support",
|
||||
label: "طراحی سناریو پاسخگویی خودکار",
|
||||
prompt: "برای پاسخگویی خودکار به مشتریان یک سناریو طراحی کن.",
|
||||
},
|
||||
{
|
||||
id: "summarize",
|
||||
label: "خلاصهسازی متن",
|
||||
prompt: "این متن را خلاصه کن:\n\n[متن خود را اینجا قرار دهید]",
|
||||
},
|
||||
{
|
||||
id: "workflow",
|
||||
label: "پیشنهاد workflow",
|
||||
prompt: "برای این فرایند یک workflow پیشنهاد بده:\n\n[فرایند موردنظر]",
|
||||
},
|
||||
{
|
||||
id: "support-reply",
|
||||
label: "تبدیل پیام مشتری به پاسخ حرفهای",
|
||||
prompt: "این پیام مشتری را به پاسخ حرفهای تبدیل کن:\n\n[پیام مشتری]",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const PLAYGROUND_SCENARIOS = [
|
||||
...PROMPT_STARTERS,
|
||||
{
|
||||
id: "email",
|
||||
label: "پیشنویس ایمیل",
|
||||
prompt: "یک ایمیل رسمی و محترمانه برای پیگیری درخواست مشتری بنویس.",
|
||||
},
|
||||
{
|
||||
id: "analyze",
|
||||
label: "تحلیل داده",
|
||||
prompt: "این دادههای فروش ماهانه را تحلیل کن و سه بینش کلیدی ارائه بده.",
|
||||
},
|
||||
{
|
||||
id: "rewrite",
|
||||
label: "بازنویسی محتوا",
|
||||
prompt: "متن زیر را با لحن حرفهایتر و روانتر بازنویسی کن.",
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import { PLAYGROUND_SCENARIOS } from "./playground-prompts";
|
||||
|
||||
export { PLAYGROUND_SCENARIOS };
|
||||
|
||||
interface PlaygroundScenariosCardProps {
|
||||
onSelectScenario: (scenarioId: string, prompt: string) => void;
|
||||
disabled?: boolean;
|
||||
activeScenarioId?: string | null;
|
||||
}
|
||||
|
||||
export function PlaygroundScenariosCard({
|
||||
onSelectScenario,
|
||||
disabled = false,
|
||||
activeScenarioId = null,
|
||||
}: PlaygroundScenariosCardProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Wand2 className="size-4" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>سناریوهای آماده</CardTitle>
|
||||
<CardDescription>
|
||||
شروع سریع با پرامپتهای نمونه
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLAYGROUND_SCENARIOS.map((scenario) => (
|
||||
<Button
|
||||
key={scenario.id}
|
||||
type="button"
|
||||
variant={activeScenarioId === scenario.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelectScenario(scenario.id, scenario.prompt)}
|
||||
className={cn(
|
||||
"h-auto min-h-8 whitespace-normal rounded-full px-3 py-1.5",
|
||||
type.caption,
|
||||
activeScenarioId === scenario.id && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
{scenario.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Settings2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PlaygroundSettingsPanel } from "./playground-settings-panel";
|
||||
import type { PlaygroundSettings } from "./types";
|
||||
|
||||
interface PlaygroundSettingsCardProps {
|
||||
settings: PlaygroundSettings;
|
||||
onChange: (settings: PlaygroundSettings) => void;
|
||||
}
|
||||
|
||||
export function PlaygroundSettingsCard({
|
||||
settings,
|
||||
onChange,
|
||||
}: PlaygroundSettingsCardProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Settings2 className="size-4" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>تنظیمات گفتگو</CardTitle>
|
||||
<CardDescription>
|
||||
پیکربندی مدل و رفتار پاسخدهی
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<PlaygroundSettingsPanel settings={settings} onChange={onChange} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PlaygroundSettings } from "./types";
|
||||
import { type } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlaygroundSettingsPanelProps {
|
||||
settings: PlaygroundSettings;
|
||||
onChange: (settings: PlaygroundSettings) => void;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function PlaygroundSettingsPanel({
|
||||
settings,
|
||||
onChange,
|
||||
idPrefix = "playground",
|
||||
}: PlaygroundSettingsPanelProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-model`}>مدل</Label>
|
||||
<Select
|
||||
value={settings.model}
|
||||
onValueChange={(model) => onChange({ ...settings, model })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-model`} className="w-full">
|
||||
<SelectValue placeholder="انتخاب مدل" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fip-assistant">دستیار فیپ (آزمایشی)</SelectItem>
|
||||
<SelectItem value="fip-fast">فیپ سریع</SelectItem>
|
||||
<SelectItem value="fip-pro">فیپ پیشرفته</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-mode`}>حالت پاسخ</Label>
|
||||
<Select
|
||||
value={settings.responseMode}
|
||||
onValueChange={(responseMode) => onChange({ ...settings, responseMode })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-mode`} className="w-full">
|
||||
<SelectValue placeholder="انتخاب حالت" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="balanced">متعادل</SelectItem>
|
||||
<SelectItem value="concise">مختصر</SelectItem>
|
||||
<SelectItem value="detailed">مفصل</SelectItem>
|
||||
<SelectItem value="structured">ساختاریافته</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`${idPrefix}-temperature`}>خلاقیت پاسخ</Label>
|
||||
<span className={cn(type.caption, "tabular-nums")}>
|
||||
{settings.temperature.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
id={`${idPrefix}-temperature`}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={[settings.temperature]}
|
||||
onValueChange={([temperature]) =>
|
||||
onChange({ ...settings, temperature: temperature ?? 0.7 })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border bg-muted/20 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`${idPrefix}-memory`} className={type.label}>
|
||||
حافظه گفتگو
|
||||
</Label>
|
||||
<p className={type.caption}>
|
||||
نگهداری زمینه مکالمه در تعاملات بعدی
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${idPrefix}-memory`}
|
||||
checked={settings.memoryEnabled}
|
||||
onCheckedChange={(memoryEnabled) =>
|
||||
onChange({ ...settings, memoryEnabled })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={cn("rounded-lg border border-dashed bg-muted/10 p-3", type.caption)}>
|
||||
این تنظیمات فعلاً برای آزمایش رابط کاربری فعال هستند و پس از اتصال به سرویس
|
||||
واقعی، مستقیماً روی پاسخها اعمال میشوند.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type } from "@/lib/typography";
|
||||
import { PlaygroundOutputPanel } from "./playground-output-panel";
|
||||
import { PlaygroundSettingsPanel } from "./playground-settings-panel";
|
||||
import { PLAYGROUND_SCENARIOS } from "./playground-prompts";
|
||||
import type { InteractionLog, PlaygroundSettings } from "./types";
|
||||
|
||||
export type ToolsTab = "settings" | "output" | "scenarios";
|
||||
|
||||
interface PlaygroundToolsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
activeTab: ToolsTab;
|
||||
onTabChange: (tab: ToolsTab) => void;
|
||||
settings: PlaygroundSettings;
|
||||
onSettingsChange: (settings: PlaygroundSettings) => void;
|
||||
latestLog: InteractionLog | null;
|
||||
logs: InteractionLog[];
|
||||
onSelectScenario: (scenarioId: string, prompt: string) => void;
|
||||
activeScenarioId?: string | null;
|
||||
}
|
||||
|
||||
export function PlaygroundToolsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
latestLog,
|
||||
logs,
|
||||
onSelectScenario,
|
||||
activeScenarioId = null,
|
||||
}: PlaygroundToolsSheetProps) {
|
||||
function handleScenarioSelect(scenarioId: string, prompt: string) {
|
||||
onSelectScenario(scenarioId, prompt);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 py-4 text-start">
|
||||
<SheetTitle>ابزارهای آزمایش</SheetTitle>
|
||||
<SheetDescription>تنظیمات، خروجی و کتابخانه سناریوها</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => onTabChange(value as ToolsTab)}
|
||||
className="flex min-h-0 flex-1 flex-col gap-0"
|
||||
>
|
||||
<div className="border-b px-4 py-3">
|
||||
<TabsList className="grid h-9 w-full grid-cols-3">
|
||||
<TabsTrigger value="settings" className={type["body-sm"]}>
|
||||
تنظیمات
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="output" className={type["body-sm"]}>
|
||||
خروجی
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scenarios" className={type["body-sm"]}>
|
||||
سناریوها
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<TabsContent value="settings" className="mt-0">
|
||||
<PlaygroundSettingsPanel
|
||||
settings={settings}
|
||||
onChange={onSettingsChange}
|
||||
idPrefix="sheet"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="output" className="mt-0">
|
||||
<PlaygroundOutputPanel latestLog={latestLog} logs={logs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scenarios" className="mt-0">
|
||||
<div className="space-y-3">
|
||||
<p className={cn(type["body-sm"], "text-muted-foreground")}>
|
||||
انتخاب سناریو، پرامپت را در کادر پیام قرار میدهد تا بتوانید قبل از
|
||||
ارسال آن را ویرایش کنید.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{PLAYGROUND_SCENARIOS.map((scenario) => (
|
||||
<Button
|
||||
key={scenario.id}
|
||||
type="button"
|
||||
variant={activeScenarioId === scenario.id ? "default" : "outline"}
|
||||
className={cn(
|
||||
"h-auto justify-start whitespace-normal px-3 py-2.5 text-start",
|
||||
type["body-sm"],
|
||||
activeScenarioId === scenario.id && "shadow-sm"
|
||||
)}
|
||||
onClick={() => handleScenarioSelect(scenario.id, scenario.prompt)}
|
||||
>
|
||||
{scenario.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
import { sendMessageToAI } from "./mock-ai";
|
||||
import { PlaygroundToolsSheet } from "./playground-tools-sheet";
|
||||
import type { ToolsTab } from "./playground-tools-sheet";
|
||||
import type { ChatMessage, InteractionLog, PlaygroundSettings } from "./types";
|
||||
|
||||
const DEFAULT_SETTINGS: PlaygroundSettings = {
|
||||
model: "fip-assistant",
|
||||
responseMode: "balanced",
|
||||
temperature: 0.7,
|
||||
memoryEnabled: true,
|
||||
};
|
||||
|
||||
function createMessage(role: ChatMessage["role"], content: string): ChatMessage {
|
||||
return {
|
||||
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
"fip-assistant": "دستیار فیپ (آزمایشی)",
|
||||
"fip-fast": "فیپ سریع",
|
||||
"fip-pro": "فیپ پیشرفته",
|
||||
};
|
||||
|
||||
export function PlaygroundWorkspace() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [settings, setSettings] = useState<PlaygroundSettings>(DEFAULT_SETTINGS);
|
||||
const [logs, setLogs] = useState<InteractionLog[]>([]);
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null);
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [toolsTab, setToolsTab] = useState<ToolsTab>("settings");
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages, isTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isSending) return;
|
||||
|
||||
const userMessage = createMessage("user", trimmed);
|
||||
const nextMessages = [...messages, userMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setInputValue("");
|
||||
setIsSending(true);
|
||||
setIsTyping(true);
|
||||
setActiveScenarioId(null);
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const assistantText = await sendMessageToAI(nextMessages, {
|
||||
settings,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const assistantMessage = createMessage("assistant", assistantText);
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
const log: InteractionLog = {
|
||||
id: `log-${Date.now()}`,
|
||||
userMessage: trimmed,
|
||||
assistantMessage: assistantText,
|
||||
model: MODEL_LABELS[settings.model] ?? settings.model,
|
||||
responseMode: settings.responseMode,
|
||||
timestamp: new Date().toISOString(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
|
||||
setLogs((prev) => [...prev, log]);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
|
||||
const errorMessage = createMessage(
|
||||
"assistant",
|
||||
"متأسفانه در پردازش درخواست خطایی رخ داد. لطفاً دوباره تلاش کنید."
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[isSending, messages, settings]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
void submitMessage(inputValue);
|
||||
}, [inputValue, submitMessage]);
|
||||
|
||||
function handleStarterSelect(_id: string, prompt: string) {
|
||||
void submitMessage(prompt);
|
||||
}
|
||||
|
||||
function handleScenarioSelect(scenarioId: string, prompt: string) {
|
||||
setInputValue(prompt);
|
||||
setActiveScenarioId(scenarioId);
|
||||
}
|
||||
|
||||
function handleOpenTools(tab: ToolsTab) {
|
||||
setToolsTab(tab);
|
||||
setToolsOpen(true);
|
||||
}
|
||||
|
||||
const latestLog = logs.length > 0 ? logs[logs.length - 1]! : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onStarterSelect={handleStarterSelect}
|
||||
onOpenTools={handleOpenTools}
|
||||
isSending={isSending}
|
||||
isTyping={isTyping}
|
||||
bottomRef={bottomRef}
|
||||
/>
|
||||
|
||||
<PlaygroundToolsSheet
|
||||
open={toolsOpen}
|
||||
onOpenChange={setToolsOpen}
|
||||
activeTab={toolsTab}
|
||||
onTabChange={setToolsTab}
|
||||
settings={settings}
|
||||
onSettingsChange={setSettings}
|
||||
latestLog={latestLog}
|
||||
logs={logs}
|
||||
onSelectScenario={handleScenarioSelect}
|
||||
activeScenarioId={activeScenarioId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type MessageRole = "user" | "assistant";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface PlaygroundSettings {
|
||||
model: string;
|
||||
responseMode: string;
|
||||
temperature: number;
|
||||
memoryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
settings?: PlaygroundSettings;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface InteractionLog {
|
||||
id: string;
|
||||
userMessage: string;
|
||||
assistantMessage: string;
|
||||
model: string;
|
||||
responseMode: string;
|
||||
timestamp: string;
|
||||
durationMs: number;
|
||||
}
|
||||
+30
-28
@@ -1,6 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, FileText, Code, Settings, Users, Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type, typeSpacing } from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Pricing() {
|
||||
return (
|
||||
@@ -8,15 +10,15 @@ export default function Pricing() {
|
||||
{/* Hero Section */}
|
||||
<section className="bg-background pt-32 pb-20 md:pt-40 md:pb-28">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center space-y-6">
|
||||
<h1 className="text-4xl font-bold md:text-5xl lg:text-6xl leading-tight tracking-normal">
|
||||
<div className={cn("text-center", typeSpacing.heroStack)}>
|
||||
<h1 className={type.h1}>
|
||||
قیمتگذاری
|
||||
</h1>
|
||||
<div className="space-y-4 max-w-3xl mx-auto">
|
||||
<p className="text-xl leading-relaxed tracking-normal text-muted-foreground">
|
||||
<div className={cn(typeSpacing.sectionStack, "max-w-3xl mx-auto")}>
|
||||
<p className={cn(type["body-lg"], "text-muted-foreground")}>
|
||||
هر راهحل با توجه به پیچیدگی، شرایط اجرا و نیازمندیهای خاص شما طراحی میشود. به همین دلیل، قیمتگذاری ما کاملاً سفارشی و متناسب با شرایط واقعی کسبوکار شماست.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed tracking-normal text-foreground font-medium">
|
||||
<p className={cn(type["section-intro"], "text-foreground font-medium")}>
|
||||
راهحلهای ما از <span className="text-primary font-bold">۲ میلیون تومان</span> شروع میشوند و بر اساس نیازهای دقیق شما محاسبه میگردند.
|
||||
</p>
|
||||
</div>
|
||||
@@ -29,10 +31,10 @@ export default function Pricing() {
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="space-y-12">
|
||||
<div className="text-center space-y-4 max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
|
||||
<h2 className={type.h2}>
|
||||
راهحلها بر اساس چه عواملی قیمتگذاری میشوند؟
|
||||
</h2>
|
||||
<p className="text-lg leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={type["section-intro"]}>
|
||||
ما متعهد هستیم که راهحلهای شما را در <strong className="text-foreground">کمترین زمان ممکن</strong> و با <strong className="text-foreground">بهترین قیمت</strong> ارائه دهیم.
|
||||
قیمتگذاری ما بر اساس نیازهای خاص شما انجام میشود و عوامل مختلفی در تعیین قیمت نهایی نقش دارند.
|
||||
</p>
|
||||
@@ -44,9 +46,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<FileText className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">نوع سرویس</h3>
|
||||
<h3 className={type.h3}>نوع سرویس</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
نوع سرویس شما یکی از مهمترین عوامل در تعیین قیمت است. سرویسهای یکپارچهسازی،
|
||||
اتوماسیون، ارتباطات چندکاناله و مدیریت دادهها هر کدام نیازمندیها و پیچیدگیهای خاص خود را دارند.
|
||||
</p>
|
||||
@@ -57,9 +59,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<Code className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">پیچیدگی راهحل</h3>
|
||||
<h3 className={type.h3}>پیچیدگی راهحل</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
میزان پیچیدگی فنی راهحل، تعداد سیستمهای متصل، حجم دادهها و نیاز به سفارشیسازی
|
||||
از عوامل مهم در تعیین قیمت هستند. راهحلهای پیچیدهتر نیاز به زمان و تخصص بیشتری دارند.
|
||||
</p>
|
||||
@@ -70,9 +72,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<Settings className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">اجرا و پیادهسازی</h3>
|
||||
<h3 className={type.h3}>اجرا و پیادهسازی</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
مراحل اجرا و پیادهسازی شامل طراحی، توسعه، تست و استقرار میشود.
|
||||
میزان زمان و منابع مورد نیاز برای هر مرحله در قیمت نهایی تاثیر دارد.
|
||||
</p>
|
||||
@@ -83,9 +85,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<Users className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">تعداد کاربران</h3>
|
||||
<h3 className={type.h3}>تعداد کاربران</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
تعداد کاربرانی که از سیستم استفاده میکنند و حجم ترافیک مورد انتظار
|
||||
در تعیین قیمت موثر است. سیستمهای با کاربران بیشتر نیاز به زیرساخت قویتری دارند.
|
||||
</p>
|
||||
@@ -96,9 +98,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<Clock className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">بازه زمانی</h3>
|
||||
<h3 className={type.h3}>بازه زمانی</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
بازه زمانی استفاده از سرویس (ماهانه، سالانه) و مدت زمان مورد نیاز برای پیادهسازی راهحل
|
||||
در قیمتگذاری تاثیر دارد. قراردادهای بلندمدت معمولاً شامل تخفیفهای ویژه میشوند.
|
||||
</p>
|
||||
@@ -109,9 +111,9 @@ export default function Pricing() {
|
||||
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0 transition-transform duration-300 group-hover:scale-110">
|
||||
<Settings className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold leading-tight">پشتیبانی و نگهداری</h3>
|
||||
<h3 className={type.h3}>پشتیبانی و نگهداری</h3>
|
||||
</div>
|
||||
<p className="leading-relaxed tracking-normal text-muted-foreground">
|
||||
<p className={cn(type.body, "text-muted-foreground")}>
|
||||
نیاز به پشتیبانی فنی، بهروزرسانیها، نگهداری و مستندات کامل
|
||||
از عوامل مهم در تعیین قیمت هستند. پشتیبانی ۲۴/۷ و نگهداری مستمر نیاز به منابع بیشتری دارد.
|
||||
</p>
|
||||
@@ -127,18 +129,18 @@ export default function Pricing() {
|
||||
<div className="bg-card relative rounded-3xl border dark:border-transparent shadow-2xl shadow-zinc-950/5">
|
||||
<div className="grid items-center gap-12 p-12 md:grid-cols-2">
|
||||
<div className="pb-12 text-center md:pb-0 md:pe-12">
|
||||
<h3 className="text-3xl font-bold leading-tight tracking-normal mb-4">راهحل سازمانی</h3>
|
||||
<p className="text-lg leading-relaxed tracking-normal text-muted-foreground mb-8">
|
||||
<h3 className={cn(type.h2, "mb-4")}>راهحل سازمانی</h3>
|
||||
<p className={cn(type["section-intro"], "mb-8")}>
|
||||
برای شرکت شما با هر اندازهای
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed tracking-normal mb-8">
|
||||
<p className={cn(type.body, "text-muted-foreground mb-8")}>
|
||||
ما راهکارهای سفارشی برای کسبوکارهای مختلف ارائه میدهیم.
|
||||
تیم ما آماده است تا بر اساس نیازهای خاص شما، بهترین راهحل را طراحی کند.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<h4 className="text-xl font-bold mb-6 leading-tight">ویژگیهای راهحل سازمانی</h4>
|
||||
<h4 className={cn(type.h4, "mb-6")}>ویژگیهای راهحل سازمانی</h4>
|
||||
<ul role="list" className="space-y-4">
|
||||
{[
|
||||
"تجمیع پایگاه داده و یکپارچهسازی",
|
||||
@@ -150,9 +152,9 @@ export default function Pricing() {
|
||||
"ذخیرهسازی نامحدود",
|
||||
"API و Webhooks نامحدود"
|
||||
].map((item, index) => (
|
||||
<li key={index} className="flex items-center gap-3 leading-relaxed tracking-normal">
|
||||
<li key={index} className={cn("flex items-center gap-3", type.body)}>
|
||||
<Check className="size-5 text-primary shrink-0" />
|
||||
<span className="text-base">{item}</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -166,16 +168,16 @@ export default function Pricing() {
|
||||
<section className="bg-muted/30 py-20 md:py-28">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center space-y-8 p-12 rounded-2xl border dark:border-transparent bg-gradient-to-br from-primary/5 to-primary/10">
|
||||
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
|
||||
<h2 className={type["cta-title"]}>
|
||||
برای اطلاع از قیمت با ما تماس بگیرید
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
|
||||
برای دریافت قیمت دقیق و مشاوره رایگان، با تیم ما در تماس باشید.
|
||||
ما آمادهایم تا بر اساس نیازهای شما بهترین پیشنهاد را ارائه دهیم.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button asChild size="lg">
|
||||
<Link href="#chat">شروع گفتگو با تیم</Link>
|
||||
<Link href="/consulting#consulting-form">شروع گفتگو با تیم</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline">
|
||||
<Link href="/about">درباره ما</Link>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
@@ -18,10 +17,11 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle theme"
|
||||
size="icon-lg"
|
||||
className="rounded-lg"
|
||||
aria-label="تغییر تم"
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
<Sun className="size-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -29,12 +29,13 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
className="rounded-lg"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
aria-label="Toggle theme"
|
||||
aria-label="تغییر تم"
|
||||
>
|
||||
<Sun className="size-4 rotate-0 scale-100 transition-all duration-300 dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute size-4 rotate-90 scale-0 transition-all duration-300 dark:rotate-0 dark:scale-100" />
|
||||
<Sun className="size-5 rotate-0 scale-100 transition-all duration-300 dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute size-5 rotate-90 scale-0 transition-all duration-300 dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const buttonVariants = cva(
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-foreground dark:hover:bg-[#FCC61D] dark:hover:text-foreground",
|
||||
link:
|
||||
"text-primary underline-offset-4 hover:text-accent hover:underline dark:hover:text-[#FCC61D]",
|
||||
"text-accent underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 text-sm has-[>svg]:px-3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type as typeScale } from "@/lib/typography"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -32,7 +33,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn(typeScale["card-title"], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -42,7 +43,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn(typeScale["card-desc"], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
"text-muted-foreground [&>a]:text-accent text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 hover:[&>a]:no-underline",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -145,7 +145,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
"[&>a]:text-accent [&>a]:underline [&>a]:underline-offset-4 hover:[&>a]:no-underline",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -135,7 +135,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
"[&>a]:text-accent [&>a]:underline [&>a]:underline-offset-4 hover:[&>a]:no-underline",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -48,9 +48,12 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
closePosition = "end",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
/** Physical corner for the close button. Use `start` for left in LTR / RTL layouts. */
|
||||
closePosition?: "start" | "end"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -72,7 +75,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<SheetPrimitive.Close
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none",
|
||||
closePosition === "start" ? "left-4" : "right-4"
|
||||
)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
import {
|
||||
typographyVariants,
|
||||
type TypographyVariant,
|
||||
} from "@/lib/typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const defaultElement: Record<TypographyVariant, React.ElementType> = {
|
||||
display: "h1",
|
||||
h1: "h1",
|
||||
h2: "h2",
|
||||
h3: "h3",
|
||||
h4: "h4",
|
||||
"body-lg": "p",
|
||||
body: "p",
|
||||
"body-sm": "p",
|
||||
caption: "span",
|
||||
eyebrow: "p",
|
||||
label: "span",
|
||||
nav: "span",
|
||||
"nav-mobile": "span",
|
||||
"card-title": "h3",
|
||||
"card-desc": "p",
|
||||
chat: "p",
|
||||
code: "code",
|
||||
stat: "span",
|
||||
link: "span",
|
||||
"section-intro": "p",
|
||||
emphasis: "p",
|
||||
"list-title": "span",
|
||||
"cta-title": "h2",
|
||||
"display-mono": "span",
|
||||
};
|
||||
|
||||
export type TypographyProps = {
|
||||
variant: TypographyVariant;
|
||||
as?: React.ElementType;
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLElement>;
|
||||
|
||||
export function Typography({
|
||||
variant,
|
||||
as,
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TypographyProps) {
|
||||
const Comp = asChild ? Slot : (as ?? defaultElement[variant] ?? "p");
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(typographyVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Display,
|
||||
PageTitle,
|
||||
SectionTitle,
|
||||
SubsectionTitle,
|
||||
SmallHeading,
|
||||
BodyLarge,
|
||||
BodyText,
|
||||
BodySmall,
|
||||
Caption,
|
||||
Eyebrow,
|
||||
CardTitleText,
|
||||
CardDescText,
|
||||
};
|
||||
|
||||
function Display(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="display" {...props} />;
|
||||
}
|
||||
|
||||
function PageTitle(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="h1" {...props} />;
|
||||
}
|
||||
|
||||
function SectionTitle(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="h2" {...props} />;
|
||||
}
|
||||
|
||||
function SubsectionTitle(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="h3" {...props} />;
|
||||
}
|
||||
|
||||
function SmallHeading(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="h4" {...props} />;
|
||||
}
|
||||
|
||||
function BodyLarge(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="body-lg" {...props} />;
|
||||
}
|
||||
|
||||
function BodyText(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="body" {...props} />;
|
||||
}
|
||||
|
||||
function BodySmall(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="body-sm" {...props} />;
|
||||
}
|
||||
|
||||
function Caption(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="caption" {...props} />;
|
||||
}
|
||||
|
||||
function Eyebrow(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="eyebrow" {...props} />;
|
||||
}
|
||||
|
||||
function CardTitleText(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="card-title" {...props} />;
|
||||
}
|
||||
|
||||
function CardDescText(props: Omit<TypographyProps, "variant">) {
|
||||
return <Typography variant="card-desc" {...props} />;
|
||||
}
|
||||
Reference in New Issue
Block a user