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
+75
View File
@@ -0,0 +1,75 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.0] - 2026-07-02
### Added
- Version History drawer opened from the footer version label.
- Categorized release notes UI with Added, Improved, and Fixed sections.
- Current version indicator badge on the latest release card.
- Hardcoded release data (`lib/changelog-releases.ts`) for the drawer.
### Changed
- Refined drawer layout, card typography, and spacing between release sections.
- Improved release date formatting and left-aligned changelog text.
### Fixed
- Empty version descriptions caused by client-side changelog file parsing.
- Scrolling issues inside the version history drawer.
- Server-only `fs` import errors by loading changelog data in the root layout.
## [0.2.0] - 2026-07-02
### Added
- Introduced a site-wide typography scale (`type.h1`, `type.body`, `type.nav`, etc.) so headings, paragraphs, and navigation look consistent across all pages.
- Built new blog blockquote templates with yellow, green, and gray color variants — side accent bar, quote icon, soft background, and decorative shapes.
- Added pullquote styling for centered highlight quotes with a larger type size and icon above the text.
- Shipped `BlogTable` with two visual themes (green and yellow): glass-style header, zebra rows, row hover, bold first column, and horizontal scroll on small screens.
- Added centralized `.blog-content` styles for article prose — heading sizes, list spacing, link colors, `my-6` image margins, rounded image shadows, and `scroll-mt-24` for TOC anchor jumps.
### Changed
- Replaced home page hero images with updated `1.jpg` / `1dark.jpg` assets; synced horizontal gutters and max-width with the header bar.
- Swapped all four features-accordion preview images (integration, panels, social, automation) plus their dark-mode variants.
- Replaced the ecosystem section image with `intelligent-workflow-control-center` light/dark JPEGs and a fixed `aspect-[3/2]` frame.
- Refactored the single blog post page: removed long inline Tailwind classes from the article element and moved prose rules into `globals.css`.
- Wired pullquote and WordPress table blocks into the post content renderer — they were previously ignored.
- Redesigned the site header: centered desktop navigation, mobile drawer next to the theme toggle, and width aligned with the hero container.
- Updated hero CTA and integrations links from placeholder `#` anchors to `/solutions`.
### Fixed
- WordPress pullquotes did not render on single post pages.
- Blockquote HTML from WordPress could contain unsafe markup — content is now sanitized before display.
- Background page kept scrolling while the mobile navigation menu was open.
- Header bar width and padding did not match page content at several breakpoints.
- Related posts section crashed the entire post page when the WordPress API returned an error.
- Featured image aspect ratio and top spacing were wrong on mobile blog posts.
- SEO metadata, Open Graph tags, 404 layout, and crawler-facing text on key pages.
- Placeholder links on the home page integrations strip pointed nowhere.
### Removed
- Deleted obsolete home page image files: `Main_pic.jpg`, `Main_pic-min.jpg`, `Untitled-1 copy-min.jpg`, `main-pic.jpg`.
## [0.1.0] - 2026-06-01
### Added
- Launched the Feep marketing site with Persian RTL layout and core pages: home, about, solutions, consulting, and blog.
- Connected headless WordPress: post listing, single article view, featured images, and related posts.
- Built blog reading tools: table of contents, reading time estimate, scroll-to-top button, and scroll progress.
- Implemented custom WordPress block rendering: accordion (collapse), blockquote, and tip components.
- Added a featured posts grid on the blog index and Docker standalone build configuration.
[0.3.0]: https://github.com/feep/next-ctrlp/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/feep/next-ctrlp/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/feep/next-ctrlp/releases/tag/v0.1.0
+47 -45
View File
@@ -5,6 +5,8 @@ export const metadata = {
};
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 AboutPage() {
return (
@@ -12,11 +14,11 @@ export default function AboutPage() {
{/* 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>
<p className="text-xl leading-relaxed tracking-normal text-muted-foreground max-w-2xl mx-auto">
<p className={cn(type["body-lg"], "text-muted-foreground max-w-2xl mx-auto")}>
ما یک گروه متخصص هستیم که در حوزه اینتگریشن و روش‌های نوین یکپارچه‌سازی فعالیت می‌کنیم.
هدف ما ارائه راهکارهای هوشمند و کارآمد برای اتصال سیستم‌ها، داده‌ها و فرآیندهای کسب‌وکار است.
</p>
@@ -29,16 +31,16 @@ export default function AboutPage() {
<div className="mx-auto max-w-6xl px-6">
<div className="grid gap-12 lg:grid-cols-2 items-center">
<div className="space-y-6">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
داستان ما
</h2>
<div className="space-y-4 text-muted-foreground leading-relaxed tracking-normal">
<p>
<div className={cn(typeSpacing.paragraphStack, "text-muted-foreground")}>
<p className={type.body}>
فیپ یک گروه متخصص است که در حوزه اینتگریشن و روش‌های نوین یکپارچه‌سازی فعالیت می‌کند.
ما با استفاده از تکنولوژی‌های پیشرفته و رویکردهای نوآورانه، به کسب‌وکارها کمک می‌کنیم تا
سیستم‌های خود را به شکلی کارآمد و قابل‌اعتماد بهم متصل کنند.
</p>
<p>
<p className={type.body}>
ما در زمینه یکپارچه‌سازی سیستم‌ها، اتوماسیون فرآیندها، ارتباطات چندکاناله و مدیریت داده‌ها
تخصص داریم. با استفاده از روش‌های نوین و ابزارهای پیشرفته، راهکارهای سفارشی برای هر کسب‌وکار
طراحی و پیاده‌سازی می‌کنیم.
@@ -47,8 +49,8 @@ export default function AboutPage() {
</div>
<div className="relative aspect-square rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 p-8 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="text-5xl font-bold text-primary">۱۰+</div>
<div className="text-lg font-medium">سال تجربه</div>
<div className={type.stat}>۱۰+</div>
<div className={cn(type["body-lg"], "font-medium text-foreground")}>سال تجربه</div>
</div>
</div>
</div>
@@ -61,10 +63,10 @@ export default function AboutPage() {
<div className="grid gap-12 md:grid-cols-2">
<div className="space-y-6 p-8 rounded-2xl border bg-muted/30 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="space-y-4">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
ماموریت ما
</h2>
<p className="leading-relaxed tracking-normal text-muted-foreground text-lg">
<p className={cn(type["section-intro"])}>
ماموریت ما این است که با استفاده از روش‌های نوین اینتگریشن، به کسب‌وکارها کمک کنیم تا
بدون پیچیدگی‌های فنی، سیستم‌های خود را بهم متصل کنند و از قدرت اتوماسیون و ارتباطات هوشمند
بهره‌مند شوند.
@@ -73,10 +75,10 @@ export default function AboutPage() {
</div>
<div className="space-y-6 p-8 rounded-2xl border bg-muted/30 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="space-y-4">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
چشم‌انداز ما
</h2>
<p className="leading-relaxed tracking-normal text-muted-foreground text-lg">
<p className={cn(type["section-intro"])}>
ما می‌خواهیم به گروه پیشرو در حوزه اینتگریشن و روش‌های نوین یکپارچه‌سازی تبدیل شویم.
چشم‌انداز ما این است که هر کسب‌وکاری، از کوچک تا بزرگ، بتواند به راحتی از خدمات ما استفاده کند
و به اهداف خود برسد.
@@ -92,35 +94,35 @@ export default function AboutPage() {
<div className="mx-auto max-w-6xl px-6">
<div className="text-center space-y-12">
<div className="space-y-4">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
ارزش‌های ما
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
ارزش‌هایی که در تمام کارهای ما رعایت می‌شود
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-4 p-6 rounded-xl bg-card border transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<h3 className="font-bold text-xl">کیفیت</h3>
<p className="text-sm leading-relaxed tracking-normal text-muted-foreground">
<h3 className={type.h3}>کیفیت</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
تعهد به کیفیت و نوآوری در تمام پروژه‌ها و خدمات
</p>
</div>
<div className="space-y-4 p-6 rounded-xl bg-card border transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<h3 className="font-bold text-xl">شفافیت</h3>
<p className="text-sm leading-relaxed tracking-normal text-muted-foreground">
<h3 className={type.h3}>شفافیت</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
شفافیت کامل در ارتباطات، قیمت‌گذاری و فرآیندها
</p>
</div>
<div className="space-y-4 p-6 rounded-xl bg-card border transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<h3 className="font-bold text-xl">مشتری‌محوری</h3>
<p className="text-sm leading-relaxed tracking-normal text-muted-foreground">
<h3 className={type.h3}>مشتری‌محوری</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
تمرکز بر نیازهای مشتری و رضایت کامل آن‌ها
</p>
</div>
<div className="space-y-4 p-6 rounded-xl bg-card border transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<h3 className="font-bold text-xl">نوآوری</h3>
<p className="text-sm leading-relaxed tracking-normal text-muted-foreground">
<h3 className={type.h3}>نوآوری</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
استفاده از آخرین تکنولوژی‌ها و روش‌های نوین
</p>
</div>
@@ -134,10 +136,10 @@ export default function AboutPage() {
<div className="mx-auto max-w-6xl px-6">
<div className="text-center space-y-12">
<div className="space-y-4">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
چرا فیپ را انتخاب کنید؟
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
مزایای رقابتی که ما را از دیگران متمایز می‌کند
</p>
</div>
@@ -150,8 +152,8 @@ export default function AboutPage() {
</svg>
</div>
</div>
<h3 className="font-bold text-xl">سادگی</h3>
<p className="text-muted-foreground leading-relaxed tracking-normal">
<h3 className={type.h3}>سادگی</h3>
<p className={cn(type.body, "text-muted-foreground")}>
رابط کاربری ساده و قابل فهم که نیاز به تخصص فنی ندارد
</p>
</div>
@@ -163,8 +165,8 @@ export default function AboutPage() {
</svg>
</div>
</div>
<h3 className="font-bold text-xl">قابلیت اطمینان</h3>
<p className="text-muted-foreground leading-relaxed tracking-normal">
<h3 className={type.h3}>قابلیت اطمینان</h3>
<p className={cn(type.body, "text-muted-foreground")}>
زیرساخت قدرتمند و پایدار که همیشه در دسترس است
</p>
</div>
@@ -176,8 +178,8 @@ export default function AboutPage() {
</svg>
</div>
</div>
<h3 className="font-bold text-xl">مقیاس‌پذیری</h3>
<p className="text-muted-foreground leading-relaxed tracking-normal">
<h3 className={type.h3}>مقیاس‌پذیری</h3>
<p className={cn(type.body, "text-muted-foreground")}>
قابلیت رشد همراه با نیازهای کسب‌وکار شما
</p>
</div>
@@ -191,10 +193,10 @@ export default function AboutPage() {
<div className="mx-auto max-w-6xl px-6">
<div className="text-center space-y-12">
<div className="space-y-4">
<h2 className="text-3xl font-bold md:text-4xl leading-tight tracking-normal">
<h2 className={type.h2}>
تیم ما
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
تیم فیپ متشکل از متخصصان با تجربه در حوزه‌های اینتگریشن، فناوری اطلاعات، توسعه نرم‌افزار
و مدیریت کسب‌وکار است. ما با همکاری یکدیگر و استفاده از روش‌های نوین، راهکارهای نوآورانه و
کاربردی برای چالش‌های یکپارچه‌سازی ارائه می‌دهیم.
@@ -204,33 +206,33 @@ export default function AboutPage() {
<div className="space-y-4 p-8 rounded-2xl bg-card border text-center transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="flex justify-center">
<div className="size-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center transition-transform duration-300 hover:scale-110">
<span className="text-3xl font-bold text-primary">ت</span>
<span className={cn(type.h2, "text-primary")}>ت</span>
</div>
</div>
<h3 className="font-bold text-xl">تخصص</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<h3 className={type.h3}>تخصص</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
متخصصان با تجربه در حوزه اینتگریشن و یکپارچه‌سازی
</p>
</div>
<div className="space-y-4 p-8 rounded-2xl bg-card border text-center transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="flex justify-center">
<div className="size-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center transition-transform duration-300 hover:scale-110">
<span className="text-3xl font-bold text-primary">ن</span>
<span className={cn(type.h2, "text-primary")}>ن</span>
</div>
</div>
<h3 className="font-bold text-xl">نوآوری</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<h3 className={type.h3}>نوآوری</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
استفاده از آخرین تکنولوژی‌ها و روش‌های نوین
</p>
</div>
<div className="space-y-4 p-8 rounded-2xl bg-card border text-center transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div className="flex justify-center">
<div className="size-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center transition-transform duration-300 hover:scale-110">
<span className="text-3xl font-bold text-primary">پ</span>
<span className={cn(type.h2, "text-primary")}>پ</span>
</div>
</div>
<h3 className="font-bold text-xl">پشتیبانی</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<h3 className={type.h3}>پشتیبانی</h3>
<p className={cn(type["body-sm"], "text-muted-foreground")}>
پشتیبانی کامل و مستمر در تمام مراحل پروژه
</p>
</div>
@@ -243,10 +245,10 @@ export default function AboutPage() {
<section className="bg-background 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 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">
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
با ما تماس بگیرید تا درباره نیازهای شما صحبت کنیم و بهترین راهکار را پیشنهاد دهیم
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
@@ -254,7 +256,7 @@ export default function AboutPage() {
<Link href="/pricing">مشاهده قیمت‌ها</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="#contact">تماس با ما</Link>
<Link href="/consulting#consulting-form">تماس با ما</Link>
</Button>
</div>
</div>
+12 -5
View File
@@ -5,16 +5,19 @@ import { notFound } from "next/navigation";
import { getPostBySlug, getFeaturedImageUrl, getRelatedPosts } from "@/lib/wp-api";
import { formatDateShamsi } from "@/lib/format-date";
import { ChevronRight } from "lucide-react";
import type { CollapseBlockPayload, BlockquoteBlockPayload, TipBlockPayload } from "@/lib/blog-blocks";
import type { CollapseBlockPayload, BlockquoteBlockPayload, TipBlockPayload, TableBlockPayload } from "@/lib/blog-blocks";
import { parseContentHeadings, getReadingTimeMinutes, formatAuthorName } from "@/lib/blog-utils";
import { BlogToc } from "@/components/blog-toc";
import { BlogPostMeta } from "@/components/blog-post-meta";
import { BlogCollapseSection } from "@/components/blog-collapse-section";
import { BlogBlockquote } from "@/components/blog-blockquote";
import { BlogTip } from "@/components/blog-tip";
import { BlogTable } from "@/components/blog-table";
import { BlogPullquote } from "@/components/blog-pullquote";
import { ScrollToTop } from "@/components/scroll-to-top";
import { BlogPostCard } from "@/components/blog-post-card";
import { type, typeSpacing } from "@/lib/typography";
import { cn } from "@/lib/utils";
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
@@ -111,14 +114,14 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
<nav className="mb-6" aria-label="مسیر">
<Link
href="/blog"
className="text-muted-foreground hover:text-foreground text-sm inline-flex items-center gap-1 transition-colors"
className={cn(type.link, "inline-flex items-center gap-1 transition-colors hover:text-foreground")}
>
<ChevronRight className="size-4" aria-hidden />
دانشنامه
</Link>
</nav>
<h1
className="text-2xl font-bold md:text-3xl leading-tight tracking-normal"
className={type.h1}
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
/>
<BlogPostMeta
@@ -133,7 +136,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
/>
</div>
<article className="blog-content text-justify space-y-6 text-foreground [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-8 [&_h2]:mb-4 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-6 [&_h3]:mb-3 [&_p]:leading-relaxed [&_p]:text-muted-foreground [&_a]:text-primary [&_a]:no-underline hover:[&_a]:underline [&_ul]:list-disc [&_ul]:pr-6 [&_ol]:list-decimal [&_ol]:pr-6 [&_li]:my-1 [&_h2]:scroll-mt-24 [&_h3]:scroll-mt-24 [&_h4]:scroll-mt-24">
<article className="blog-content text-justify">
{segments.map((seg, i) =>
seg.type === "html" ? (
<div
@@ -148,8 +151,12 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
/>
) : seg.blockType === "blockquote" ? (
<BlogBlockquote key={i} payload={seg.payload as BlockquoteBlockPayload} />
) : seg.blockType === "pullquote" ? (
<BlogPullquote key={i} payload={seg.payload as BlockquoteBlockPayload} />
) : seg.blockType === "tip" ? (
<BlogTip key={i} payload={seg.payload as TipBlockPayload} />
) : seg.blockType === "table" ? (
<BlogTable key={i} payload={seg.payload as TableBlockPayload} />
) : (
null
)
@@ -163,7 +170,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
{relatedPosts.length > 0 && (
<section className="bg-background border-t pb-16 md:pb-20">
<div className="mx-auto max-w-6xl px-6 pt-10 md:pt-14">
<h2 className="text-xl md:text-2xl text-center mb-8 md:mb-10">
<h2 className={cn(type.h3, "mb-8 text-center md:mb-10")}>
مقالات مرتبط
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 min-w-0">
+6 -4
View File
@@ -5,6 +5,8 @@ import { BlogPostCard } from "@/components/blog-post-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ChevronRight, ChevronLeft, Search, X } from "lucide-react";
import { type, typeSpacing } from "@/lib/typography";
import { cn } from "@/lib/utils";
const PER_PAGE = 9;
@@ -44,8 +46,8 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
return (
<section className="bg-background pt-32 pb-20 md:pt-40 md:pb-28">
<div className="mx-auto max-w-4xl px-6 text-center">
<h1 className="text-4xl font-bold md:text-5xl">بلاگ</h1>
<p className="mt-4 text-muted-foreground">
<h1 className={type.h1}>بلاگ</h1>
<p className={cn(type.body, "mt-4 text-muted-foreground")}>
در حال حاضر امکان بارگذاری مطالب وجود ندارد. لطفاً بعداً تلاش کنید.
</p>
</div>
@@ -72,10 +74,10 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<div className="text-center space-y-5">
<div className="space-y-1.5">
<h1 className="text-3xl font-bold md:text-4xl tracking-tight">
<h1 className={type.h1}>
دانشنامه
</h1>
<p className="text-sm md:text-base text-muted-foreground max-w-xl mx-auto">
<p className={cn(type["body-sm"], "mx-auto max-w-xl text-muted-foreground md:text-base")}>
مقالات و مطالب فیپ در زمینه یکپارچه‌سازی، اتوماسیون و بهره‌وری سازمانی.
</p>
</div>
+23 -21
View File
@@ -20,6 +20,8 @@ import {
Quote,
} from 'lucide-react'
import Link from 'next/link'
import { type, typeSpacing } from '@/lib/typography'
import { cn } from '@/lib/utils'
const consultants = [
{
@@ -114,16 +116,16 @@ export default function ConsultingPage() {
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16 items-center">
<div className="space-y-8 order-2 lg:order-1">
<div className="space-y-4">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">
<div className={typeSpacing.sectionStack}>
<h1 className={type.h1}>
مشاوره سازمانی
</h1>
<p className="text-lg text-muted-foreground leading-relaxed max-w-xl">
<p className={cn(type["section-intro"], "max-w-xl")}>
مشاوره سازمانی به معنای همراهی تخصصی با سازمان شما برای شناسایی چالش‌ها،
انتخاب راهکارهای فنی مناسب و برنامه‌ریزی برای پیاده‌سازی است. ما در حوزه‌های
اینتگریشن، هوشمندسازی، RAG و بهینه‌سازی عملکرد در کنار شما هستیم.
</p>
<p className="text-muted-foreground leading-relaxed max-w-xl">
<p className={cn(type.body, "text-muted-foreground max-w-xl")}>
شرایط همکاری: پس از ثبت درخواست، تیم ما با شما تماس می‌گیرد؛ جلسه اول برای
شناخت نیازها و تعیین دامنه کار است و در ادامه پیشنهاد و برنامه اجرا ارائه
می‌شود.
@@ -158,8 +160,8 @@ export default function ConsultingPage() {
<section className="bg-muted/30 py-16 md:py-24" id="domains">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="mx-auto max-w-2xl text-center mb-12">
<h2 className="text-3xl font-bold md:text-4xl">در چه حوزه‌هایی مشاوره می‌دهیم؟</h2>
<p className="mt-4 text-muted-foreground">
<h2 className={type.h2}>در چه حوزه‌هایی مشاوره می‌دهیم؟</h2>
<p className={cn(type.body, "mt-4 text-muted-foreground")}>
از اینتگریشن و هوشمندسازی تا RAG و معماری نرم‌افزار
</p>
</div>
@@ -174,8 +176,8 @@ export default function ConsultingPage() {
<Icon className="size-6" />
</div>
<div>
<h3 className="font-bold">{domain.title}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
<h3 className={type["card-title"]}>{domain.title}</h3>
<p className={cn(type["card-desc"], "mt-1")}>
{domain.description}
</p>
</div>
@@ -192,8 +194,8 @@ export default function ConsultingPage() {
<section className="bg-background py-16 md:py-24" id="consultants">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="mx-auto max-w-2xl text-center mb-12">
<h2 className="text-3xl font-bold md:text-4xl">چه کسانی مشاوره می‌دهند؟</h2>
<p className="mt-4 text-muted-foreground">
<h2 className={type.h2}>چه کسانی مشاوره می‌دهند؟</h2>
<p className={cn(type.body, "mt-4 text-muted-foreground")}>
تیم مشاوره ما متشکل از متخصصان با تجربه در حوزه‌های مختلف فناوری است.
</p>
</div>
@@ -202,21 +204,21 @@ export default function ConsultingPage() {
<Card key={person.fallback} className="overflow-hidden border-border/60 shadow-sm">
<CardContent className="p-6 md:p-8">
<Quote className="size-10 text-primary/30 mb-4" aria-hidden />
<blockquote className="text-muted-foreground leading-relaxed mb-6">
<blockquote className={cn(type.body, "text-muted-foreground mb-6")}>
{person.description}
</blockquote>
<div className="flex items-center gap-4">
<Avatar className="h-14 w-14 shrink-0 border-2 border-background shadow">
<AvatarImage src={person.image} alt={person.name} />
<AvatarFallback className="text-lg bg-primary/10 text-primary">
<AvatarFallback className={cn(type.h4, "bg-primary/10 text-primary")}>
{person.fallback}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-bold text-foreground">{person.name}</h3>
<div className="flex flex-wrap gap-1.5 mt-1">
<h3 className={type["card-title"]}>{person.name}</h3>
<div className="mt-1 flex flex-wrap gap-1.5">
{person.expertise.map((e) => (
<span key={e} className="text-xs text-muted-foreground">
<span key={e} className={type.caption}>
{e}
{e !== person.expertise[person.expertise.length - 1] ? ' · ' : ''}
</span>
@@ -235,8 +237,8 @@ export default function ConsultingPage() {
<section className="bg-muted/30 py-16 md:py-24" id="faq">
<div className="mx-auto max-w-3xl px-4 md:px-6">
<div className="mx-auto max-w-xl text-center mb-12">
<h2 className="text-3xl font-bold md:text-4xl">سوالات متداول</h2>
<p className="mt-4 text-muted-foreground">
<h2 className={type.h2}>سوالات متداول</h2>
<p className={cn(type.body, "mt-4 text-muted-foreground")}>
پاسخ سوالات رایج درباره مشاوره سازمانی
</p>
</div>
@@ -247,11 +249,11 @@ export default function ConsultingPage() {
value={item.id}
className="rounded-xl border-none px-4 data-[state=open]:bg-muted/50 data-[state=open]:shadow-sm"
>
<AccordionTrigger className="text-start hover:no-underline py-5">
<AccordionTrigger className={cn(type["list-title"], "py-5 text-start hover:no-underline")}>
{item.question}
</AccordionTrigger>
<AccordionContent>
<p className="text-muted-foreground leading-relaxed pb-2">{item.answer}</p>
<p className={cn(type.body, "pb-2 text-muted-foreground")}>{item.answer}</p>
</AccordionContent>
</AccordionItem>
))}
@@ -264,8 +266,8 @@ export default function ConsultingPage() {
<div className="mx-auto max-w-6xl px-4 md:px-6">
<div className="mx-auto max-w-xl">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold md:text-4xl">درخواست مشاوره</h2>
<p className="mt-4 text-muted-foreground">
<h2 className={type.h2}>درخواست مشاوره</h2>
<p className={cn(type.body, "mt-4 text-muted-foreground")}>
فرم زیر را پر کنید؛ در سریع‌ترین زمان ممکن با شما تماس می‌گیریم.
</p>
</div>
+4 -2
View File
@@ -3,6 +3,8 @@
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { type } from '@/lib/typography'
import { cn } from '@/lib/utils'
export default function Error({
error,
@@ -17,8 +19,8 @@ export default function Error({
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 text-center">
<h2 className="text-xl font-semibold text-foreground">خطایی رخ داد</h2>
<p className="text-muted-foreground max-w-md text-sm">
<h2 className={cn(type.h3, "text-foreground")}>خطایی رخ داد</h2>
<p className={cn(type["body-sm"], "max-w-md text-muted-foreground")}>
لطفاً دوباره تلاش کنید یا به صفحه اصلی برگردید.
</p>
<div className="flex flex-wrap gap-3 justify-center">
+5 -3
View File
@@ -1,5 +1,7 @@
import './globals.css'
import Link from 'next/link'
import { type } from '@/lib/typography'
import { cn } from '@/lib/utils'
export const metadata = {
title: '۴۰۴ | صفحه یافت نشد',
@@ -13,17 +15,17 @@ export default function GlobalNotFound() {
<div className="mx-auto max-w-lg w-full text-center">
<div className="relative inline-block mb-6">
<span
className="text-[8rem] md:text-[10rem] font-black leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-primary/90 to-primary/50 select-none"
className={cn(type["display-mono"], "text-transparent bg-clip-text bg-gradient-to-b from-primary/90 to-primary/50 select-none")}
aria-hidden
>
۴۰۴
</span>
</div>
<h1 className="text-2xl font-bold text-foreground mb-3">
<h1 className={cn(type.h2, "mb-3 text-foreground")}>
صفحه پیدا نشد
</h1>
<p className="text-muted-foreground mb-10 max-w-sm mx-auto leading-relaxed">
<p className={cn(type.body, "mb-10 mx-auto max-w-sm text-muted-foreground")}>
به نظر می‌رسد این آدرس وجود ندارد یا منتقل شده است.
</p>
+295 -32
View File
@@ -80,8 +80,10 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: "IRANSansX", "system-ui", "Avenir", "Helvetica", "Arial",
"sans-serif";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
--font-iran-sans: "IRANSansX", "system-ui", "Avenir", "Helvetica", "Arial",
"sans-serif";
--color-sidebar-ring: var(--sidebar-ring);
@@ -117,11 +119,13 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-blog: var(--radius-blog);
}
:root {
--font-iran-sans: "IRANSansX", var(--font-geist-sans);
--font-iran-sans: "IRANSansX", system-ui, sans-serif;
--radius: 0.625rem;
--radius-blog: 0.9375rem;
--card: oklch(1 0.006 175);
--card-foreground: oklch(0.145 0.02 175);
--popover: oklch(1 0.006 175);
@@ -233,46 +237,98 @@
}
/* WordPress table block (figure.wp-block-table > table) */
.wp-block-table {
margin: 1.5rem 0;
overflow-x: auto;
border-radius: var(--radius);
/* Blog table blocks (figure.blog-table) */
.blog-table {
position: relative;
width: 100%;
margin-inline: 0;
padding: 0;
}
.wp-block-table table {
.blog-table__scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0;
padding: 0;
}
.blog-table__grid {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
line-height: 1.6;
}
.wp-block-table table.has-fixed-layout {
table-layout: fixed;
}
.wp-block-table th,
.wp-block-table td {
padding: 0.75rem 1rem;
border: 1px solid var(--border);
.blog-table__grid th,
.blog-table__grid td {
padding: 0.875rem 1rem;
text-align: right;
min-width: 850px;
vertical-align: top;
}
.wp-block-table tbody tr:first-child th,
.wp-block-table tbody tr:first-child td {
background: var(--muted);
font-weight: 600;
.blog-table__grid th p,
.blog-table__grid td p {
margin: 0;
}
.blog-table__caption {
margin-top: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.blog-table__grid a {
color: var(--primary);
text-decoration: underline;
}
.blog-table__grid a:hover {
text-decoration: none;
}
/* Variant one (green) & two (yellow) — glass layout aligned with HeroHeader */
.blog-table--one {
--table-tone: var(--primary);
}
.blog-table--two {
--table-tone: var(--accent);
}
.blog-table--one .blog-table__grid thead th,
.blog-table--two .blog-table__grid thead th {
background: color-mix(in srgb, var(--table-tone) 35%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--foreground);
}
.wp-block-table tbody tr:not(:first-child):hover td {
background: var(--muted);
}
.wp-block-table thead th {
background: var(--muted);
font-weight: 600;
border-bottom: 1px solid color-mix(in srgb, var(--border) 10%, transparent);
}
.blog-table--one .blog-table__grid tbody tr,
.blog-table--two .blog-table__grid tbody tr {
transition: background-color 0.3s ease;
}
.blog-table--one .blog-table__grid tbody tr:nth-child(even) td,
.blog-table--two .blog-table__grid tbody tr:nth-child(even) td {
background: color-mix(in srgb, var(--background) 20%, transparent);
}
.blog-table--one .blog-table__grid tbody tr:hover td,
.blog-table--two .blog-table__grid tbody tr:hover td {
background: color-mix(in srgb, var(--table-tone) 12%, transparent);
}
.blog-table--one .blog-table__grid tbody td,
.blog-table--two .blog-table__grid tbody td {
border-top: 1px solid color-mix(in srgb, var(--border) 10%, transparent);
}
.blog-table--one .blog-table__grid tbody td:first-child,
.blog-table--two .blog-table__grid tbody td:first-child {
font-weight: 500;
color: var(--foreground);
}
@@ -294,10 +350,217 @@
}
.wp-block-image {
@apply rounded-md overflow-hidden shadow-sm;
@apply rounded-blog overflow-hidden shadow-sm;
}
.blog-content .wp-block-image {
@apply my-6;
}
.blog-content img {
@apply w-full h-auto;
image-rendering: auto;
}
/* Blog prose typography — single source of truth */
.blog-content {
@apply space-y-6 text-foreground;
}
.blog-content h2 {
@apply mt-8 mb-4 text-2xl font-semibold leading-snug tracking-normal scroll-mt-24;
}
.blog-content h3 {
@apply mt-6 mb-3 text-xl font-semibold leading-snug tracking-normal scroll-mt-24;
}
.blog-content h4 {
@apply mt-4 mb-2 text-lg font-semibold leading-snug tracking-normal scroll-mt-24;
}
.blog-content p {
@apply leading-relaxed tracking-normal text-muted-foreground;
}
.blog-content a {
@apply text-primary no-underline hover:underline;
}
.blog-content ul {
@apply list-disc pr-6;
}
.blog-content ol {
@apply list-decimal pr-6;
}
.blog-content li {
@apply my-1;
}
/* Blog blockquote variants */
.blog-blockquote {
position: relative;
overflow: hidden;
border-radius: var(--radius-blog);
border: 1px solid;
padding-block: 1.5rem;
padding-inline-end: 1.75rem;
padding-inline-start: 4rem;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
}
.blog-blockquote::before {
content: "";
position: absolute;
inset-block: 1rem;
inset-inline-start: 0;
width: 4px;
border-radius: 9999px;
}
.blog-blockquote--yellow {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 18%, var(--background));
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 14%, transparent);
}
.blog-blockquote--yellow::before {
background: var(--accent);
}
.blog-blockquote--yellow .blog-blockquote__icon {
color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.blog-blockquote--yellow .blog-blockquote__footer {
border-top-color: color-mix(in srgb, var(--accent) 28%, var(--border));
}
.blog-blockquote--green {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
background: color-mix(in srgb, var(--primary) 12%, var(--background));
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 12%, transparent);
}
.blog-blockquote--green::before {
background: var(--primary);
}
.blog-blockquote--green .blog-blockquote__icon {
color: color-mix(in srgb, var(--primary) 50%, transparent);
}
.blog-blockquote--green .blog-blockquote__footer {
border-top-color: color-mix(in srgb, var(--primary) 28%, var(--border));
}
.blog-blockquote--gray {
border-color: var(--border);
background: color-mix(in srgb, var(--muted) 65%, var(--background));
}
.blog-blockquote--gray::before {
background: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
}
.blog-blockquote--gray .blog-blockquote__icon {
color: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
}
.blog-blockquote--gray .blog-blockquote__footer {
border-top-color: color-mix(in srgb, var(--border) 80%, transparent);
}
.blog-blockquote__icon {
position: absolute;
inset-inline-start: 1.25rem;
top: 1.5rem;
width: 2rem;
height: 2rem;
flex-shrink: 0;
z-index: 1;
}
.blog-blockquote__decorations {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
opacity: 0.15;
/* Keep decorative shapes out of the quote-icon corner (inline-start). */
clip-path: polygon(
0 0,
calc(100% - 4.75rem) 0,
calc(100% - 4.75rem) 4.75rem,
100% 4.75rem,
100% 100%,
0 100%
);
}
:dir(ltr) .blog-blockquote__decorations {
clip-path: polygon(
4.75rem 0,
100% 0,
100% 100%,
0 100%,
0 4.75rem,
4.75rem 4.75rem
);
}
.blog-pullquote .blog-blockquote__decorations {
clip-path: none;
}
.blog-blockquote__content {
position: relative;
z-index: 1;
isolation: isolate;
font-size: 1.125rem;
line-height: 1.75;
text-align: justify;
}
.blog-blockquote__content p {
margin-block: 0.5rem;
}
.blog-blockquote__content a {
color: var(--accent);
text-decoration: underline;
}
.blog-blockquote__content a:hover {
text-decoration: none;
}
.blog-blockquote__footer {
position: relative;
z-index: 1;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid;
}
.blog-pullquote {
text-align: center;
padding-inline: 1.5rem;
}
.blog-pullquote .blog-blockquote__icon {
position: static;
margin-inline: auto;
margin-bottom: 0.75rem;
width: 1.75rem;
height: 1.75rem;
}
.blog-pullquote .blog-blockquote__content {
font-size: 1.25rem;
font-weight: 500;
}
+6 -1
View File
@@ -3,6 +3,7 @@ import "./globals.css";
import { ConditionalLayout } from "@/components/conditional-layout";
import { ThemeProvider } from "@/components/theme-provider";
import { TopProgressBar } from "@/components/top-progress-bar";
import { getChangelog } from "@/lib/changelog.server";
const SITE_NAME = "فیپ";
@@ -25,6 +26,8 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const changelogReleases = getChangelog();
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body
@@ -32,7 +35,9 @@ export default function RootLayout({
>
<ThemeProvider>
<TopProgressBar />
<ConditionalLayout>{children}</ConditionalLayout>
<ConditionalLayout changelogReleases={changelogReleases}>
{children}
</ConditionalLayout>
</ThemeProvider>
</body>
</html>
+5 -3
View File
@@ -1,6 +1,8 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Home } from 'lucide-react'
import { type, typeSpacing } from '@/lib/typography'
import { cn } from '@/lib/utils'
export const metadata = {
title: '۴۰۴',
@@ -13,17 +15,17 @@ export default function NotFound() {
<div className="mx-auto max-w-lg w-full text-center">
<div className="relative inline-block mb-6">
<span
className="text-[8rem] md:text-[10rem] font-black leading-none tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-primary/90 to-primary/50 select-none"
className={cn(type["display-mono"], "text-transparent bg-clip-text bg-gradient-to-b from-primary/90 to-primary/50 select-none")}
aria-hidden
>
۴۰۴
</span>
</div>
<h1 className="text-2xl font-bold text-foreground mb-3">
<h1 className={cn(type.h2, "mb-3 text-foreground")}>
صفحه پیدا نشد
</h1>
<p className="text-muted-foreground mb-10 max-w-sm mx-auto leading-relaxed">
<p className={cn(type.body, "mb-10 mx-auto max-w-sm text-muted-foreground")}>
به نظر می‌رسد این آدرس وجود ندارد یا منتقل شده است.
</p>
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { PlaygroundGuide } from "@/components/playground/playground-guide";
import { PlaygroundWorkspace } from "@/components/playground/playground-workspace";
import { type } from "@/lib/typography";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "آزمایشگاه فیپ",
description:
"آزمایشگاه فیپ برای تست سناریوهای تعاملی، پرامپت‌ها و تجربه‌های مبتنی بر هوش مصنوعی.",
};
export default function PlaygroundPage() {
return (
<>
<section className="bg-background px-4 pb-2 pt-[4.75rem] sm:px-6 md:pt-[5.25rem] md:pb-3">
<div className="mx-auto max-w-6xl text-center">
<h1 className={type.h1}>
آزمایشگاه فیپ
</h1>
<p className={cn(type["body-sm"], "mx-auto mt-1 max-w-lg text-muted-foreground md:text-base")}>
آزمایش تعامل با دستیار، پرامپت‌ها و سناریوهای هوش مصنوعی
</p>
</div>
</section>
<section className="bg-background px-4 pb-6 sm:px-6 md:pb-8">
<div className="mx-auto max-w-6xl">
<PlaygroundWorkspace />
</div>
</section>
<PlaygroundGuide />
</>
);
}
+15 -13
View File
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Link as LinkIcon, BarChart3, Share2, Zap, TrendingUp, Workflow } from "lucide-react";
import Link from "next/link";
import { type, typeSpacing } from "@/lib/typography";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "راه حل‌ها",
@@ -90,14 +92,14 @@ export default function SolutionsPage() {
{/* 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>
<p className="text-xl leading-relaxed tracking-normal text-muted-foreground max-w-3xl mx-auto">
<p className={cn(type["body-lg"], "text-muted-foreground max-w-3xl mx-auto")}>
در دنیای امروز، کسب‌و‌کارهای موفق دیگر نمی‌توانند سیستم‌ها، ابزارها و داده‌هایشان را جدا از هم مدیریت کنند. حجم اطلاعات، تعداد ابزارهای مختلف و نیاز به واکنش سریع باعث شده تا یکپارچه‌سازی واقعی سیستم‌ها دیگر تنها یک انتخاب نباشد — بلکه یک ضرورت باشد.
</p>
<p className="text-lg leading-relaxed tracking-normal text-muted-foreground max-w-3xl mx-auto">
<p className={cn(type["section-intro"], "max-w-3xl mx-auto")}>
راهکارهایی که اینجا معرفی می‌شود، فراتر از تکنولوژی‌های معمول است. هدف اصلی این نیست که فقط چند سرویس را به هم وصل کنیم، بلکه این است که فرایندهای کسب‌و‌کار شما را هوشمند، کارا و قابل کنترل‌تر کنیم.
</p>
</div>
@@ -119,18 +121,18 @@ export default function SolutionsPage() {
<div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Icon className="size-7" />
</div>
<h2 className="text-2xl font-bold leading-tight tracking-normal">
<h2 className={type.h3}>
{index + 1}. {solution.title}
</h2>
</div>
<p className="mb-6 leading-relaxed tracking-normal text-muted-foreground text-lg">
<p className={cn("mb-6", type["section-intro"])}>
{solution.description}
</p>
<ul className="space-y-3 mb-6">
{solution.features.map((feature, featureIndex) => (
<li
key={featureIndex}
className="flex items-start gap-3 text-base leading-relaxed tracking-normal"
className={cn("flex items-start gap-3", type.body)}
>
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
<span className="text-muted-foreground">{feature}</span>
@@ -138,7 +140,7 @@ export default function SolutionsPage() {
))}
</ul>
{solution.result && (
<p className="text-base leading-relaxed tracking-normal text-foreground/90 font-medium border-r-4 border-primary/50 pr-4">
<p className={cn(type.emphasis, "border-r-4 border-primary/50 pr-4 text-foreground/90")}>
{solution.result}
</p>
)}
@@ -153,11 +155,11 @@ export default function SolutionsPage() {
<section className="bg-background py-16 md:py-24">
<div className="mx-auto max-w-4xl px-6">
<div className="rounded-2xl border bg-muted/30 p-8 md:p-10">
<h2 className="text-2xl font-bold mb-6">نتیجه‌گیری</h2>
<p className="text-lg leading-relaxed text-muted-foreground mb-4">
<h2 className={cn(type.h3, "mb-6")}>نتیجه‌گیری</h2>
<p className={cn(type["section-intro"], "mb-4")}>
این راهکارها کمک می‌کنند تا کسب‌و‌کار شما از حالت «جزیره‌ای» بیرون بیاید و به یک ساختار هوشمند، به‌هم‌پیوسته و قابل رشد تبدیل شود.
</p>
<p className="text-lg leading-relaxed text-muted-foreground">
<p className={type["section-intro"]}>
دیگر لازم نیست زمان زیادی صرف انتقال داده، گزارش‌گیری دستی یا تکرار فعالیت‌ها بکنید؛ بلکه می‌توانید به‌طور هوشمندانه‌تر روی اهداف استراتژیک خود تمرکز کنید و رشد سریع‌تر را تجربه کنید.
</p>
</div>
@@ -168,10 +170,10 @@ export default function SolutionsPage() {
<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 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">
<p className={cn(type["section-intro"], "max-w-2xl mx-auto")}>
همین حالا شروع کنید و از قدرت یکپارچه‌سازی سیستم‌ها، اتوماسیون فرآیندها و تحلیل داده‌ها بهره‌مند شوید
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
+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} />;
}
+4
View File
@@ -0,0 +1,4 @@
/** حذف برچسب‌های ویرایشی (#1, #2, ...) از ابتدای متن HTML نقل‌قول. */
export function sanitizeBlockquoteContent(html: string): string {
return html.replace(/(^|>)\s*#\d+\s*/g, "$1").trim();
}
+137 -6
View File
@@ -8,11 +8,22 @@
*/
import * as cheerio from "cheerio";
import type { Element } from "domhandler";
import { sanitizeBlockquoteContent } from "@/lib/blockquote-content";
import {
detectTableVariant,
sanitizeTableCellContent,
} from "@/lib/table-content";
// ─── انواع بلاک (برای گسترش بعدی) ───────────────────────────────────────────
/** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */
export type BlockType = "collapse" | "blockquote" | "pullquote" | "tip" | "code";
export type BlockType =
| "collapse"
| "blockquote"
| "pullquote"
| "tip"
| "table"
| "code";
/** Payload بلاک collapse (سوالات متداول / accordion). */
export interface CollapseBlockPayload {
@@ -20,11 +31,16 @@ export interface CollapseBlockPayload {
items: { question: string; answer: string }[];
}
/** استایل رنگی نقل‌قول: زرد (accent)، سبز (primary)، طوسی (muted). */
export type BlockquoteVariant = "yellow" | "green" | "gray";
/** Payload بلاک blockquote (نقل‌قول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */
export interface BlockquoteBlockPayload {
/** HTML محتوای نقل‌قول (بدون تگ cite). */
content: string;
citation?: string;
/** استایل رنگی؛ پیش‌فرض طوسی. از کلاس‌های quote-yellow | quote-green | quote-gray در وردپرس. */
variant?: BlockquoteVariant;
}
/** Payload بلاک tip (نکته / اینفو با variant: info, warn, danger). */
@@ -34,6 +50,19 @@ export interface TipBlockPayload {
content: string;
}
/** استایل UI جدول: one (primary/سبز) یا two (accent/زرد) — هر دو با چیدمان کلاسیک. */
export type TableVariant = "one" | "two";
/** Payload بلاک table (جدول وردپرس). */
export interface TableBlockPayload {
variant: TableVariant;
/** HTML محتوای هر ستون در سطر سرستون. */
headers: string[];
/** سطرهای بدنه؛ هر سلول HTML است. */
rows: string[][];
caption?: string;
}
/** Payload بلاک code؛ بعداً پیاده‌سازی می‌شود. */
// export interface CodeBlockPayload { ... }
@@ -41,7 +70,8 @@ export interface TipBlockPayload {
export type BlockPayload =
| CollapseBlockPayload
| BlockquoteBlockPayload
| TipBlockPayload;
| TipBlockPayload
| TableBlockPayload;
// | CodeBlockPayload
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
@@ -311,16 +341,43 @@ function discoverFaqCollapseBlocks(
return faqEntries;
}
const BLOCKQUOTE_VARIANT_CYCLE: BlockquoteVariant[] = ["yellow", "green", "gray"];
function parseExplicitBlockquoteVariant(
classList: string[]
): BlockquoteVariant | undefined {
if (
classList.some(
(c) => c === "quote-yellow" || c === "is-style-yellow"
)
) {
return "yellow";
}
if (
classList.some((c) => c === "quote-green" || c === "is-style-green")
) {
return "green";
}
if (
classList.some((c) => c === "quote-gray" || c === "is-style-gray")
) {
return "gray";
}
return undefined;
}
function extractBlockquotePayload(
$: cheerio.CheerioAPI,
el: Element
): BlockquoteBlockPayload {
const $el = $(el);
const classList = ($el.attr("class") ?? "").split(/\s+/);
const variant = parseExplicitBlockquoteVariant(classList);
const citation = $el.find("cite").first().text().trim() || undefined;
const $body = $el.clone();
$body.find("cite").remove();
const content = ($body.html() ?? "").trim();
return { content, citation };
const content = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
}
/**
@@ -332,17 +389,71 @@ function extractPullquotePayload(
el: Element
): BlockquoteBlockPayload {
const $el = $(el);
const classList = [
...($el.attr("class") ?? "").split(/\s+/),
...($el.find("blockquote").first().attr("class") ?? "").split(/\s+/),
];
const variant = parseExplicitBlockquoteVariant(classList);
const $blockquote = $el.find("blockquote").first();
const citation = $blockquote.find("cite").first().text().trim() || undefined;
const $body = $blockquote.clone();
$body.find("cite").remove();
const content = ($body.html() ?? "").trim();
return { content, citation };
const content = sanitizeBlockquoteContent($body.html() ?? "");
return { content, citation, ...(variant ? { variant } : {}) };
}
/**
* استخراج از بلاک tip وردپرس: div.wp-block-group.tip با کلاس tip-info | tip-warn | tip-danger.
*/
function extractTablePayload(
$: cheerio.CheerioAPI,
el: Element
): TableBlockPayload {
const $el = $(el);
const variant = detectTableVariant($el.text());
const captionRaw = $el.find("figcaption").first().text().trim();
const caption = captionRaw
? sanitizeTableCellContent(captionRaw)
: undefined;
const $table = $el.find("table").first();
const headers: string[] = [];
const rows: string[][] = [];
$table.find("thead tr").first().find("th, td").each((_, cell) => {
headers.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
const bodyRowEls = $table.find("tbody tr").toArray().filter(isElement);
const allRowEls = bodyRowEls.length
? bodyRowEls
: $table.find("tr").toArray().filter(isElement);
let bodyStart = 0;
if (!headers.length && allRowEls.length) {
$(allRowEls[0])
.find("th, td")
.each((_, cell) => {
headers.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
bodyStart = 1;
}
for (let i = bodyStart; i < allRowEls.length; i++) {
const row: string[] = [];
$(allRowEls[i])
.find("th, td")
.each((_, cell) => {
row.push(sanitizeTableCellContent($(cell).html() ?? ""));
});
if (row.some((cell) => cell.replace(/<[^>]*>/g, "").trim())) {
rows.push(row);
}
}
return { variant, headers, rows, ...(caption ? { caption } : {}) };
}
function extractTipPayload($: cheerio.CheerioAPI, el: Element): TipBlockPayload {
const $el = $(el);
const classList = ($el.attr("class") ?? "").split(/\s+/);
@@ -384,6 +495,11 @@ export const BLOCK_DEFINITIONS: BlockDefinition[] = [
selector: "div.wp-block-group.tip",
extract: extractTipPayload as BlockDefinition["extract"],
},
{
blockType: "table",
selector: "figure.wp-block-table",
extract: extractTablePayload as BlockDefinition["extract"],
},
// { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload },
];
@@ -433,6 +549,7 @@ export function discoverBlocks(html: string): DiscoveredContent {
blockquote: [],
pullquote: [],
tip: [],
table: [],
code: [],
};
@@ -459,6 +576,20 @@ export function discoverBlocks(html: string): DiscoveredContent {
// مرتب‌سازی بر اساس ترتیب در سند (از آخر به اول جایگزین می‌کنیم تا ایندکس‌ها جابجا نشوند)
entries.sort((a, b) => orderMap.get(a.el)! - orderMap.get(b.el)!);
// اگر وردپرس کلاس رنگی نفرستاده، به‌ترتیب زرد → سبز → طوسی چرخش می‌دهد
let quoteAutoIndex = 0;
for (const entry of entries) {
if (entry.blockType !== "blockquote" && entry.blockType !== "pullquote") {
continue;
}
const payload = entry.payload as BlockquoteBlockPayload;
if (!payload.variant) {
payload.variant =
BLOCKQUOTE_VARIANT_CYCLE[quoteAutoIndex % BLOCKQUOTE_VARIANT_CYCLE.length];
}
quoteAutoIndex++;
}
// جایگزینی با placeholder از آخر به اول
for (let i = entries.length - 1; i >= 0; i--) {
const { el, blockType, blockId, removeNodes } = entries[i];
+68
View File
@@ -0,0 +1,68 @@
import type { ChangelogSection, ChangelogSectionTitle } from "@/lib/changelog-types";
/** Use a short paragraph when a release has very few notes. */
const PARAGRAPH_ITEM_THRESHOLD = 4;
export function stripChangelogMarkdown(text: string): string {
return text
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
export function countReleaseItems(sections: ChangelogSection[]): number {
return sections.reduce((total, section) => total + section.items.length, 0);
}
export function shouldUseParagraphSummary(sections: ChangelogSection[]): boolean {
return countReleaseItems(sections) <= PARAGRAPH_ITEM_THRESHOLD;
}
const SECTION_VERBS: Partial<Record<ChangelogSectionTitle, string>> = {
Added: "introduces",
Changed: "improves",
Fixed: "fixes",
Removed: "removes",
Deprecated: "deprecates",
Security: "patches",
};
function joinNaturalList(items: string[]): string {
if (items.length === 0) return "";
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
export function buildReleaseParagraph(sections: ChangelogSection[]): string {
const clauses = sections
.map((section) => {
const items = section.items.map((item) =>
stripChangelogMarkdown(item).replace(/\.$/, "").toLowerCase()
);
const verb = SECTION_VERBS[section.title] ?? "updates";
return `${verb} ${joinNaturalList(items)}`;
})
.filter(Boolean);
if (clauses.length === 0) return "";
if (clauses.length === 1) {
return `This release ${clauses[0]}.`;
}
const last = clauses[clauses.length - 1];
const rest = clauses.slice(0, -1);
return `This release ${rest.join(", ")}, and ${last}.`;
}
export function formatReleaseDate(date: string): string {
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return date;
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(parsed);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ChangelogRelease } from "@/lib/changelog-types";
/**
* Hardcoded release notes shown in the Version History drawer.
* Update this file when shipping a new version — always prepend; never overwrite.
*/
export const CHANGELOG_RELEASES: ChangelogRelease[] = [
{
version: "0.3.0",
date: "2026-07-02",
major: 0,
minor: 3,
patch: 0,
sections: [
{
title: "Added",
items: [
"Introduced the Version History drawer, opened from the footer version label.",
"Added categorized release notes with Added, Improved, and Fixed sections.",
"Added a current version indicator on the latest release card.",
"Added hardcoded release data in lib/changelog-releases.ts for reliable drawer content.",
],
},
{
title: "Changed",
items: [
"Refined the drawer layout, card typography, and spacing between release sections.",
"Improved release date formatting and left-aligned changelog text for readability.",
"Moved the drawer close button to the left edge for RTL-friendly interaction.",
],
},
{
title: "Fixed",
items: [
"Fixed empty version descriptions by serving release notes from static release data.",
"Fixed scrolling issues inside the version history drawer.",
"Fixed server-only fs import errors by loading changelog data on the server layout.",
],
},
],
},
{
version: "0.2.0",
date: "2026-07-02",
major: 0,
minor: 2,
patch: 0,
sections: [
{
title: "Added",
items: [
"Built blog blockquote templates with yellow, green, and gray color variants.",
"Shipped styled WordPress table blocks with glass headers and mobile scroll.",
"Introduced a site-wide typography scale for consistent headings and body text.",
"Added centralized .blog-content prose styles for single post pages.",
],
},
{
title: "Changed",
items: [
"Replaced home page hero, features accordion, and ecosystem images with updated assets.",
"Refactored single blog post pages with centralized prose styles in globals.css.",
"Redesigned the site header with centered navigation and aligned container width.",
"Updated hero and integration call-to-action links to point to the solutions page.",
],
},
{
title: "Fixed",
items: [
"Fixed pullquotes not rendering on single blog post pages.",
"Fixed background scrolling while the mobile navigation menu was open.",
"Fixed header width and padding misalignment at several breakpoints.",
"Fixed related posts crashing the page when the WordPress API returned an error.",
"Fixed featured image aspect ratio and top spacing on mobile blog posts.",
"Fixed SEO metadata, Open Graph tags, and 404 page layout.",
],
},
],
},
{
version: "0.1.0",
date: "2026-06-01",
major: 0,
minor: 1,
patch: 0,
sections: [],
summary:
"Initial public release of the application, including the homepage, navigation, article pages, and the first version of the overall user interface.",
},
];
+83
View File
@@ -0,0 +1,83 @@
const SECTION_HEADINGS = [
"Added",
"Changed",
"Fixed",
"Removed",
"Deprecated",
"Security",
] as const;
export type ChangelogSectionTitle = (typeof SECTION_HEADINGS)[number];
export type ChangelogSection = {
title: ChangelogSectionTitle;
items: string[];
};
export type ChangelogRelease = {
version: string;
date: string;
major: number;
minor: number;
patch: number;
/** Categorized release notes. */
sections: ChangelogSection[];
/** Optional paragraph summary for major or initial releases. */
summary?: string;
};
export const CHANGELOG_SECTION_LABELS: Record<ChangelogSectionTitle, string> = {
Added: "Added",
Changed: "Improved",
Fixed: "Fixed",
Removed: "Removed",
Deprecated: "Deprecated",
Security: "Security",
};
export function parseChangelog(markdown: string): ChangelogRelease[] {
const releases: ChangelogRelease[] = [];
const lines = markdown.split("\n");
let i = 0;
while (i < lines.length) {
const versionMatch = lines[i].match(/^## \[([\d.]+)\] - (.+)$/);
if (!versionMatch) {
i++;
continue;
}
const version = versionMatch[1];
const date = versionMatch[2];
const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
i++;
const sections: ChangelogSection[] = [];
while (i < lines.length && !lines[i].match(/^## \[/)) {
const sectionMatch = lines[i].match(/^### (.+)$/);
if (
sectionMatch &&
SECTION_HEADINGS.includes(sectionMatch[1] as ChangelogSectionTitle)
) {
const title = sectionMatch[1] as ChangelogSectionTitle;
i++;
const items: string[] = [];
while (i < lines.length && lines[i].startsWith("- ")) {
items.push(lines[i].slice(2).trim());
i++;
}
if (items.length > 0) {
sections.push({ title, items });
}
continue;
}
i++;
}
releases.push({ version, date, major, minor, patch, sections });
}
return releases;
}
+6
View File
@@ -0,0 +1,6 @@
import { CHANGELOG_RELEASES } from "@/lib/changelog-releases";
import type { ChangelogRelease } from "@/lib/changelog-types";
export function getChangelog(): ChangelogRelease[] {
return CHANGELOG_RELEASES;
}
+90
View File
@@ -0,0 +1,90 @@
import fs from "fs";
import path from "path";
export type LocalImage = {
src: string;
width: number;
height: number;
};
function readJpegSize(buffer: Buffer): { width: number; height: number } {
let i = 2;
while (i < buffer.length) {
if (buffer[i] !== 0xff) break;
const marker = buffer[i + 1];
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
return {
height: buffer.readUInt16BE(i + 5),
width: buffer.readUInt16BE(i + 7),
};
}
i += 2 + buffer.readUInt16BE(i + 2);
}
throw new Error("Could not read JPEG dimensions");
}
function localImage(publicPath: string, publicUrl?: string): LocalImage {
const filePath = path.join(process.cwd(), "public", publicPath.replace(/^\//, ""));
const buffer = fs.readFileSync(filePath);
const { width, height } = readJpegSize(buffer);
return {
src: publicUrl ?? publicPath,
width,
height,
};
}
function sectionImage(filename: string): LocalImage {
return localImage(
`/assets/main-pic/4section/${filename}`,
`/assets/main-pic/4section/${encodeURIComponent(filename)}`,
);
}
export type FeatureImageKey = "item-1" | "item-2" | "item-3" | "item-4";
export type FeatureImageSet = Record<
FeatureImageKey,
{
image: LocalImage;
imageDark: LocalImage;
alt: string;
}
>;
export function getHomeImages() {
return {
hero: {
backgroundDark: localImage("/assets/main-pic/1dark.jpg"),
main: localImage("/assets/main-pic/1.jpg"),
mainDark: localImage("/assets/main-pic/1dark.jpg"),
},
features: {
"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: "اتوماسیون فرآیندها",
},
} satisfies FeatureImageSet,
ecosystem: {
light: localImage("/assets/main-pic/intelligent-workflow-control-center.jpg"),
dark: localImage("/assets/main-pic/intelligent-workflow-control-center-dark.jpg"),
},
};
}
+11
View File
@@ -0,0 +1,11 @@
/** حذف برچسب‌های انتخاب UI (#one, #two) از محتوای سلول جدول. */
export function sanitizeTableCellContent(html: string): string {
return html.replace(/#(?:one|two)\b/gi, "").trim();
}
/** تشخیص variant جدول از متن خام (قبل از sanitize). */
export function detectTableVariant(text: string): "one" | "two" {
if (/#two\b/i.test(text)) return "two";
if (/#one\b/i.test(text)) return "one";
return "one";
}
+130
View File
@@ -0,0 +1,130 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Site-wide typography scale optimized for Persian (RTL) readability.
* Use via `typography({ variant })`, `type.*` shorthand, or `<Typography>`.
*/
export const typographyVariants = cva("tracking-normal", {
variants: {
variant: {
/** Large marketing hero titles */
display:
"text-3xl font-semibold leading-relaxed text-balance sm:text-4xl lg:text-5xl",
/** Main page titles */
h1: "text-3xl font-bold leading-snug text-balance md:text-4xl lg:text-5xl",
/** Major section headings */
h2: "text-2xl font-semibold leading-snug text-balance md:text-3xl lg:text-4xl",
/** Subsection / feature block headings */
h3: "text-lg font-semibold leading-snug md:text-xl",
/** Compact block headings, mini sections */
h4: "text-base font-semibold leading-snug md:text-lg",
/** Prominent intro paragraphs */
"body-lg": "text-lg leading-relaxed md:text-xl",
/** Standard paragraphs */
body: "text-base leading-relaxed",
/** Secondary / denser copy */
"body-sm": "text-sm leading-relaxed",
/** Metadata, timestamps, footnotes */
caption: "text-xs leading-normal text-muted-foreground",
/** Section eyebrow / badge labels */
eyebrow: "text-sm font-medium leading-normal text-muted-foreground",
/** Form labels, settings labels */
label: "text-sm font-medium leading-none",
/** Header / menu items (desktop) */
nav: "text-sm font-medium",
/** Mobile navigation items */
"nav-mobile": "text-base font-medium",
/** Titles inside cards and panels */
"card-title": "text-base font-semibold leading-snug",
/** Supporting copy inside cards */
"card-desc": "text-sm leading-relaxed text-muted-foreground",
/** Chat message bubbles */
chat: "text-sm leading-relaxed",
/** Log panels, JSON, technical output */
code: "font-mono text-xs leading-relaxed",
/** Large stat numbers */
stat: "text-4xl font-bold leading-none text-primary md:text-5xl",
/** Inline nav / footer links */
link: "text-sm text-muted-foreground",
/** Section intro paragraphs (muted) */
"section-intro": "text-lg leading-relaxed text-muted-foreground",
/** Pull quotes / emphasis blocks */
emphasis: "text-base font-medium leading-relaxed text-foreground",
/** Accordion / list item titles */
"list-title": "text-base font-medium leading-snug",
/** CTA section headings (slightly larger than h2) */
"cta-title":
"text-2xl font-semibold leading-snug text-balance md:text-3xl lg:text-4xl",
/** Decorative error page numbers */
"display-mono":
"text-[5rem] font-black leading-none sm:text-[6rem] md:text-[8rem]",
},
},
defaultVariants: {
variant: "body",
},
});
export type TypographyVariant = NonNullable<
VariantProps<typeof typographyVariants>["variant"]
>;
/** Shorthand class strings for inline use: `className={type.h2}` */
export const type: Record<TypographyVariant, string> = {
display:
"text-3xl font-semibold leading-relaxed tracking-normal text-balance sm:text-4xl lg:text-5xl",
h1: "text-3xl font-bold leading-snug tracking-normal text-balance md:text-4xl lg:text-5xl",
h2: "text-2xl font-semibold leading-snug tracking-normal text-balance md:text-3xl lg:text-4xl",
h3: "text-lg font-semibold leading-snug tracking-normal md:text-xl",
h4: "text-base font-semibold leading-snug tracking-normal md:text-lg",
"body-lg": "text-lg leading-relaxed tracking-normal md:text-xl",
body: "text-base leading-relaxed tracking-normal",
"body-sm": "text-sm leading-relaxed tracking-normal",
caption: "text-xs leading-normal tracking-normal text-muted-foreground",
eyebrow: "text-sm font-medium leading-normal tracking-normal text-muted-foreground",
label: "text-sm font-medium leading-none tracking-normal",
nav: "text-sm font-medium tracking-normal",
"nav-mobile": "text-base font-medium tracking-normal",
"card-title": "text-base font-semibold leading-snug tracking-normal",
"card-desc": "text-sm leading-relaxed tracking-normal text-muted-foreground",
chat: "text-sm leading-relaxed tracking-normal",
code: "font-mono text-xs leading-relaxed tracking-normal",
stat: "text-4xl font-bold leading-none tracking-normal text-primary md:text-5xl",
link: "text-sm tracking-normal text-muted-foreground",
"section-intro": "text-lg leading-relaxed tracking-normal text-muted-foreground",
emphasis: "text-base font-medium leading-relaxed tracking-normal text-foreground",
"list-title": "text-base font-medium leading-snug tracking-normal",
"cta-title":
"text-2xl font-semibold leading-snug tracking-normal text-balance md:text-3xl lg:text-4xl",
"display-mono":
"text-[5rem] font-black leading-none tracking-tighter sm:text-[6rem] md:text-[8rem]",
};
/** Text-related spacing tied to typography hierarchy */
export const typeSpacing = {
/** Eyebrow → heading */
eyebrowToHeading: "mt-2",
/** Heading → supporting paragraph */
headingToBody: "mt-3",
/** Section title block */
sectionStack: "space-y-4",
/** Page hero block */
heroStack: "space-y-6",
/** Card title → description */
cardStack: "gap-1.5",
/** Stacked paragraphs */
paragraphStack: "space-y-4",
/** Label → input */
labelToControl: "gap-1.5",
/** Message → timestamp in chat */
chatMeta: "space-y-1",
} as const;
export function typography(
variant: TypographyVariant,
className?: string
): string {
return cn(typographyVariants({ variant }), className);
}
+4
View File
@@ -0,0 +1,4 @@
import packageJson from "../package.json";
/** Application version from package.json (semver). */
export const APP_VERSION = packageJson.version;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "next-ctrlp",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "next dev",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB