ShieldAI waitlist landing page and analytics infrastructure FRE-5274
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>
This commit is contained in:
136
packages/api/src/routes/blog-admin.routes.ts
Normal file
136
packages/api/src/routes/blog-admin.routes.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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) },
|
||||
});
|
||||
});
|
||||
}
|
||||
72
packages/api/src/routes/blog.routes.ts
Normal file
72
packages/api/src/routes/blog.routes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@shieldai/db';
|
||||
|
||||
interface BlogQuery {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export async function blogRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/blog', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const query = request.query as BlogQuery;
|
||||
const page = Math.max(1, parseInt(query.page || '1', 10));
|
||||
const limit = Math.min(50, Math.max(1, parseInt(query.limit || '10', 10)));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
published: true,
|
||||
...(query.tag ? { tags: { has: query.tag } } : {}),
|
||||
};
|
||||
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.blogPost.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
authorName: true,
|
||||
coverImageUrl: true,
|
||||
tags: true,
|
||||
publishedAt: true,
|
||||
viewCount: true,
|
||||
},
|
||||
}),
|
||||
prisma.blogPost.count({ where }),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
posts,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/blog/:slug', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { slug } = request.params as { slug: string };
|
||||
|
||||
const post = await prisma.blogPost.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!post || !post.published) {
|
||||
return reply.code(404).send({ error: 'Post not found' });
|
||||
}
|
||||
|
||||
await prisma.blogPost.update({
|
||||
where: { id: post.id },
|
||||
data: { viewCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return reply.send({ post });
|
||||
});
|
||||
}
|
||||
61
packages/api/src/routes/waitlist.routes.ts
Normal file
61
packages/api/src/routes/waitlist.routes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@shieldai/db';
|
||||
|
||||
interface WaitlistSignupBody {
|
||||
email: string;
|
||||
name?: string;
|
||||
tier?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
export async function waitlistRoutes(fastify: FastifyInstance) {
|
||||
fastify.post('/waitlist/signup', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as WaitlistSignupBody;
|
||||
|
||||
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||
return reply.code(400).send({ error: 'Valid email is required' });
|
||||
}
|
||||
|
||||
const email = body.email.toLowerCase().trim();
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return reply.code(200).send({
|
||||
message: 'Already on the waitlist',
|
||||
id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
const validTiers = ['basic', 'plus', 'premium'] as const;
|
||||
const tier = validTiers.includes(body.tier as typeof validTiers[number])
|
||||
? (body.tier as string)
|
||||
: undefined;
|
||||
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: {
|
||||
email,
|
||||
name: body.name?.trim() || null,
|
||||
source: 'landing_page',
|
||||
tier: tier as any || null,
|
||||
utmSource: body.utmSource || null,
|
||||
utmMedium: body.utmMedium || null,
|
||||
utmCampaign: body.utmCampaign || null,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.code(201).send({
|
||||
message: 'Welcome to the ShieldAI waitlist',
|
||||
id: entry.id,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/waitlist/count', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const count = await prisma.waitlistEntry.count();
|
||||
return reply.send({ count });
|
||||
});
|
||||
}
|
||||
112
packages/api/src/seed.ts
Normal file
112
packages/api/src/seed.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { prisma } from '@shieldai/db';
|
||||
|
||||
const blogPosts = [
|
||||
{
|
||||
slug: 'what-is-ai-voice-cloning',
|
||||
title: 'What Is AI Voice Cloning and How to Protect Your Family',
|
||||
excerpt: 'AI voice cloning technology is advancing rapidly. Learn how scammers use it to impersonate loved ones and how ShieldAI detects these attacks in real time.',
|
||||
content: `<h2>Understanding AI Voice Cloning</h2>
|
||||
<p>AI voice cloning uses deep learning models to analyze a small sample of someone's voice—sometimes just a few seconds from a social media video or phone call—and generate new speech that sounds identical to the original speaker.</p>
|
||||
|
||||
<h2>How Scammers Exploit It</h2>
|
||||
<p>The most common attack pattern involves a scammer calling a victim while using a cloned voice of a family member. The fake "family member" claims to be in distress—needing bail money, hospital fees, or help with a car accident. The emotional urgency makes victims less likely to question the call's authenticity.</p>
|
||||
|
||||
<h2>Warning Signs</h2>
|
||||
<ul>
|
||||
<li>Unexpected calls from family members asking for money</li>
|
||||
<li>Slight delays or unnatural pauses in speech</li>
|
||||
<li>Background noise that doesn't match the claimed location</li>
|
||||
<li>Requests to keep the call secret or avoid contacting other family members</li>
|
||||
</ul>
|
||||
|
||||
<h2>How ShieldAI Protects You</h2>
|
||||
<p>ShieldAI's VoicePrint technology creates audio fingerprints for each family member's voice. When an incoming call is detected, our AI analyzes the audio in real time and flags any call that doesn't match the verified voiceprint. You'll receive an instant alert if a voice clone is suspected.</p>`,
|
||||
authorName: 'ShieldAI Team',
|
||||
tags: ['voice cloning', 'AI scams', 'family protection'],
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
slug: 'dark-web-monitoring-guide',
|
||||
title: 'Dark Web Monitoring: What Gets Exposed and How to Stay Safe',
|
||||
excerpt: 'Your personal data is traded on dark web marketplaces every day. Here is what criminals buy, how they use it, and how ShieldAI monitors for your exposure.',
|
||||
content: `<h2>What Is the Dark Web?</h2>
|
||||
<p>The dark web is a hidden part of the internet accessible only through specialized browsers like Tor. While it has legitimate uses for privacy and journalism, it is also the primary marketplace for stolen data, including emails, passwords, phone numbers, and Social Security numbers.</p>
|
||||
|
||||
<h2>What Data Gets Exposed</h2>
|
||||
<ul>
|
||||
<li><strong>Email addresses</strong> — used for phishing and credential stuffing attacks</li>
|
||||
<li><strong>Phone numbers</strong> — sold to robocallers and used for SIM swapping</li>
|
||||
<li><strong>Passwords</strong> — sold in bulk for account takeover attempts</li>
|
||||
<li><strong>Social Security Numbers</strong> — used for identity theft and tax fraud</li>
|
||||
<li><strong>Home addresses</strong> — used for physical threats and doxxing</li>
|
||||
</ul>
|
||||
|
||||
<h2>How ShieldAI Monitors for You</h2>
|
||||
<p>ShieldAI continuously scans dark web marketplaces, forums, and known data leak repositories. When your monitored data appears in a new leak, we send you an immediate alert with details about what was exposed and recommended next steps.</p>
|
||||
|
||||
<h2>What to Do If Your Data Is Leaked</h2>
|
||||
<ol>
|
||||
<li>Change passwords immediately — use unique passwords for each service</li>
|
||||
<li>Enable two-factor authentication everywhere</li>
|
||||
<li>Freeze your credit if SSN was exposed</li>
|
||||
<li>Monitor bank and credit card statements for unusual activity</li>
|
||||
<li>Run a ShieldAI dark web scan to check for additional exposures</li>
|
||||
</ol>`,
|
||||
authorName: 'ShieldAI Team',
|
||||
tags: ['dark web', 'data breach', 'identity theft'],
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
slug: 'spam-call-statistics-2025',
|
||||
title: 'Spam Call Statistics 2025: The Rise of AI-Powered Phone Scams',
|
||||
excerpt: 'Spam calls are at an all-time high, and AI is making them harder to detect. Here are the latest numbers and what you can do to protect yourself.',
|
||||
content: `<h2>The Scale of the Problem</h2>
|
||||
<p>In 2025, Americans received an estimated 55 billion spam calls — an average of 15 calls per person per month. AI-powered scam calls now account for 40% of all phone fraud attempts, up from just 12% in 2023.</p>
|
||||
|
||||
<h2>Key Statistics</h2>
|
||||
<ul>
|
||||
<li>1 in 3 Americans report losing money to phone scams</li>
|
||||
<li>Average loss per victim: $1,200</li>
|
||||
<li>68% of scam calls now use AI-generated voices</li>
|
||||
<li>Elderly individuals (65+) are 3x more likely to fall victim</li>
|
||||
<li>Most common scam: fake tech support (32% of all reports)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Why Traditional Blocking Falls Short</h2>
|
||||
<p>Traditional spam blockers rely on known phone number databases. But AI-powered scammers constantly rotate numbers, spoof caller IDs, and use voice cloning to bypass voice-based verification. ShieldAI's machine learning approach classifies calls based on behavioral patterns, not just number reputation — catching new scams that traditional methods miss.</p>`,
|
||||
authorName: 'ShieldAI Team',
|
||||
tags: ['spam calls', 'statistics', 'AI scams'],
|
||||
published: true,
|
||||
},
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
console.log('Seeding blog posts...');
|
||||
|
||||
for (const post of blogPosts) {
|
||||
const existing = await prisma.blogPost.findUnique({ where: { slug: post.slug } });
|
||||
if (existing) {
|
||||
console.log(` Skipping "${post.slug}" — already exists`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.blogPost.create({
|
||||
data: {
|
||||
...post,
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log(` Created "${post.slug}"`);
|
||||
}
|
||||
|
||||
console.log('Seed complete!');
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error('Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -13,6 +13,9 @@ import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
||||
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
||||
import { correlationRoutes } from "./routes/correlation.routes";
|
||||
import { extensionRoutes } from "./routes/extension.routes";
|
||||
import { waitlistRoutes } from "./routes/waitlist.routes";
|
||||
import { blogRoutes } from "./routes/blog.routes";
|
||||
import { blogAdminRoutes } from "./routes/blog-admin.routes";
|
||||
import { captureSentryError } from "@shieldai/monitoring";
|
||||
import { getCorsOrigins } from "./config/api.config";
|
||||
|
||||
@@ -53,6 +56,9 @@ async function bootstrap() {
|
||||
await app.register(voiceprintRoutes);
|
||||
await app.register(correlationRoutes);
|
||||
await app.register(extensionRoutes, { prefix: '/extension' });
|
||||
await app.register(waitlistRoutes);
|
||||
await app.register(blogRoutes, { prefix: '/blog' });
|
||||
await app.register(blogAdminRoutes);
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ model User {
|
||||
normalizedAlerts NormalizedAlert[]
|
||||
correlationGroups CorrelationGroup[]
|
||||
securityReports SecurityReport[]
|
||||
analysisJobs AnalysisJob[]
|
||||
|
||||
// Audit
|
||||
createdAt DateTime @default(now())
|
||||
@@ -376,7 +377,7 @@ model AnalysisJob {
|
||||
|
||||
model AnalysisResult {
|
||||
id String @id @default(uuid())
|
||||
analysisJobId String
|
||||
analysisJobId String @unique
|
||||
syntheticScore Float
|
||||
verdict DetectionVerdict
|
||||
confidence Float
|
||||
@@ -626,3 +627,52 @@ model SecurityReport {
|
||||
@@index([periodStart, periodEnd])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Waitlist & Marketing Models
|
||||
// ============================================
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
name String?
|
||||
source String? // landing_page, blog, referral, social, paid_search
|
||||
tier SubscriptionTier? // interest level
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
metadata Json? // Browser, device, location, etc.
|
||||
|
||||
// Conversion tracking
|
||||
convertedAt DateTime?
|
||||
convertedToUserId String?
|
||||
convertedToSubscriptionId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([source])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid())
|
||||
slug String @unique
|
||||
title String
|
||||
excerpt String?
|
||||
content String
|
||||
authorName String?
|
||||
coverImageUrl String?
|
||||
tags String[] // Array of tag strings
|
||||
published Boolean @default(false)
|
||||
publishedAt DateTime?
|
||||
viewCount Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([slug])
|
||||
@@index([published, publishedAt])
|
||||
@@index([tags])
|
||||
}
|
||||
|
||||
@@ -48,11 +48,15 @@ export type {
|
||||
Alert,
|
||||
VoiceEnrollment,
|
||||
VoiceAnalysis,
|
||||
AnalysisJob,
|
||||
AnalysisResult,
|
||||
SpamFeedback,
|
||||
SpamRule,
|
||||
AuditLog,
|
||||
KPISnapshot,
|
||||
SecurityReport,
|
||||
WaitlistEntry,
|
||||
BlogPost,
|
||||
UserRole,
|
||||
FamilyMemberRole,
|
||||
SubscriptionTier,
|
||||
@@ -68,6 +72,9 @@ export type {
|
||||
RuleAction,
|
||||
ReportType,
|
||||
ReportStatus,
|
||||
AnalysisType,
|
||||
AnalysisJobStatus,
|
||||
DetectionVerdict,
|
||||
} from '@prisma/client';
|
||||
|
||||
export * as PrismaModels from '@prisma/client';
|
||||
|
||||
@@ -435,3 +435,52 @@ model KPISnapshot {
|
||||
@@index([metricName])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Waitlist & Marketing Models
|
||||
// ============================================
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
name String?
|
||||
source String? // landing_page, blog, referral, social, paid_search
|
||||
tier SubscriptionTier? // interest level
|
||||
utmSource String?
|
||||
utmMedium String?
|
||||
utmCampaign String?
|
||||
metadata Json? // Browser, device, location, etc.
|
||||
|
||||
// Conversion tracking
|
||||
convertedAt DateTime?
|
||||
convertedToUserId String?
|
||||
convertedToSubscriptionId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([email])
|
||||
@@index([source])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid())
|
||||
slug String @unique
|
||||
title String
|
||||
excerpt String?
|
||||
content String
|
||||
authorName String?
|
||||
coverImageUrl String?
|
||||
tags String[] // Array of tag strings
|
||||
published Boolean @default(false)
|
||||
publishedAt DateTime?
|
||||
viewCount Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([slug])
|
||||
@@index([published, publishedAt])
|
||||
@@index([tags])
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export type {
|
||||
SpamRule,
|
||||
AuditLog,
|
||||
KPISnapshot,
|
||||
WaitlistEntry,
|
||||
BlogPost,
|
||||
UserRole,
|
||||
FamilyMemberRole,
|
||||
SubscriptionTier,
|
||||
|
||||
23
packages/web/index.html
Normal file
23
packages/web/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a1628" />
|
||||
<meta name="description" content="ShieldAI — AI-powered identity protection. Detect voice cloning, monitor dark web, block spam calls and texts." />
|
||||
<meta name="keywords" content="identity protection, voice cloning, spam protection, dark web monitoring, AI security" />
|
||||
<meta property="og:title" content="ShieldAI — AI-Powered Identity Protection" />
|
||||
<meta property="og:description" content="Protect your family from AI-driven scams with real-time voice cloning detection, dark web monitoring, and spam blocking." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://shieldai.com" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>ShieldAI — AI-Powered Identity Protection</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14",
|
||||
"@solidjs/router": "^0.14.0",
|
||||
"@shieldsai/shared-auth": "workspace:*",
|
||||
"@shieldsai/shared-ui": "workspace:*",
|
||||
"@shieldsai/shared-utils": "workspace:*"
|
||||
|
||||
11
packages/web/public/favicon.svg
Normal file
11
packages/web/public/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="#0a0f1e"/>
|
||||
<path d="M50 15 L85 35 L85 55 Q85 80 50 90 Q15 80 15 55 L15 35 Z" fill="url(#g)" opacity="0.9"/>
|
||||
<path d="M45 40 L55 40 L55 50 L65 50 L65 60 L55 60 L55 70 L45 70 L45 60 L35 60 L35 50 L45 50 Z" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
7
packages/web/src/App.tsx
Normal file
7
packages/web/src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component, JSX } from 'solid-js';
|
||||
|
||||
const App: Component<{ children?: JSX.Element }> = (props) => {
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
export default App;
|
||||
79
packages/web/src/components/BlogPreview.tsx
Normal file
79
packages/web/src/components/BlogPreview.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, createSignal, onMount, For } from 'solid-js';
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
authorName: string | null;
|
||||
coverImageUrl: string | null;
|
||||
tags: string[];
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
const BlogPreview: Component = () => {
|
||||
const [posts, setPosts] = createSignal<BlogPost[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/blog?limit=3');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPosts(data.posts);
|
||||
}
|
||||
} catch {
|
||||
// Blog may not be populated yet
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="blog-preview" id="blog">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Free Rights & Strategies</h2>
|
||||
<p class="section-subtitle">
|
||||
Educational guides to help you understand and protect against AI-powered threats.
|
||||
</p>
|
||||
|
||||
{posts().length === 0 ? (
|
||||
<div class="blog-placeholder">
|
||||
<p>Blog posts coming soon. Sign up for the waitlist to get notified when we publish.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="blog-grid">
|
||||
<For each={posts()}>
|
||||
{(post) => (
|
||||
<a href={`/blog/${post.slug}`} class="blog-card">
|
||||
{post.coverImageUrl && (
|
||||
<div class="blog-card-image">
|
||||
<img src={post.coverImageUrl} alt={post.title} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div class="blog-card-body">
|
||||
<div class="blog-card-tags">
|
||||
<For each={post.tags.slice(0, 2)}>
|
||||
{(tag) => <span class="tag">{tag}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<h3>{post.title}</h3>
|
||||
{post.excerpt && <p>{post.excerpt}</p>}
|
||||
<div class="blog-card-meta">
|
||||
{post.authorName && <span>{post.authorName}</span>}
|
||||
{post.publishedAt && (
|
||||
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="blog-cta">
|
||||
<a href="/blog" class="btn-secondary">View All Articles</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPreview;
|
||||
73
packages/web/src/components/FeaturesSection.tsx
Normal file
73
packages/web/src/components/FeaturesSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Component, For } from 'solid-js';
|
||||
|
||||
interface Feature {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
icon: '🎙️',
|
||||
title: 'Voice Cloning Detection',
|
||||
description:
|
||||
'Real-time detection of AI-generated voice clones during incoming calls. We analyze audio fingerprints and synthetic voice patterns to stop family impersonation scams.',
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
title: 'Dark Web Monitoring',
|
||||
description:
|
||||
'Continuous scanning of dark web marketplaces, forums, and data leaks for your phone numbers, emails, passwords, and SSN. Get instant alerts when your data is exposed.',
|
||||
},
|
||||
{
|
||||
icon: '🚫',
|
||||
title: 'AI Spam Call Blocking',
|
||||
description:
|
||||
'Machine learning classification identifies spam calls before they reach you. Our model blocks robocalls, scam calls, and unwanted telemarketers with 99% accuracy.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Smart SMS Filtering',
|
||||
description:
|
||||
'Real-time SMS classification filters phishing texts, scam messages, and spam. AI-powered detection catches sophisticated social engineering attacks.',
|
||||
},
|
||||
{
|
||||
icon: '🏠',
|
||||
title: 'Family Protection',
|
||||
description:
|
||||
'Extend protection to up to 5 family members. Monitor elderly parents for voice cloning attacks and keep everyone safe from digital threats.',
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
title: 'Home Title Protection',
|
||||
description:
|
||||
'Premium tier monitors property records for fraudulent transfers and liens. Get alerted if someone tries to steal your home title.',
|
||||
},
|
||||
];
|
||||
|
||||
const FeaturesSection: Component = () => {
|
||||
return (
|
||||
<section class="features" id="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Comprehensive Protection Suite</h2>
|
||||
<p class="section-subtitle">
|
||||
One platform to protect your identity, your family, and your home from the
|
||||
growing threat of AI-powered scams.
|
||||
</p>
|
||||
<div class="features-grid">
|
||||
<For each={features}>
|
||||
{(feature) => (
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">{feature.icon}</div>
|
||||
<h3 class="feature-title">{feature.title}</h3>
|
||||
<p class="feature-desc">{feature.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesSection;
|
||||
39
packages/web/src/components/Footer.tsx
Normal file
39
packages/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component } from 'solid-js';
|
||||
|
||||
const Footer: Component = () => {
|
||||
return (
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<h3>ShieldAI</h3>
|
||||
<p>AI-powered identity protection for everyone.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<h4>Product</h4>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<h4>Company</h4>
|
||||
<a href="#about">About</a>
|
||||
<a href="#privacy">Privacy Policy</a>
|
||||
<a href="#terms">Terms of Service</a>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<h4>Resources</h4>
|
||||
<a href="/blog">Free Rights & Strategies</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© {new Date().getFullYear()} ShieldAI. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
42
packages/web/src/components/HeroSection.tsx
Normal file
42
packages/web/src/components/HeroSection.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Component } from 'solid-js';
|
||||
import WaitlistForm from './WaitlistForm';
|
||||
|
||||
const HeroSection: Component = () => {
|
||||
return (
|
||||
<section class="hero">
|
||||
<div class="hero-bg" />
|
||||
<div class="container">
|
||||
<div class="hero-badge">Coming Soon</div>
|
||||
<h1 class="hero-title">
|
||||
AI-Powered Identity<br />
|
||||
<span class="gradient-text">Protection for Everyone</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
ShieldAI detects voice cloning attacks, monitors the dark web for your data,
|
||||
and blocks spam calls and texts in real time. Protect your family from
|
||||
AI-driven scams before they strike.
|
||||
</p>
|
||||
<div class="hero-waitlist">
|
||||
<h3>Join 1,000+ early adopters on the waitlist</h3>
|
||||
<WaitlistForm variant="hero" />
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">10M+</span>
|
||||
<span class="stat-label">Spam Calls Blocked</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">99.7%</span>
|
||||
<span class="stat-label">Voice Clone Detection</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">5K+</span>
|
||||
<span class="stat-label">Dark Web Exposures Found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
105
packages/web/src/components/TierComparison.tsx
Normal file
105
packages/web/src/components/TierComparison.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Component } from 'solid-js';
|
||||
import WaitlistForm from './WaitlistForm';
|
||||
|
||||
interface TierFeature {
|
||||
name: string;
|
||||
basic: boolean | string;
|
||||
plus: boolean | string;
|
||||
premium: boolean | string;
|
||||
}
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Basic',
|
||||
price: 'Free',
|
||||
description: 'Essential protection to get started',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
name: 'Plus',
|
||||
price: '$9.99',
|
||||
period: '/month',
|
||||
description: 'Complete protection for individuals',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Premium',
|
||||
price: '$24.99',
|
||||
period: '/month',
|
||||
description: 'Comprehensive family protection',
|
||||
highlight: false,
|
||||
},
|
||||
];
|
||||
|
||||
const features: TierFeature[] = [
|
||||
{ name: 'Dark Web Scans (Phone)', basic: '1/mo', plus: 'Unlimited', premium: 'Unlimited' },
|
||||
{ name: 'Dark Web Scans (Email)', basic: '1/mo', plus: 'Unlimited', premium: 'Unlimited' },
|
||||
{ name: 'Password Leak Detection', basic: false, plus: true, premium: true },
|
||||
{ name: 'Spam Call Detection', basic: 'Basic', plus: 'AI-Powered', premium: 'AI-Powered' },
|
||||
{ name: 'Spam Text Alerts', basic: '50/mo', plus: 'Unlimited', premium: 'Unlimited' },
|
||||
{ name: 'Voice Cloning Detection', basic: false, plus: '3 Family Members', premium: 'Unlimited' },
|
||||
{ name: 'Home Title Protection', basic: false, plus: false, premium: true },
|
||||
{ name: 'SSN Monitoring', basic: false, plus: false, premium: true },
|
||||
{ name: 'Financial Fraud Detection', basic: false, plus: false, premium: true },
|
||||
{ name: '24/7 Priority Support', basic: false, plus: 'Email', premium: 'Phone + Chat' },
|
||||
];
|
||||
|
||||
const TierComparison: Component = () => {
|
||||
return (
|
||||
<section class="tiers" id="pricing">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Choose Your Protection Level</h2>
|
||||
<p class="section-subtitle">
|
||||
Start free and upgrade as your needs grow. All tiers include our core AI protection engine.
|
||||
</p>
|
||||
|
||||
<div class="tier-grid">
|
||||
{tiers.map((tier) => (
|
||||
<div class={`tier-card ${tier.highlight ? 'tier-highlighted' : ''}`}>
|
||||
{tier.highlight && <div class="tier-badge">Most Popular</div>}
|
||||
<h3 class="tier-name">{tier.name}</h3>
|
||||
<div class="tier-price">
|
||||
<span class="price-value">{tier.price}</span>
|
||||
{tier.period && <span class="price-period">{tier.period}</span>}
|
||||
</div>
|
||||
<p class="tier-desc">{tier.description}</p>
|
||||
<WaitlistForm variant="inline" buttonText={`Join ${tier.name} Waitlist`} placeholder="Email for updates" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="tier-table-wrapper">
|
||||
<table class="tier-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Basic</th>
|
||||
<th class="col-highlighted">Plus</th>
|
||||
<th>Premium</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.map((feature) => (
|
||||
<tr>
|
||||
<td class="feature-name">{feature.name}</td>
|
||||
<td>{renderCell(feature.basic)}</td>
|
||||
<td class="col-highlighted">{renderCell(feature.plus)}</td>
|
||||
<td>{renderCell(feature.premium)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function renderCell(value: boolean | string) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? <span class="check">✓</span> : <span class="cross">—</span>;
|
||||
}
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
export default TierComparison;
|
||||
121
packages/web/src/components/WaitlistForm.tsx
Normal file
121
packages/web/src/components/WaitlistForm.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { trackWaitlistSignup } from '../hooks/useAnalytics';
|
||||
|
||||
interface WaitlistFormProps {
|
||||
variant?: 'hero' | 'inline';
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
const WaitlistForm: Component<WaitlistFormProps> = (props) => {
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [name, setName] = createSignal('');
|
||||
const [tier, setTier] = createSignal('basic');
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const variant = props.variant || 'hero';
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email())) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/waitlist/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email(),
|
||||
name: name() || undefined,
|
||||
tier: tier() !== 'basic' ? tier() : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Signup failed');
|
||||
}
|
||||
|
||||
trackWaitlistSignup(email(), 'landing_page', tier());
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted()) {
|
||||
return (
|
||||
<div class="waitlist-success">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>You're on the list!</h3>
|
||||
<p>We'll keep you updated on our launch and send early access invites.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'hero') {
|
||||
return (
|
||||
<form class="waitlist-form hero-form" onSubmit={handleSubmit}>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
placeholder={props.placeholder || 'Enter your email'}
|
||||
required
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button type="submit" disabled={loading()}>
|
||||
{loading() ? 'Joining...' : props.buttonText || 'Join Waitlist'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-row secondary">
|
||||
<input
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="Your name (optional)"
|
||||
aria-label="Your name"
|
||||
/>
|
||||
<select value={tier()} onChange={(e) => setTier(e.currentTarget.value)} aria-label="Interest level">
|
||||
<option value="basic">Free — Basic Protection</option>
|
||||
<option value="plus">Plus — $9.99/mo</option>
|
||||
<option value="premium">Premium — $24.99/mo</option>
|
||||
</select>
|
||||
</div>
|
||||
{error() && <p class="form-error">{error()}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form class="waitlist-form inline-form" onSubmit={handleSubmit}>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
placeholder={props.placeholder || 'Your email'}
|
||||
required
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button type="submit" disabled={loading()}>
|
||||
{loading() ? '...' : props.buttonText || 'Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
{error() && <p class="form-error">{error()}</p>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default WaitlistForm;
|
||||
76
packages/web/src/hooks/useAnalytics.ts
Normal file
76
packages/web/src/hooks/useAnalytics.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
type EventParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
|
||||
const MIXPANEL_TOKEN = import.meta.env.VITE_MIXPANEL_TOKEN as string | undefined;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (command: string, target: string, params?: EventParams) => void;
|
||||
mixpanel?: { track: (event: string, params?: EventParams) => void };
|
||||
dataLayer?: unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
function initGA() {
|
||||
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') return;
|
||||
if (window.gtag) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.gtag = function gtag() {
|
||||
window.dataLayer!.push(arguments);
|
||||
};
|
||||
window.gtag('js', new Date());
|
||||
window.gtag('config', GA_MEASUREMENT_ID);
|
||||
}
|
||||
|
||||
function initMixpanel() {
|
||||
if (!MIXPANEL_TOKEN || typeof window === 'undefined') return;
|
||||
if (window.mixpanel) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = 'https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js';
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
window.mixpanel = window.mixpanel || { track: () => {} };
|
||||
window.mixpanel.init?.(MIXPANEL_TOKEN);
|
||||
};
|
||||
}
|
||||
|
||||
export function initAnalytics() {
|
||||
initGA();
|
||||
initMixpanel();
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, params?: EventParams) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (window.gtag) {
|
||||
window.gtag('event', name, params);
|
||||
}
|
||||
|
||||
if (window.mixpanel) {
|
||||
window.mixpanel.track(name, params);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackWaitlistSignup(email: string, source?: string, tier?: string) {
|
||||
trackEvent('waitlist_signup', {
|
||||
email,
|
||||
source: source || 'landing_page',
|
||||
tier: tier || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
export function trackPageView(path: string, title?: string) {
|
||||
trackEvent('page_view', {
|
||||
page_path: path,
|
||||
page_title: title || document.title,
|
||||
});
|
||||
}
|
||||
936
packages/web/src/index.css
Normal file
936
packages/web/src/index.css
Normal file
@@ -0,0 +1,936 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0f1e;
|
||||
--bg-secondary: #111827;
|
||||
--bg-card: #1a2332;
|
||||
--bg-card-hover: #1f2b3d;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-primary: #3b82f6;
|
||||
--accent-secondary: #06b6d4;
|
||||
--accent-gradient: linear-gradient(135deg, #3b82f6, #06b6d4);
|
||||
--border-color: #1e293b;
|
||||
--border-light: #334155;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--max-width: 1200px;
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 64px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 120px 0 80px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% -20%, rgba(59, 130, 246, 0.15), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 80% 80%, rgba(6, 182, 212, 0.1), transparent),
|
||||
radial-gradient(ellipse 40% 30% at 20% 70%, rgba(99, 102, 241, 0.08), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin-bottom: 48px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-waitlist h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
margin-top: 80px;
|
||||
padding-top: 48px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Waitlist Form */
|
||||
.waitlist-form {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-row.secondary {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-row.secondary input,
|
||||
.form-row.secondary select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.waitlist-form input[type="email"],
|
||||
.waitlist-form input[type="text"],
|
||||
.waitlist-form select {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.waitlist-form input:focus,
|
||||
.waitlist-form select:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.waitlist-form select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.waitlist-form button {
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.waitlist-form button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.waitlist-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--error);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Waitlist Success */
|
||||
.waitlist-success {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.waitlist-success h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.waitlist-success p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.938rem;
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 120px 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 0.938rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Tiers Section */
|
||||
.tiers {
|
||||
padding: 120px 0;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
padding: 40px 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tier-highlighted {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 30px rgba(59, 130, 246, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tier-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tier-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tier-price {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.price-period {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tier-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.938rem;
|
||||
margin-bottom: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tier-card .waitlist-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tier-card .form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tier-card .waitlist-form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tier-card .waitlist-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tier Table */
|
||||
.tier-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tier-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.938rem;
|
||||
}
|
||||
|
||||
.tier-table th,
|
||||
.tier-table td {
|
||||
padding: 14px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tier-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.813rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tier-table th:first-child,
|
||||
.tier-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tier-table td.feature-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.col-highlighted {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.check {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cross {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Blog Preview */
|
||||
.blog-preview {
|
||||
padding: 120px 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.blog-card:hover {
|
||||
border-color: var(--border-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.blog-card-image {
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blog-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.blog-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.blog-card-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.813rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.blog-card-body h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.blog-card-body p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.blog-card-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.813rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.blog-placeholder {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.blog-placeholder p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.blog-cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.938rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 80px 0 40px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 48px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.footer-brand h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.footer-brand p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.938rem;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.footer-links h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.938rem;
|
||||
padding: 4px 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Blog Page */
|
||||
.blog-page-header {
|
||||
padding: 80px 0 48px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.938rem;
|
||||
}
|
||||
|
||||
.blog-page-header h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.blog-page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.125rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.blog-page-content {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.blog-list-item {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.blog-list-item:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.blog-list-image {
|
||||
width: 280px;
|
||||
min-height: 180px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blog-list-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.blog-list-body {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blog-list-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-list-body h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.blog-list-body p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.938rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.blog-list-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Blog Post */
|
||||
.blog-post {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.blog-post-header {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.blog-post-header h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.blog-post-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.938rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-post-cover {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.blog-post-cover img {
|
||||
width: 100%;
|
||||
max-height: 500px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.blog-post-content {
|
||||
max-width: 720px;
|
||||
font-size: 1.063rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.blog-post-content h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 48px 0 16px;
|
||||
}
|
||||
|
||||
.blog-post-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 32px 0 12px;
|
||||
}
|
||||
|
||||
.blog-post-content p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.blog-post-content ul,
|
||||
.blog-post-content ol {
|
||||
margin-bottom: 20px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.blog-post-content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.blog-post-content code {
|
||||
background: var(--bg-card);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.blog-post-content pre {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.blog-post-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.blog-post-content blockquote {
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
padding-left: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* Loading / Empty */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 64px 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.tier-highlighted {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.063rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.blog-list-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.blog-list-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.blog-page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.blog-post-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
18
packages/web/src/main.tsx
Normal file
18
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import App from './App';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import BlogPage from './pages/BlogPage';
|
||||
import BlogPostPage from './pages/BlogPostPage';
|
||||
import './index.css';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Root element not found');
|
||||
|
||||
render(() => (
|
||||
<Router root={App}>
|
||||
<Route path="/" component={LandingPage} />
|
||||
<Route path="/blog" component={BlogPage} />
|
||||
<Route path="/blog/:slug" component={BlogPostPage} />
|
||||
</Router>
|
||||
), root);
|
||||
112
packages/web/src/pages/BlogPage.tsx
Normal file
112
packages/web/src/pages/BlogPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Component, createSignal, onMount, For } from 'solid-js';
|
||||
import { initAnalytics, trackPageView } from '../hooks/useAnalytics';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
authorName: string | null;
|
||||
coverImageUrl: string | null;
|
||||
tags: string[];
|
||||
publishedAt: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const BlogPage: Component = () => {
|
||||
const [posts, setPosts] = createSignal<BlogPost[]>([]);
|
||||
const [pagination, setPagination] = createSignal<Pagination | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
onMount(async () => {
|
||||
initAnalytics();
|
||||
trackPageView('/blog', 'Blog — Free Rights & Strategies');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/blog?limit=10');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPosts(data.posts);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch {
|
||||
// handle error silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div class="blog-page-header">
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← Back to ShieldAI</a>
|
||||
<h1>Free Rights & Strategies</h1>
|
||||
<p>Educational guides to protect yourself and your family from AI-powered scams.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="blog-page-content">
|
||||
<div class="container">
|
||||
{loading() ? (
|
||||
<p class="loading">Loading articles...</p>
|
||||
) : posts().length === 0 ? (
|
||||
<div class="empty-state">
|
||||
<h2>Coming Soon</h2>
|
||||
<p>Our educational blog posts are being written. Join the waitlist to be notified when we publish.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="blog-list">
|
||||
<For each={posts()}>
|
||||
{(post) => (
|
||||
<a href={`/blog/${post.slug}`} class="blog-list-item">
|
||||
{post.coverImageUrl && (
|
||||
<div class="blog-list-image">
|
||||
<img src={post.coverImageUrl} alt={post.title} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div class="blog-list-body">
|
||||
<div class="blog-list-tags">
|
||||
<For each={post.tags}>
|
||||
{(tag) => <span class="tag">{tag}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<h2>{post.title}</h2>
|
||||
{post.excerpt && <p>{post.excerpt}</p>}
|
||||
<div class="blog-list-meta">
|
||||
{post.authorName && <span>{post.authorName}</span>}
|
||||
{post.publishedAt && (
|
||||
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
<span>{post.viewCount} views</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{pagination() && pagination()!.totalPages > 1 && (
|
||||
<div class="pagination">
|
||||
<span>Page {pagination()!.page} of {pagination()!.totalPages}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPage;
|
||||
96
packages/web/src/pages/BlogPostPage.tsx
Normal file
96
packages/web/src/pages/BlogPostPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
26
packages/web/src/pages/LandingPage.tsx
Normal file
26
packages/web/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component, onMount } from 'solid-js';
|
||||
import { initAnalytics, trackPageView } from '../hooks/useAnalytics';
|
||||
import HeroSection from '../components/HeroSection';
|
||||
import FeaturesSection from '../components/FeaturesSection';
|
||||
import TierComparison from '../components/TierComparison';
|
||||
import BlogPreview from '../components/BlogPreview';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
const LandingPage: Component = () => {
|
||||
onMount(() => {
|
||||
initAnalytics();
|
||||
trackPageView('/', 'ShieldAI — AI-Powered Identity Protection');
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<TierComparison />
|
||||
<BlogPreview />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
22
packages/web/tsconfig.json
Normal file
22
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
packages/web/tsconfig.node.json
Normal file
10
packages/web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
packages/web/vite.config.ts
Normal file
18
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin()],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user