From 112e5eb46d9f9cd46418dc189f816b6a6934904a Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 29 Jul 2026 21:57:44 +0330 Subject: [PATCH] Add CMS website and Qom landing pages with responsive layout fixes. Ship blog/portfolio routes, shared content components, and mobile-safe typography so the marketing site can deploy cleanly. Co-authored-by: Cursor --- next.config.ts | 5 + src/app/blog/[slug]/page.tsx | 102 +++++++++++++ src/app/blog/page.tsx | 103 +++++++++++++ src/app/cms-website/page.tsx | 183 +++++++++++++++++++++++ src/app/globals.css | 91 +++++++++++- src/app/portfolio/[slug]/page.tsx | 134 +++++++++++++++++ src/app/portfolio/page.tsx | 112 ++++++++++++++ src/app/website-qom/page.tsx | 158 ++++++++++++++++++++ src/components/AppSection.tsx | 2 +- src/components/BlogSection.tsx | 91 ++++++++---- src/components/CmsSection.tsx | 2 +- src/components/ContactSection.tsx | 14 +- src/components/ContentPageShell.tsx | 18 +++ src/components/FramedImage.tsx | 8 +- src/components/GraphicBrands.tsx | 28 ++++ src/components/Hero.tsx | 6 +- src/components/HtmlContent.tsx | 13 ++ src/components/MediaSection.tsx | 2 +- src/components/Pagination.tsx | 127 ++++++++++++++++ src/components/Portfolio.tsx | 9 +- src/components/RevealObserver.tsx | 21 ++- src/components/SeoCities.tsx | 63 +++++++- src/components/ServiceContactStrip.tsx | 55 +++++++ src/components/ServicePageLayout.tsx | 92 ++++++++---- src/components/Services.tsx | 4 +- src/components/WebsiteProjects.tsx | 36 ++++- src/data/assets.ts | 10 +- src/data/home.ts | 47 +----- src/lib/api.ts | 195 +++++++++++++++++++++++++ 29 files changed, 1584 insertions(+), 147 deletions(-) create mode 100644 src/app/blog/[slug]/page.tsx create mode 100644 src/app/blog/page.tsx create mode 100644 src/app/cms-website/page.tsx create mode 100644 src/app/portfolio/[slug]/page.tsx create mode 100644 src/app/portfolio/page.tsx create mode 100644 src/app/website-qom/page.tsx create mode 100644 src/components/ContentPageShell.tsx create mode 100644 src/components/HtmlContent.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/ServiceContactStrip.tsx create mode 100644 src/lib/api.ts diff --git a/next.config.ts b/next.config.ts index 6c19289..6832aa2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,11 @@ const nextConfig: NextConfig = { hostname: "meshkee-templates.sas.amin.parminstorage.ir", pathname: "/**", }, + { + protocol: "https", + hostname: "meshkee-storage.sas.amin.parminstorage.ir", + pathname: "/**", + }, { protocol: "https", hostname: "www.meshkee.com", diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..4a38888 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ContentPageShell } from "@/components/ContentPageShell"; +import { HtmlContent } from "@/components/HtmlContent"; +import { + ApiNotFoundError, + authorName, + formatFaDate, + getBlog, + getBlogCoverImage, +} from "@/lib/api"; + +type BlogDetailPageProps = { + params: Promise<{ slug: string }>; +}; + +export async function generateMetadata({ + params, +}: BlogDetailPageProps): Promise { + const { slug } = await params; + + try { + const blog = await getBlog(slug); + return { + title: `${blog.title} | مشکی`, + description: blog.abstract ?? undefined, + }; + } catch { + return { title: "مقاله | مشکی" }; + } +} + +export default async function BlogDetailPage({ params }: BlogDetailPageProps) { + const { slug } = await params; + + let blog; + try { + blog = await getBlog(slug); + } catch (error) { + if (error instanceof ApiNotFoundError) notFound(); + throw error; + } + + const cover = getBlogCoverImage(blog); + const date = formatFaDate(blog.publishedAt); + const author = authorName(blog.author); + + return ( + +
+
+
+ + بازگشت به مقالات + + + {blog.categoryName ? ( +

+ {blog.categoryName} +

+ ) : null} + +

+ {blog.title} +

+ + {blog.abstract ? ( +

+ {blog.abstract} +

+ ) : null} + +
+ {date ? : null} + {author ? نویسنده: {author} : null} +
+
+ + {cover && blog.titleImageUrl ? ( +
+ +
+ ) : null} + + {blog.mainTextHtml ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..666b695 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ContentPageShell } from "@/components/ContentPageShell"; +import { Pagination } from "@/components/Pagination"; +import { getBlogCoverImage, getBlogs } from "@/lib/api"; + +export const metadata: Metadata = { + title: "مقالات | مشکی", + description: "آخرین مقالات آموزشی و انگیزشی تیم مشکی.", +}; + +const PAGE_SIZE = 12; + +type BlogPageProps = { + searchParams: Promise<{ page?: string }>; +}; + +export default async function BlogListPage({ searchParams }: BlogPageProps) { + const { page: pageParam } = await searchParams; + const page = Math.max(1, Number(pageParam) || 1); + + let items: Awaited>["items"] = []; + let total = 0; + + try { + const data = await getBlogs({ page, pageSize: PAGE_SIZE }); + items = data.items; + total = data.total; + } catch { + items = []; + total = 0; + } + + return ( + +
+
+
+

+ رشدنامه، آموزشی و انگیزشی +

+

+ Our Blog +

+

+ با محتوای مشکی همیشه به‌روز باشید. +

+
+ + {items.length === 0 ? ( +

مقاله‌ای برای نمایش وجود ندارد.

+ ) : ( +
+ {items.map((post, index) => { + const image = getBlogCoverImage(post); + + return ( +
+ +
+ {image ? ( + + ) : null} +
+

+ {post.title} +

+ {post.abstract ? ( +

+ {post.abstract} +

+ ) : null} + +
+ ); + })} +
+ )} + + +
+
+
+ ); +} diff --git a/src/app/cms-website/page.tsx b/src/app/cms-website/page.tsx new file mode 100644 index 0000000..e042528 --- /dev/null +++ b/src/app/cms-website/page.tsx @@ -0,0 +1,183 @@ +import type { Metadata } from "next"; +import { ServicePageLayout } from "@/components/ServicePageLayout"; +import { GraphicBrands, cmsWebsiteBrands } from "@/components/GraphicBrands"; +import { SeoCities, cmsWebsiteCities } from "@/components/SeoCities"; +import { + ProjectsShowcase, + cmsWebsiteProjects, +} from "@/components/WebsiteProjects"; +import { images } from "@/data/assets"; + +const CDN = + "https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files"; +const ASSETS = + "https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets"; + +export const metadata: Metadata = { + title: "وبسایت CMS | مشکی", + description: + "طراحی وبسایت روی سیستم مدیریت محتوای مشکی؛ ویرایش زنده، اتصال به پلتفرم‌ها و کدنویسی اختصاصی.", +}; + +export default function CmsWebsitePage() { + return ( + + ), + }, + { + after: 1, + node: ( + + ), + }, + { + after: 2, + node: ( + + ), + }, + ]} + /> + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 336fcc5..b06775d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -42,6 +42,11 @@ html { scroll-behavior: smooth; + overflow-x: clip; +} + +body { + overflow-x: clip; } /* Rule: all interactive elements animate smoothly on hover */ @@ -57,11 +62,12 @@ a { opacity 0.2s ease; } -/* Headings: consistent weight matching hero h1 — titles stay on one row */ +/* Headings: consistent weight matching hero h1 */ h1, h2 { font-weight: 400; - white-space: nowrap; + overflow-wrap: anywhere; + word-break: break-word; } h3, @@ -256,3 +262,84 @@ a.group-border-hover:hover { transform: none !important; } } + +.prose-content { + color: var(--ink-soft); + font-size: 0.975rem; + line-height: 2; +} + +.prose-content > *:first-child { + margin-top: 0; +} + +.prose-content p, +.prose-content ul, +.prose-content ol, +.prose-content blockquote, +.prose-content pre, +.prose-content table, +.prose-content figure { + margin: 1.1em 0; +} + +.prose-content h1, +.prose-content h2, +.prose-content h3, +.prose-content h4 { + color: var(--ink); + font-weight: 400; + white-space: normal; + margin: 1.6em 0 0.6em; + line-height: 1.5; +} + +.prose-content h2 { + font-size: 1.35rem; +} + +.prose-content h3 { + font-size: 1.15rem; +} + +.prose-content a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; +} + +.prose-content img { + display: block; + max-width: 100%; + height: auto; + margin: 1.5rem auto; + border-radius: 1rem; +} + +.prose-content ul, +.prose-content ol { + padding-inline-start: 1.4rem; +} + +.prose-content li { + margin: 0.35em 0; +} + +.prose-content blockquote { + border-inline-start: 3px solid var(--line); + padding-inline-start: 1rem; + color: var(--muted); +} + +.prose-content table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.prose-content th, +.prose-content td { + border: 1px solid var(--line); + padding: 0.55rem 0.75rem; + text-align: start; +} diff --git a/src/app/portfolio/[slug]/page.tsx b/src/app/portfolio/[slug]/page.tsx new file mode 100644 index 0000000..76400f6 --- /dev/null +++ b/src/app/portfolio/[slug]/page.tsx @@ -0,0 +1,134 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ContentPageShell } from "@/components/ContentPageShell"; +import { HtmlContent } from "@/components/HtmlContent"; +import { + ApiNotFoundError, + formatFaDate, + getPortfolio, + getPortfolioCoverImage, +} from "@/lib/api"; + +type PortfolioDetailPageProps = { + params: Promise<{ slug: string }>; +}; + +export async function generateMetadata({ + params, +}: PortfolioDetailPageProps): Promise { + const { slug } = await params; + + try { + const portfolio = await getPortfolio(slug); + const title = portfolio.titleFa || portfolio.title; + return { + title: `${title} | مشکی`, + description: portfolio.abstract ?? undefined, + }; + } catch { + return { title: "نمونه‌کار | مشکی" }; + } +} + +export default async function PortfolioDetailPage({ + params, +}: PortfolioDetailPageProps) { + const { slug } = await params; + + let portfolio; + try { + portfolio = await getPortfolio(slug); + } catch (error) { + if (error instanceof ApiNotFoundError) notFound(); + throw error; + } + + const cover = getPortfolioCoverImage(portfolio); + const date = formatFaDate(portfolio.publishedAt); + const title = portfolio.titleFa || portfolio.title; + const gallery = (portfolio.gallery ?? []).filter( + (item) => item.url !== cover, + ); + + return ( + +
+
+
+ + بازگشت به نمونه‌کارها + + + {portfolio.categoryName ? ( +

+ {portfolio.categoryName} +

+ ) : null} + +

+ {title} +

+ + {portfolio.titleEn ? ( +

+ {portfolio.titleEn} +

+ ) : null} + + {portfolio.abstract ? ( +

+ {portfolio.abstract} +

+ ) : null} + + {date ? ( + + ) : null} +
+ + {cover ? ( +
+ +
+ ) : null} + + {gallery.length > 0 ? ( +
+ {gallery.map((item) => ( +
+ +
+ ))} +
+ ) : null} + + {portfolio.mainTextHtml ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx new file mode 100644 index 0000000..a02b744 --- /dev/null +++ b/src/app/portfolio/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ContentPageShell } from "@/components/ContentPageShell"; +import { Pagination } from "@/components/Pagination"; +import { getPortfolioCoverImage, getPortfolios } from "@/lib/api"; + +export const metadata: Metadata = { + title: "نمونه‌کارها | مشکی", + description: "نمونه پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.", +}; + +const PAGE_SIZE = 12; + +type PortfolioPageProps = { + searchParams: Promise<{ page?: string }>; +}; + +export default async function PortfolioListPage({ + searchParams, +}: PortfolioPageProps) { + const { page: pageParam } = await searchParams; + const page = Math.max(1, Number(pageParam) || 1); + + let items: Awaited>["items"] = []; + let total = 0; + + try { + const data = await getPortfolios({ page, pageSize: PAGE_SIZE }); + items = data.items; + total = data.total; + } catch { + items = []; + total = 0; + } + + return ( + +
+
+
+

+ نمونه‌کارها +

+

+ Portfolio +

+

+ نگاهی به پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی. +

+
+ + {items.length === 0 ? ( +

+ نمونه‌کاری برای نمایش وجود ندارد. +

+ ) : ( +
+ {items.map((item, index) => { + const image = getPortfolioCoverImage(item); + + return ( +
+ +
+ {image ? ( + + ) : null} +
+ {item.categoryName ? ( +

+ {item.categoryName} +

+ ) : null} +

+ {item.titleFa || item.title} +

+ {item.abstract ? ( +

+ {item.abstract} +

+ ) : null} + +
+ ); + })} +
+ )} + + +
+
+
+ ); +} diff --git a/src/app/website-qom/page.tsx b/src/app/website-qom/page.tsx new file mode 100644 index 0000000..8cdc543 --- /dev/null +++ b/src/app/website-qom/page.tsx @@ -0,0 +1,158 @@ +import type { Metadata } from "next"; +import { ServicePageLayout } from "@/components/ServicePageLayout"; +import { ServiceContactStrip } from "@/components/ServiceContactStrip"; +import { + ProjectsShowcase, + websiteQomProjects, +} from "@/components/WebsiteProjects"; +import { images } from "@/data/assets"; + +const CDN = + "https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files"; + +export const metadata: Metadata = { + title: "طراحی سایت در قم توسط تیم حرفه‌ای مشکی", + description: + "طراحی وبسایت در قم توسط تیم مشکی با استفاده از جدیدترین متدهای کدنویسی و CMS اختصاصی با بهترین قیمت.", +}; + +export default function WebsiteQomPage() { + return ( + + + + ), + }, + { + after: 1, + node: ( + + ), + }, + ]} + /> + ); +} diff --git a/src/components/AppSection.tsx b/src/components/AppSection.tsx index f9645ef..fad1d6f 100644 --- a/src/components/AppSection.tsx +++ b/src/components/AppSection.tsx @@ -8,7 +8,7 @@ export function AppSection() { >
-

+

طراحی و توسعه‌ی اپلیکیشن

diff --git a/src/components/BlogSection.tsx b/src/components/BlogSection.tsx index e357b63..db5de8f 100644 --- a/src/components/BlogSection.tsx +++ b/src/components/BlogSection.tsx @@ -1,50 +1,77 @@ -import { posts } from "@/data/home"; +import Link from "next/link"; +import { getBlogCoverImage, getBlogs } from "@/lib/api"; + +export async function BlogSection() { + let posts: Awaited>["items"] = []; + + try { + const data = await getBlogs({ page: 1, pageSize: 6 }); + posts = data.items; + } catch { + posts = []; + } + + if (posts.length === 0) return null; -export function BlogSection() { return (

-

+

رشدنامه، آموزشی و انگیزشی

Our Blog

-

- با محتوای مشکی همیشه به‌روز باشید. -

+
+

+ با محتوای مشکی همیشه به‌روز باشید. +

+ + مشاهده همه مقالات + +
- {posts.map((post, index) => ( - - ))} + {posts.map((post, index) => { + const image = getBlogCoverImage(post); + + return ( +
+ +
+ {image ? ( + + ) : null} +
+

+ {post.title} +

+ {post.abstract ? ( +

+ {post.abstract} +

+ ) : null} + +
+ ); + })}
diff --git a/src/components/CmsSection.tsx b/src/components/CmsSection.tsx index 381f7d2..29021f8 100644 --- a/src/components/CmsSection.tsx +++ b/src/components/CmsSection.tsx @@ -16,7 +16,7 @@ export function CmsSection() {
-

+

سیستم‌های مدیریت یکپارچه

diff --git a/src/components/ContactSection.tsx b/src/components/ContactSection.tsx index 1104430..08ec1d8 100644 --- a/src/components/ContactSection.tsx +++ b/src/components/ContactSection.tsx @@ -8,21 +8,25 @@ export function ContactSection() {

-
+

شما تماس بگیرید …

{site.phone}

آدرس

-

{site.address}

+

+ {site.address} +

-
-

تماس با ما

+
+

+ تماس با ما +

ما همیشه دنبال دوستای جدید هستیم — یا ما خدمتتون تماس بگیریم.

diff --git a/src/components/ContentPageShell.tsx b/src/components/ContentPageShell.tsx new file mode 100644 index 0000000..cc3e46f --- /dev/null +++ b/src/components/ContentPageShell.tsx @@ -0,0 +1,18 @@ +import { Header } from "@/components/Header"; +import { Footer } from "@/components/Footer"; +import { RevealObserver } from "@/components/RevealObserver"; + +type ContentPageShellProps = { + children: React.ReactNode; +}; + +export function ContentPageShell({ children }: ContentPageShellProps) { + return ( + <> + +
+
{children}
+