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:
alirez.br
2026-07-02 02:46:20 +03:30
co-authored by Cursor
parent 62163c4ab9
commit a47ec493c5
92 changed files with 4574 additions and 507 deletions
+248
View File
@@ -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>
);
}
+7
View File
@@ -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",
};
+15 -12
View File
@@ -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>
+6 -4
View File
@@ -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>
+9 -7
View File
@@ -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>
+4 -1
View File
@@ -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
)}
>
+19 -8
View File
@@ -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>
+6
View File
@@ -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",
};
+65
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
+6 -4
View File
@@ -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>
+14
View File
@@ -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) }}
/>
);
}
+60
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+11 -3
View File
@@ -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
View File
@@ -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>
+9 -7
View File
@@ -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
View File
@@ -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">
+154
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" />
+109
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+385
View File
@@ -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 />;
}
}
+156
View File
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+256
View File
@@ -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>
);
}
+56
View File
@@ -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);
}
+125
View File
@@ -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}
/>
</>
);
}
+30
View File
@@ -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
View File
@@ -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>
+9 -8
View File
@@ -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>
)
}
+1 -1
View File
@@ -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",
+3 -2
View File
@@ -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}
/>
)
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+9 -1
View File
@@ -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>
+126
View File
@@ -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} />;
}