Files
yingfungsolar/src/data/content.ts
T

177 lines
5.6 KiB
TypeScript

import { and, asc, desc, eq, isNull, ne, sql } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
blogKeywords,
cases,
contentItems,
CONTENT_KINDS,
posts,
siteSettings,
type BlogKeyword,
type CaseStudy,
type ContentItem,
type ContentKind,
type Post,
} from "../db/schema";
import * as schema from "../db/schema";
export type Db = DrizzleD1Database<typeof schema>;
export type Settings = Record<string, string>;
export type HomeData = {
settings: Settings;
services: ContentItem[];
features: ContentItem[];
steps: ContentItem[];
faqs: ContentItem[];
cases: CaseStudy[];
};
export async function getSettings(db: Db): Promise<Settings> {
const rows = await db.select().from(siteSettings);
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
export async function getItems(db: Db, kind: ContentKind): Promise<ContentItem[]> {
return db
.select()
.from(contentItems)
.where(and(eq(contentItems.kind, kind), eq(contentItems.status, "published")))
.orderBy(asc(contentItems.sortOrder));
}
export async function getCases(db: Db): Promise<CaseStudy[]> {
return db
.select()
.from(cases)
.where(eq(cases.status, "published"))
.orderBy(asc(cases.sortOrder));
}
export async function getPublishedPosts(db: Db): Promise<Post[]> {
return db
.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
}
export async function getPostBySlug(db: Db, slug: string): Promise<Post | null> {
const [post] = await db
.select()
.from(posts)
.where(and(eq(posts.slug, slug), eq(posts.status, "published")))
.limit(1);
return post ?? null;
}
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
return db
.select()
.from(blogKeywords)
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
}
export type DashboardData = {
posts: { published: number; draft: number };
cases: { published: number; draft: number };
content: { kind: ContentKind; published: number; draft: number }[];
keywords: { pending: number; generated: number; skipped: number };
recentPosts: Post[];
postsWithoutCover: number;
};
/** 後台總覽用嘅統計(只讀 DB;AI secrets 由頁面層提供)。 */
export async function getDashboardData(db: Db): Promise<DashboardData> {
const [postRows, caseRows, itemRows, kwRows, recentPosts, noCover] = await Promise.all([
db.select({ status: posts.status, n: sql<number>`count(*)` }).from(posts).groupBy(posts.status),
db.select({ status: cases.status, n: sql<number>`count(*)` }).from(cases).groupBy(cases.status),
db
.select({ kind: contentItems.kind, status: contentItems.status, n: sql<number>`count(*)` })
.from(contentItems)
.groupBy(contentItems.kind, contentItems.status),
db
.select({ status: blogKeywords.status, n: sql<number>`count(*)` })
.from(blogKeywords)
.groupBy(blogKeywords.status),
db.select().from(posts).orderBy(desc(posts.updatedAt)).limit(5),
db.select({ n: sql<number>`count(*)` }).from(posts).where(isNull(posts.coverImage)),
]);
const pick = <T extends string>(rows: { status: T; n: number }[], status: T) =>
Number(rows.find((r) => r.status === status)?.n ?? 0);
return {
posts: { published: pick(postRows, "published"), draft: pick(postRows, "draft") },
cases: { published: pick(caseRows, "published"), draft: pick(caseRows, "draft") },
content: CONTENT_KINDS.map((kind) => ({
kind,
published: Number(itemRows.find((r) => r.kind === kind && r.status === "published")?.n ?? 0),
draft: Number(itemRows.find((r) => r.kind === kind && r.status === "draft")?.n ?? 0),
})),
keywords: {
pending: pick(kwRows, "pending"),
generated: pick(kwRows, "generated"),
skipped: pick(kwRows, "skipped"),
},
recentPosts,
postsWithoutCover: Number(noCover[0]?.n ?? 0),
};
}
export async function getHomeData(db: Db): Promise<HomeData> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
getItems(db, "service"),
getItems(db, "feature"),
getItems(db, "step"),
getItems(db, "faq"),
getCases(db),
]);
return { settings, services, features, steps, faqs, cases: caseList };
}
export function digits(input: string | undefined): string {
return (input ?? "").replace(/\D/g, "");
}
export function whatsappHref(
settings: Settings,
text = "你好,我想查詢村屋太陽能安裝,想預約免費評估。",
): string {
return `https://api.whatsapp.com/send?phone=${digits(settings.whatsapp)}&text=${encodeURIComponent(text)}`;
}
export function telHref(settings: Settings): string {
const phone = (settings.phone ?? "").replace(/[^\d+]/g, "");
return `tel:${phone}`;
}
export function mailHref(settings: Settings): string {
return `mailto:${settings.email ?? ""}?subject=${encodeURIComponent("查詢村屋太陽能安裝")}`;
}
export function formatDate(d: Date | null | undefined): string {
if (!d) return "";
return new Date(d).toLocaleDateString("zh-Hant-HK", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}