Build waitlist landing page with Solid.js (hero, features, tier comparison, waitlist signup form, blog preview, footer). Create waitlist signup and blog API endpoints in Fastify. Add WaitlistEntry and BlogPost models to Prisma schema. Create analytics hooks for GA4 and Mixpanel tracking. Fix pre-existing Prisma schema issue (AnalysisJob relation missing User field). - Landing page: responsive Solid.js app with hero, 6 feature cards, 3-tier pricing comparison table, blog preview, and full waitlist signup form with interest tier selection - API: POST /api/waitlist/signup, GET /api/waitlist/count, GET /api/blog, GET /api/blog/:slug, CRUD /api/admin/blog - DB models: WaitlistEntry (with UTM params, conversion tracking, source), BlogPost (with tags, view count, publish scheduling) - Analytics: useAnalytics hook with initAnalytics(), trackEvent(), trackWaitlistSignup(), trackPageView() — GA4 and Mixpanel dual-tracking - Blog: listing, detail, and admin CRUD routes; seed.ts with 3 starter articles - Fix: AnalysisJob.analysisJobId missing @unique constraint, missing analysisJobs[] on User model Delegated to CMO: FRE-5280 (GA4 config), FRE-5281 (Mixpanel config), FRE-5282 (email marketing platform) Co-Authored-By: Paperclip <noreply@paperclip.ing>
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import { prisma } from '@shieldai/db';
|
|
|
|
interface CreatePostBody {
|
|
slug: string;
|
|
title: string;
|
|
excerpt?: string;
|
|
content: string;
|
|
authorName?: string;
|
|
coverImageUrl?: string;
|
|
tags?: string[];
|
|
published?: boolean;
|
|
publishedAt?: string;
|
|
}
|
|
|
|
export async function blogAdminRoutes(fastify: FastifyInstance) {
|
|
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const authReq = request as FastifyRequest & { user?: { id: string; role?: string } };
|
|
const user = authReq.user;
|
|
|
|
if (!user) {
|
|
return reply.code(401).send({ error: 'Unauthorized' });
|
|
}
|
|
|
|
if (user.role !== 'support') {
|
|
return reply.code(403).send({ error: 'Admin access required' });
|
|
}
|
|
});
|
|
|
|
fastify.post('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const body = request.body as CreatePostBody;
|
|
|
|
if (!body.slug || !/^[a-z0-9-]+$/.test(body.slug)) {
|
|
return reply.code(400).send({ error: 'Invalid slug: must be lowercase alphanumeric with hyphens' });
|
|
}
|
|
if (!body.title || body.title.length > 200) {
|
|
return reply.code(400).send({ error: 'Title is required (max 200 chars)' });
|
|
}
|
|
if (!body.content) {
|
|
return reply.code(400).send({ error: 'Content is required' });
|
|
}
|
|
|
|
const existing = await prisma.blogPost.findUnique({
|
|
where: { slug: body.slug },
|
|
});
|
|
|
|
if (existing) {
|
|
return reply.code(409).send({ error: 'A post with this slug already exists' });
|
|
}
|
|
|
|
const post = await prisma.blogPost.create({
|
|
data: {
|
|
slug: body.slug,
|
|
title: body.title,
|
|
excerpt: body.excerpt || null,
|
|
content: body.content,
|
|
authorName: body.authorName || null,
|
|
coverImageUrl: body.coverImageUrl || null,
|
|
tags: body.tags || [],
|
|
published: body.published || false,
|
|
publishedAt: body.publishedAt
|
|
? new Date(body.publishedAt)
|
|
: body.published
|
|
? new Date()
|
|
: null,
|
|
},
|
|
});
|
|
|
|
return reply.code(201).send({ post });
|
|
});
|
|
|
|
fastify.put('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as Partial<CreatePostBody>;
|
|
|
|
const existing = await prisma.blogPost.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
return reply.code(404).send({ error: 'Post not found' });
|
|
}
|
|
|
|
if (body.slug && body.slug !== existing.slug) {
|
|
const slugExists = await prisma.blogPost.findUnique({ where: { slug: body.slug } });
|
|
if (slugExists) {
|
|
return reply.code(409).send({ error: 'A post with this slug already exists' });
|
|
}
|
|
}
|
|
|
|
const post = await prisma.blogPost.update({
|
|
where: { id },
|
|
data: {
|
|
...(body.slug !== undefined && { slug: body.slug }),
|
|
...(body.title !== undefined && { title: body.title }),
|
|
...(body.excerpt !== undefined && { excerpt: body.excerpt }),
|
|
...(body.content !== undefined && { content: body.content }),
|
|
...(body.authorName !== undefined && { authorName: body.authorName }),
|
|
...(body.coverImageUrl !== undefined && { coverImageUrl: body.coverImageUrl }),
|
|
...(body.tags !== undefined && { tags: body.tags }),
|
|
...(body.published !== undefined && { published: body.published }),
|
|
publishedAt: body.publishedAt
|
|
? new Date(body.publishedAt)
|
|
: body.published === true && !existing.published
|
|
? new Date()
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
return reply.send({ post });
|
|
});
|
|
|
|
fastify.delete('/admin/blog/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const { id } = request.params as { id: string };
|
|
await prisma.blogPost.delete({ where: { id } });
|
|
return reply.code(204).send();
|
|
});
|
|
|
|
fastify.get('/admin/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
const query = request.query as { page?: string; limit?: string };
|
|
const page = Math.max(1, parseInt(query.page || '1', 10));
|
|
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '20', 10)));
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [posts, total] = await Promise.all([
|
|
prisma.blogPost.findMany({
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: limit,
|
|
}),
|
|
prisma.blogPost.count(),
|
|
]);
|
|
|
|
return reply.send({
|
|
posts,
|
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
|
});
|
|
});
|
|
}
|