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