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>
97 lines
2.9 KiB
TypeScript
97 lines
2.9 KiB
TypeScript
import { Component, createSignal, onMount } from 'solid-js';
|
|
import { useParams } from '@solidjs/router';
|
|
import { initAnalytics, trackPageView } from '../hooks/useAnalytics';
|
|
import Footer from '../components/Footer';
|
|
|
|
interface BlogPost {
|
|
slug: string;
|
|
title: string;
|
|
excerpt: string | null;
|
|
content: string;
|
|
authorName: string | null;
|
|
coverImageUrl: string | null;
|
|
tags: string[];
|
|
publishedAt: string;
|
|
viewCount: number;
|
|
}
|
|
|
|
const BlogPostPage: Component = () => {
|
|
const params = useParams();
|
|
const [post, setPost] = createSignal<BlogPost | null>(null);
|
|
const [loading, setLoading] = createSignal(true);
|
|
const [notFound, setNotFound] = createSignal(false);
|
|
|
|
onMount(async () => {
|
|
initAnalytics();
|
|
|
|
try {
|
|
const res = await fetch(`/api/blog/${params.slug}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPost(data.post);
|
|
trackPageView(`/blog/${params.slug}`, data.post.title);
|
|
} else {
|
|
setNotFound(true);
|
|
}
|
|
} catch {
|
|
setNotFound(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<main>
|
|
{loading() ? (
|
|
<div class="blog-post-loading">
|
|
<div class="container">
|
|
<p>Loading article...</p>
|
|
</div>
|
|
</div>
|
|
) : notFound() || !post() ? (
|
|
<div class="blog-post-not-found">
|
|
<div class="container">
|
|
<a href="/blog" class="back-link">← Back to Blog</a>
|
|
<h1>Article Not Found</h1>
|
|
<p>The article you're looking for doesn't exist or has been removed.</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<article class="blog-post">
|
|
<div class="container">
|
|
<a href="/blog" class="back-link">← Back to Blog</a>
|
|
<header class="blog-post-header">
|
|
<h1>{post()!.title}</h1>
|
|
<div class="blog-post-meta">
|
|
{post()!.authorName && <span>By {post()!.authorName}</span>}
|
|
{post()!.publishedAt && (
|
|
<span>{new Date(post()!.publishedAt).toLocaleDateString()}</span>
|
|
)}
|
|
<span>{post()!.viewCount} views</span>
|
|
</div>
|
|
{post()!.tags.length > 0 && (
|
|
<div class="blog-post-tags">
|
|
{post()!.tags.map((tag) => (
|
|
<span class="tag">{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</header>
|
|
{post()!.coverImageUrl && (
|
|
<div class="blog-post-cover">
|
|
<img src={post()!.coverImageUrl} alt={post()!.title} />
|
|
</div>
|
|
)}
|
|
<div class="blog-post-content" innerHTML={post()!.content} />
|
|
</div>
|
|
</article>
|
|
<Footer />
|
|
</>
|
|
)}
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default BlogPostPage;
|