mirror of
https://git.meshkee.com/novintrades/website.git
synced 2026-08-11 20:50:58 +04:30
Add SEO meta descriptions and a dynamic sitemap.
Expose optional metaDescription for blogs, reportages, and brands in admin/API, apply it on detail pages, and serve an up-to-date /sitemap.xml for approved content plus static site pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5088e0f4ae
commit
16bc154fb0
@@ -3,6 +3,7 @@ DATABASE_URL=postgresql://novintrades:novintrades@localhost:5433/novintrades?sch
|
|||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
SITE_URL=https://novintrades.com
|
||||||
|
|
||||||
# ParsPack S3-compatible storage
|
# ParsPack S3-compatible storage
|
||||||
S3_ENDPOINT=https://c287211.parspack.net
|
S3_ENDPOINT=https://c287211.parspack.net
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function getBlog(id: string) {
|
|||||||
export function createBlog(input: {
|
export function createBlog(input: {
|
||||||
title: string;
|
title: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -50,6 +51,7 @@ export function updateBlog(
|
|||||||
input: {
|
input: {
|
||||||
title?: string;
|
title?: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -76,6 +78,7 @@ export function createReportage(input: {
|
|||||||
title: string;
|
title: string;
|
||||||
businessOwner: string;
|
businessOwner: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -95,6 +98,7 @@ export function updateReportage(
|
|||||||
title?: string;
|
title?: string;
|
||||||
businessOwner?: string;
|
businessOwner?: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -120,6 +124,7 @@ export function getBrand(id: string) {
|
|||||||
export function createBrand(input: {
|
export function createBrand(input: {
|
||||||
title: string;
|
title: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
galleryUrls?: string[];
|
galleryUrls?: string[];
|
||||||
@@ -143,6 +148,7 @@ export function updateBrand(
|
|||||||
input: {
|
input: {
|
||||||
title?: string;
|
title?: string;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
metaDescription?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
galleryUrls?: string[];
|
galleryUrls?: string[];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Blog {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -42,6 +43,7 @@ export interface Reportage {
|
|||||||
slug: string;
|
slug: string;
|
||||||
businessOwner: string;
|
businessOwner: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -71,6 +73,7 @@ export interface Brand {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
galleryUrls: string[];
|
galleryUrls: string[];
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function BlogNewPage() {
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [image, setImage] = useState<string | null>(null);
|
const [image, setImage] = useState<string | null>(null);
|
||||||
const [abstract, setAbstract] = useState("");
|
const [abstract, setAbstract] = useState("");
|
||||||
|
const [metaDescription, setMetaDescription] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -49,6 +50,7 @@ export function BlogNewPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTitle(blog.title);
|
setTitle(blog.title);
|
||||||
setAbstract(blog.abstract ?? "");
|
setAbstract(blog.abstract ?? "");
|
||||||
|
setMetaDescription(blog.metaDescription ?? "");
|
||||||
setContent(blog.content);
|
setContent(blog.content);
|
||||||
setImage(blog.imageUrl);
|
setImage(blog.imageUrl);
|
||||||
setTags(blog.tags);
|
setTags(blog.tags);
|
||||||
@@ -86,6 +88,7 @@ export function BlogNewPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
abstract: abstract.trim() || undefined,
|
abstract: abstract.trim() || undefined,
|
||||||
|
metaDescription: metaDescription.trim() || undefined,
|
||||||
content,
|
content,
|
||||||
imageUrl: image,
|
imageUrl: image,
|
||||||
tags,
|
tags,
|
||||||
@@ -175,6 +178,20 @@ export function BlogNewPage() {
|
|||||||
placeholder="Short summary shown in lists and previews"
|
placeholder="Short summary shown in lists and previews"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field__label" htmlFor="post-meta-description">
|
||||||
|
Meta description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="post-meta-description"
|
||||||
|
className="field-textarea"
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
placeholder="SEO description for search engines (recommended under 160 characters)"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function BrandNewPage() {
|
|||||||
const [categories, setCategories] = useState<string[]>([]);
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [about, setAbout] = useState("");
|
const [about, setAbout] = useState("");
|
||||||
|
const [metaDescription, setMetaDescription] = useState("");
|
||||||
const [image, setImage] = useState<string | null>(null);
|
const [image, setImage] = useState<string | null>(null);
|
||||||
const [gallery, setGallery] = useState<string[]>([]);
|
const [gallery, setGallery] = useState<string[]>([]);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
@@ -76,6 +77,7 @@ export function BrandNewPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setName(brand.title);
|
setName(brand.title);
|
||||||
setAbout(brand.abstract ?? "");
|
setAbout(brand.abstract ?? "");
|
||||||
|
setMetaDescription(brand.metaDescription ?? "");
|
||||||
setContent(brand.content);
|
setContent(brand.content);
|
||||||
setImage(brand.imageUrl);
|
setImage(brand.imageUrl);
|
||||||
setGallery(brand.galleryUrls ?? []);
|
setGallery(brand.galleryUrls ?? []);
|
||||||
@@ -118,6 +120,7 @@ export function BrandNewPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
title: name.trim(),
|
title: name.trim(),
|
||||||
abstract: about.trim() || undefined,
|
abstract: about.trim() || undefined,
|
||||||
|
metaDescription: metaDescription.trim() || undefined,
|
||||||
content,
|
content,
|
||||||
imageUrl: image,
|
imageUrl: image,
|
||||||
galleryUrls: gallery,
|
galleryUrls: gallery,
|
||||||
@@ -214,6 +217,20 @@ export function BrandNewPage() {
|
|||||||
placeholder="Short description of the business"
|
placeholder="Short description of the business"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field__label" htmlFor="brand-meta-description">
|
||||||
|
Meta description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="brand-meta-description"
|
||||||
|
className="field-textarea"
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
placeholder="SEO description for search engines (recommended under 160 characters)"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function ReportageNewPage() {
|
|||||||
const [businessOwner, setBusinessOwner] = useState("");
|
const [businessOwner, setBusinessOwner] = useState("");
|
||||||
const [image, setImage] = useState<string | null>(null);
|
const [image, setImage] = useState<string | null>(null);
|
||||||
const [abstract, setAbstract] = useState("");
|
const [abstract, setAbstract] = useState("");
|
||||||
|
const [metaDescription, setMetaDescription] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -51,6 +52,7 @@ export function ReportageNewPage() {
|
|||||||
setTitle(reportage.title);
|
setTitle(reportage.title);
|
||||||
setBusinessOwner(reportage.businessOwner);
|
setBusinessOwner(reportage.businessOwner);
|
||||||
setAbstract(reportage.abstract ?? "");
|
setAbstract(reportage.abstract ?? "");
|
||||||
|
setMetaDescription(reportage.metaDescription ?? "");
|
||||||
setContent(reportage.content);
|
setContent(reportage.content);
|
||||||
setImage(reportage.imageUrl);
|
setImage(reportage.imageUrl);
|
||||||
setTags(reportage.tags);
|
setTags(reportage.tags);
|
||||||
@@ -91,6 +93,7 @@ export function ReportageNewPage() {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
businessOwner: businessOwner.trim(),
|
businessOwner: businessOwner.trim(),
|
||||||
abstract: abstract.trim() || undefined,
|
abstract: abstract.trim() || undefined,
|
||||||
|
metaDescription: metaDescription.trim() || undefined,
|
||||||
content,
|
content,
|
||||||
imageUrl: image,
|
imageUrl: image,
|
||||||
tags,
|
tags,
|
||||||
@@ -194,6 +197,20 @@ export function ReportageNewPage() {
|
|||||||
placeholder="Short summary for listings"
|
placeholder="Short summary for listings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field__label" htmlFor="reportage-meta-description">
|
||||||
|
Meta description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="reportage-meta-description"
|
||||||
|
className="field-textarea"
|
||||||
|
value={metaDescription}
|
||||||
|
onChange={(e) => setMetaDescription(e.target.value)}
|
||||||
|
placeholder="SEO description for search engines (recommended under 160 characters)"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Blog" ADD COLUMN "metaDescription" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Reportage" ADD COLUMN "metaDescription" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Brand" ADD COLUMN "metaDescription" TEXT;
|
||||||
@@ -49,6 +49,7 @@ model Blog {
|
|||||||
title String
|
title String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
abstract String?
|
abstract String?
|
||||||
|
metaDescription String?
|
||||||
content String @default("")
|
content String @default("")
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
@@ -80,6 +81,7 @@ model Reportage {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
businessOwner String
|
businessOwner String
|
||||||
abstract String?
|
abstract String?
|
||||||
|
metaDescription String?
|
||||||
content String @default("")
|
content String @default("")
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
@@ -110,6 +112,7 @@ model Brand {
|
|||||||
title String
|
title String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
abstract String?
|
abstract String?
|
||||||
|
metaDescription String?
|
||||||
content String @default("")
|
content String @default("")
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
galleryUrls String[] @default([])
|
galleryUrls String[] @default([])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Fastify from "fastify";
|
|||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import { prisma } from "./lib/prisma.js";
|
import { prisma } from "./lib/prisma.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { sitemapRoutes } from "./routes/sitemap.js";
|
||||||
import { categoryRoutes } from "./routes/categories.js";
|
import { categoryRoutes } from "./routes/categories.js";
|
||||||
import { blogRoutes } from "./routes/blogs.js";
|
import { blogRoutes } from "./routes/blogs.js";
|
||||||
import { reportageRoutes } from "./routes/reportages.js";
|
import { reportageRoutes } from "./routes/reportages.js";
|
||||||
@@ -21,6 +22,7 @@ export async function buildApp() {
|
|||||||
app.decorate("prisma", prisma);
|
app.decorate("prisma", prisma);
|
||||||
|
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
|
await app.register(sitemapRoutes);
|
||||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||||
await app.register(categoryRoutes, { prefix: "/api/categories" });
|
await app.register(categoryRoutes, { prefix: "/api/categories" });
|
||||||
await app.register(blogRoutes, { prefix: "/api/blogs" });
|
await app.register(blogRoutes, { prefix: "/api/blogs" });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const createSchema = z.object({
|
|||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
slug: z.string().min(1).optional(),
|
slug: z.string().min(1).optional(),
|
||||||
abstract: z.string().optional(),
|
abstract: z.string().optional(),
|
||||||
|
metaDescription: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
imageUrl: z.string().url().nullable().optional(),
|
imageUrl: z.string().url().nullable().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
@@ -58,6 +59,7 @@ export const blogRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
slug,
|
slug,
|
||||||
abstract: parsed.data.abstract,
|
abstract: parsed.data.abstract,
|
||||||
|
metaDescription: parsed.data.metaDescription,
|
||||||
content: parsed.data.content ?? "",
|
content: parsed.data.content ?? "",
|
||||||
imageUrl: parsed.data.imageUrl ?? null,
|
imageUrl: parsed.data.imageUrl ?? null,
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
@@ -102,6 +104,9 @@ export const blogRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||||
|
...(data.metaDescription !== undefined
|
||||||
|
? { metaDescription: data.metaDescription }
|
||||||
|
: {}),
|
||||||
...(data.content !== undefined ? { content: data.content } : {}),
|
...(data.content !== undefined ? { content: data.content } : {}),
|
||||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||||
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const createSchema = z.object({
|
|||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
slug: z.string().min(1).optional(),
|
slug: z.string().min(1).optional(),
|
||||||
abstract: z.string().optional(),
|
abstract: z.string().optional(),
|
||||||
|
metaDescription: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
imageUrl: z.string().url().nullable().optional(),
|
imageUrl: z.string().url().nullable().optional(),
|
||||||
galleryUrls: z.array(z.string().url()).optional(),
|
galleryUrls: z.array(z.string().url()).optional(),
|
||||||
@@ -91,6 +92,7 @@ export const brandRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
slug,
|
slug,
|
||||||
abstract: parsed.data.abstract,
|
abstract: parsed.data.abstract,
|
||||||
|
metaDescription: parsed.data.metaDescription,
|
||||||
content: parsed.data.content ?? "",
|
content: parsed.data.content ?? "",
|
||||||
imageUrl: parsed.data.imageUrl ?? null,
|
imageUrl: parsed.data.imageUrl ?? null,
|
||||||
galleryUrls,
|
galleryUrls,
|
||||||
@@ -144,6 +146,9 @@ export const brandRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
? { slug: slugify(data.slug ?? data.title ?? existing.title) }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||||
|
...(data.metaDescription !== undefined
|
||||||
|
? { metaDescription: data.metaDescription }
|
||||||
|
: {}),
|
||||||
...(data.content !== undefined ? { content: data.content } : {}),
|
...(data.content !== undefined ? { content: data.content } : {}),
|
||||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||||
...(galleryUrls !== undefined ? { galleryUrls } : {}),
|
...(galleryUrls !== undefined ? { galleryUrls } : {}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const createSchema = z.object({
|
|||||||
slug: z.string().min(1).optional(),
|
slug: z.string().min(1).optional(),
|
||||||
businessOwner: z.string().min(1),
|
businessOwner: z.string().min(1),
|
||||||
abstract: z.string().optional(),
|
abstract: z.string().optional(),
|
||||||
|
metaDescription: z.string().optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
imageUrl: z.string().url().nullable().optional(),
|
imageUrl: z.string().url().nullable().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
@@ -60,6 +61,7 @@ export const reportageRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
slug,
|
slug,
|
||||||
businessOwner: parsed.data.businessOwner,
|
businessOwner: parsed.data.businessOwner,
|
||||||
abstract: parsed.data.abstract,
|
abstract: parsed.data.abstract,
|
||||||
|
metaDescription: parsed.data.metaDescription,
|
||||||
content: parsed.data.content ?? "",
|
content: parsed.data.content ?? "",
|
||||||
imageUrl: parsed.data.imageUrl ?? null,
|
imageUrl: parsed.data.imageUrl ?? null,
|
||||||
tags: parsed.data.tags ?? [],
|
tags: parsed.data.tags ?? [],
|
||||||
@@ -107,6 +109,9 @@ export const reportageRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
? { businessOwner: data.businessOwner }
|
? { businessOwner: data.businessOwner }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
...(data.abstract !== undefined ? { abstract: data.abstract } : {}),
|
||||||
|
...(data.metaDescription !== undefined
|
||||||
|
? { metaDescription: data.metaDescription }
|
||||||
|
: {}),
|
||||||
...(data.content !== undefined ? { content: data.content } : {}),
|
...(data.content !== undefined ? { content: data.content } : {}),
|
||||||
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
...(data.imageUrl !== undefined ? { imageUrl: data.imageUrl } : {}),
|
||||||
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import { VerificationStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const DEFAULT_SITE_URL = "https://novintrades.com";
|
||||||
|
|
||||||
|
function siteOrigin(): string {
|
||||||
|
const raw = process.env.SITE_URL?.trim() || DEFAULT_SITE_URL;
|
||||||
|
return raw.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLastmod(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SitemapEntry = {
|
||||||
|
path: string;
|
||||||
|
lastmod?: Date;
|
||||||
|
changefreq?: string;
|
||||||
|
priority?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderUrl(origin: string, entry: SitemapEntry): string {
|
||||||
|
const lines = [
|
||||||
|
" <url>",
|
||||||
|
` <loc>${escapeXml(`${origin}${entry.path}`)}</loc>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (entry.lastmod) {
|
||||||
|
lines.push(` <lastmod>${toLastmod(entry.lastmod)}</lastmod>`);
|
||||||
|
}
|
||||||
|
if (entry.changefreq) {
|
||||||
|
lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
|
||||||
|
}
|
||||||
|
if (entry.priority) {
|
||||||
|
lines.push(` <priority>${entry.priority}</priority>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" </url>");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sitemapRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.get("/sitemap.xml", async (_request, reply) => {
|
||||||
|
const origin = siteOrigin();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [blogs, reportages, brands] = await Promise.all([
|
||||||
|
app.prisma.blog.findMany({
|
||||||
|
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
app.prisma.reportage.findMany({
|
||||||
|
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
app.prisma.brand.findMany({
|
||||||
|
where: { verificationStatus: VerificationStatus.APPROVED },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const staticEntries: SitemapEntry[] = [
|
||||||
|
{ path: "/", changefreq: "daily", priority: "1.0", lastmod: now },
|
||||||
|
{ path: "/about", changefreq: "monthly", priority: "0.6", lastmod: now },
|
||||||
|
{ path: "/contact", changefreq: "monthly", priority: "0.6", lastmod: now },
|
||||||
|
{ path: "/blog", changefreq: "daily", priority: "0.8", lastmod: now },
|
||||||
|
{
|
||||||
|
path: "/reportages",
|
||||||
|
changefreq: "daily",
|
||||||
|
priority: "0.8",
|
||||||
|
lastmod: now,
|
||||||
|
},
|
||||||
|
{ path: "/brands", changefreq: "daily", priority: "0.8", lastmod: now },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dynamicEntries: SitemapEntry[] = [
|
||||||
|
...blogs.map((item) => ({
|
||||||
|
path: `/blog/${item.slug}`,
|
||||||
|
lastmod: item.updatedAt,
|
||||||
|
changefreq: "weekly",
|
||||||
|
priority: "0.7",
|
||||||
|
})),
|
||||||
|
...reportages.map((item) => ({
|
||||||
|
path: `/reportages/${item.slug}`,
|
||||||
|
lastmod: item.updatedAt,
|
||||||
|
changefreq: "weekly",
|
||||||
|
priority: "0.7",
|
||||||
|
})),
|
||||||
|
...brands.map((item) => ({
|
||||||
|
path: `/brands/${item.slug}`,
|
||||||
|
lastmod: item.updatedAt,
|
||||||
|
changefreq: "weekly",
|
||||||
|
priority: "0.7",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...staticEntries.map((entry) => renderUrl(origin, entry)),
|
||||||
|
...dynamicEntries.map((entry) => renderUrl(origin, entry)),
|
||||||
|
"</urlset>",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.type("application/xml; charset=utf-8")
|
||||||
|
.header("Cache-Control", "public, max-age=300")
|
||||||
|
.send(body);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://novintrades.com/sitemap.xml
|
||||||
@@ -18,6 +18,7 @@ export interface Blog {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -34,6 +35,7 @@ export interface Reportage {
|
|||||||
slug: string;
|
slug: string;
|
||||||
businessOwner: string;
|
businessOwner: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -54,6 +56,7 @@ export interface Brand {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
abstract: string | null;
|
abstract: string | null;
|
||||||
|
metaDescription: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
galleryUrls: string[];
|
galleryUrls: string[];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const DEFAULT_TITLE = "NovinTrades";
|
||||||
|
const DEFAULT_DESCRIPTION =
|
||||||
|
"NovinTrades — discover brands, insights, and trade opportunities across industries.";
|
||||||
|
|
||||||
|
function getOrCreateMetaDescription(): HTMLMetaElement {
|
||||||
|
const existing = document.querySelector<HTMLMetaElement>(
|
||||||
|
'meta[name="description"]',
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const meta = document.createElement("meta");
|
||||||
|
meta.name = "description";
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageMeta(options: {
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}) {
|
||||||
|
const { title, description } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousTitle = document.title;
|
||||||
|
const meta = getOrCreateMetaDescription();
|
||||||
|
const previousDescription = meta.content;
|
||||||
|
|
||||||
|
document.title = title?.trim()
|
||||||
|
? `${title.trim()} | NovinTrades`
|
||||||
|
: DEFAULT_TITLE;
|
||||||
|
meta.content = description?.trim() || DEFAULT_DESCRIPTION;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.title = previousTitle;
|
||||||
|
meta.content = previousDescription;
|
||||||
|
};
|
||||||
|
}, [title, description]);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
fetchBlogBySlug,
|
fetchBlogBySlug,
|
||||||
} from "../lib/services";
|
} from "../lib/services";
|
||||||
import type { Blog, Brand } from "../lib/types";
|
import type { Blog, Brand } from "../lib/types";
|
||||||
|
import { usePageMeta } from "../lib/usePageMeta";
|
||||||
import "./detail.css";
|
import "./detail.css";
|
||||||
|
|
||||||
export default function BlogDetailPage() {
|
export default function BlogDetailPage() {
|
||||||
@@ -20,6 +21,11 @@ export default function BlogDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: blog?.title,
|
||||||
|
description: blog?.metaDescription || blog?.abstract,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
fetchBrandBySlug,
|
fetchBrandBySlug,
|
||||||
} from "../lib/services";
|
} from "../lib/services";
|
||||||
import type { Brand } from "../lib/types";
|
import type { Brand } from "../lib/types";
|
||||||
|
import { usePageMeta } from "../lib/usePageMeta";
|
||||||
import "./detail.css";
|
import "./detail.css";
|
||||||
|
|
||||||
export default function BrandDetailPage() {
|
export default function BrandDetailPage() {
|
||||||
@@ -18,6 +19,11 @@ export default function BrandDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: brand?.title,
|
||||||
|
description: brand?.metaDescription || brand?.abstract,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
fetchReportageBySlug,
|
fetchReportageBySlug,
|
||||||
} from "../lib/services";
|
} from "../lib/services";
|
||||||
import type { Blog, Reportage } from "../lib/types";
|
import type { Blog, Reportage } from "../lib/types";
|
||||||
|
import { usePageMeta } from "../lib/usePageMeta";
|
||||||
import "./detail.css";
|
import "./detail.css";
|
||||||
|
|
||||||
export default function ReportageDetailPage() {
|
export default function ReportageDetailPage() {
|
||||||
@@ -18,6 +19,11 @@ export default function ReportageDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: reportage?.title,
|
||||||
|
description: reportage?.metaDescription || reportage?.abstract,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export default defineConfig({
|
|||||||
target: "http://127.0.0.1:3000",
|
target: "http://127.0.0.1:3000",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
"/sitemap.xml": {
|
||||||
|
target: "http://127.0.0.1:3000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user