08: Migrate & redesign Blog, Ads, and Dashboard pages
- Blog listing page with hero, responsive grid, tag filters, load more - Blog post page with markdown rendering, related posts, social share - Ads landing page with conversion copy, pricing, FAQ, testimonials - Dashboard shell with sidebar, topbar, stat cards, activity feed - Dashboard components: Sidebar, TopBar, StatCard, ActivityFeed, QuickActions - Comprehensive test suite covering all pages and components
This commit is contained in:
80
web/src/components/dashboard/ActivityFeed.tsx
Normal file
80
web/src/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui";
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
type: "alert" | "success" | "info" | "warning";
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: Activity[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const typeVariants: Record<Activity["type"], "error" | "success" | "info" | "warning"> = {
|
||||
alert: "error",
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
function ActivityIcon(props: { type: Activity["type"] }) {
|
||||
return (
|
||||
<div class={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0",
|
||||
props.type === "alert" && "bg-[var(--color-error-bg)] text-[var(--color-error)]",
|
||||
props.type === "success" && "bg-[var(--color-success-bg)] text-[var(--color-success)]",
|
||||
props.type === "info" && "bg-[var(--color-info-bg)] text-[var(--color-info)]",
|
||||
props.type === "warning" && "bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
|
||||
)}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<Show when={props.type === "alert"}>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 5v3M8 10.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "success"}>
|
||||
<path d="M4 8l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "info"}>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
<Show when={props.type === "warning"}>
|
||||
<path d="M8 2l6 11H2L8 2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M8 7v3M8 12v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</Show>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityFeed(props: ActivityFeedProps) {
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl", props.class)}>
|
||||
<div class="px-5 py-4 border-b border-[var(--color-border)]/50">
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">Recent Activity</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-[var(--color-border)]/50">
|
||||
<For each={props.activities}>
|
||||
{(activity) => (
|
||||
<div class="px-5 py-3.5 flex items-start gap-3">
|
||||
<ActivityIcon type={activity.type} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{activity.title}</span>
|
||||
<Badge variant={typeVariants[activity.type]}>{activity.type}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">{activity.description}</p>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--color-text-tertiary)] whitespace-nowrap">{activity.timestamp}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
web/src/components/dashboard/QuickActions.tsx
Normal file
40
web/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
interface QuickActionsProps {
|
||||
actions: QuickAction[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function QuickActions(props: QuickActionsProps) {
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl", props.class)}>
|
||||
<div class="px-5 py-4 border-b border-[var(--color-border)]/50">
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-2 gap-3">
|
||||
<For each={props.actions}>
|
||||
{(action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<A
|
||||
href={action.href}
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl bg-[var(--color-bg-secondary)] hover:bg-[var(--color-brand-primary)]/10 text-[var(--color-text-secondary)] hover:text-[var(--color-brand-primary)] transition-colors text-center"
|
||||
>
|
||||
<Icon />
|
||||
<span class="text-xs font-medium">{action.label}</span>
|
||||
</A>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
web/src/components/dashboard/Sidebar.tsx
Normal file
146
web/src/components/dashboard/Sidebar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createSignal, Show, type JSX } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SidebarLink {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function OverviewIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
|
||||
<rect x="11" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="11" width="7" height="7" rx="1" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="11" y="11" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DarkWatchIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2L2 6v6c0 4.4 3.6 8.4 8 9 4.4-.6 8-4.6 8-9V6l-8-4zm0 1.7L16 7.5v4.5c0 3.7-2.6 7-6 8-3.4-1-6-4.3-6-8V7.5l6-3.8z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoicePrintIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 2h2v3H9V2zM6 6h8v1.5H6V6zm-1 3h10v1.5H5V9zm0 3h10v1.5H5V12z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpamShieldIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2zm0 2.1L5 7.2v3.3c0 4.2 2.8 7.8 5 8.6 2.2-.8 5-4.4 5-8.6V7.2l-5-3.1z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeTitleIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L3 8v9h5v-5h4v5h5V8l-7-5zm0 2.5L14 9H6l4-3.5z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveBrokersIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3h6v2H7V3zm-1 4h8l1 1v1.5H5V8l1-1zm-1 4h10v7H5v-7zm2 2v3h6v-3H7z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="2.5" fill="currentColor"/>
|
||||
<path d="M10 1.5l2.5 1v2.5L10 5l-2.5-1v-2.5L10 1.5z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M18.5 10l-1 2.5H15l-1-2.5 1-2.5h2.5l1 2.5z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M10 18.5l-2.5-1V15L10 14l2.5 1v2.5l-2.5 1z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M1.5 10l1-2.5H5l1 2.5-1 2.5H2.5l-1-2.5z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{ label: "Overview", href: "/dashboard", icon: OverviewIcon },
|
||||
{ label: "DarkWatch", href: "/dashboard/darkwatch", icon: DarkWatchIcon },
|
||||
{ label: "VoicePrint", href: "/dashboard/voiceprint", icon: VoicePrintIcon },
|
||||
{ label: "SpamShield", href: "/dashboard/spamshield", icon: SpamShieldIcon },
|
||||
{ label: "HomeTitle", href: "/dashboard/hometitle", icon: HomeTitleIcon },
|
||||
{ label: "RemoveBrokers", href: "/dashboard/removebrokers", icon: RemoveBrokersIcon },
|
||||
{ label: "Settings", href: "/dashboard/settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar(props: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
class={cn(
|
||||
"fixed lg:static inset-y-0 left-0 z-40 w-64 bg-[var(--color-bg)] border-r border-[var(--color-border)] transform transition-transform duration-300 ease-in-out lg:transform-none overflow-y-auto",
|
||||
props.open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
)}
|
||||
>
|
||||
<div class="p-6 border-b border-[var(--color-border)]">
|
||||
<A href="/dashboard" class="flex items-center gap-2">
|
||||
<svg width="24" height="28" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0L26 6V16C26 24 14 32 14 32S2 24 2 16V6L14 0Z" fill="url(#sidebar-shield-grad)"/>
|
||||
<path d="M10 16L13 19L19 13" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="sidebar-shield-grad" x1="0" y1="0" x2="28" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="var(--color-brand-primary)"/>
|
||||
<stop offset="1" stop-color="var(--color-brand-accent)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="text-lg font-bold text-[var(--color-text-primary)]">ShieldAI</span>
|
||||
</A>
|
||||
</div>
|
||||
<nav class="p-4 space-y-1">
|
||||
{sidebarLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const isActive = location.pathname === link.href ||
|
||||
(link.href !== "/dashboard" && location.pathname.startsWith(link.href));
|
||||
return (
|
||||
<A
|
||||
href={link.href}
|
||||
class={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]",
|
||||
)}
|
||||
onClick={() => props.onClose()}
|
||||
>
|
||||
<Icon />
|
||||
{link.label}
|
||||
</A>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => props.onClose()}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
web/src/components/dashboard/StatCard.tsx
Normal file
54
web/src/components/dashboard/StatCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Show, type JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: "up" | "down";
|
||||
trendLabel?: string;
|
||||
icon: () => JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<div class={cn("gradient-card border border-[var(--color-border)]/50 rounded-xl p-5", props.class)}>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<span class="text-sm font-medium text-[var(--color-text-secondary)]">{props.label}</span>
|
||||
<div class="p-2 rounded-lg bg-[var(--color-brand-primary)]/10 text-[var(--color-brand-primary)]">
|
||||
<Icon />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-[var(--color-text-primary)] mb-1">{props.value}</div>
|
||||
<Show when={props.trend}>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={cn(
|
||||
props.trend === "up" ? "text-[var(--color-success)]" : "text-[var(--color-error)]",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d={props.trend === "up" ? "M6 9V3M6 3l-3 3M6 3l3 3" : "M6 3v6M6 9l-3-3M6 9l3-3"}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class={cn(
|
||||
"font-medium",
|
||||
props.trend === "up" ? "text-[var(--color-success)]" : "text-[var(--color-error)]",
|
||||
)}>
|
||||
{props.trendLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
web/src/components/dashboard/TopBar.tsx
Normal file
91
web/src/components/dashboard/TopBar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar(props: TopBarProps) {
|
||||
const [showDropdown, setShowDropdown] = createSignal(false);
|
||||
|
||||
return (
|
||||
<header class="h-16 border-b border-[var(--color-border)] bg-[var(--color-bg)] flex items-center justify-between px-4 lg:px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="lg:hidden p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]"
|
||||
onClick={() => props.onMenuToggle()}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative hidden sm:block">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]">
|
||||
<circle cx="7" cy="7" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
class="pl-9 pr-4 py-2 text-sm rounded-lg bg-[var(--color-bg-secondary)] border border-[var(--color-border)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)]/30 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5a5.5 5.5 0 00-5.5 5.5v3l-1.5 2v1h14v-1l-1.5-2V8a5.5 5.5 0 00-5.5-5.5z" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 15.5a2 2 0 004 0" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<span class="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--color-error)]" />
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
onClick={() => setShowDropdown(v => !v)}
|
||||
aria-label="User menu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)] flex items-center justify-center text-white text-sm font-medium">
|
||||
JD
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={showDropdown()}>
|
||||
<>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
<div class="absolute right-0 top-full mt-2 z-50 w-48 rounded-xl bg-[var(--color-bg)] border border-[var(--color-border)] shadow-lg py-1">
|
||||
<div class="px-4 py-2 border-b border-[var(--color-border)]">
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">John Doe</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">john@shieldai.app</p>
|
||||
</div>
|
||||
<A href="/dashboard/settings" class="block px-4 py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]" onClick={() => setShowDropdown(false)}>
|
||||
Settings
|
||||
</A>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm text-[var(--color-error)] hover:bg-[var(--color-bg-secondary)]"
|
||||
onClick={() => setShowDropdown(false)}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
5
web/src/components/dashboard/index.ts
Normal file
5
web/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Sidebar } from "./Sidebar";
|
||||
export { default as TopBar } from "./TopBar";
|
||||
export { default as StatCard } from "./StatCard";
|
||||
export { default as ActivityFeed } from "./ActivityFeed";
|
||||
export { default as QuickActions } from "./QuickActions";
|
||||
98
web/src/routes/(webapp)/dashboard.tsx
Normal file
98
web/src/routes/(webapp)/dashboard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { Sidebar, TopBar, StatCard, ActivityFeed, QuickActions } from "~/components/dashboard";
|
||||
|
||||
function AlertsIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2.5a5.5 5.5 0 00-5.5 5.5v3l-1.5 2v1h14v-1l-1.5-2V8a5.5 5.5 0 00-5.5-5.5z" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 14.5a2 2 0 004 0" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2z" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M10 4l5 2.5v3.5c0 3.7-2.5 6.8-5 7.5-2.5-.7-5-3.8-5-7.5V6.5L10 4z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4c-4 0-7.5 2.5-9 6 1.5 3.5 5 6 9 6s7.5-2.5 9-6c-1.5-3.5-5-6-9-6z" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="10" cy="10" r="2.5" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 10h4l2-5 4 10 2-5h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{ label: "Active Threats", value: "3", trend: "down" as const, trendLabel: "2 fewer than yesterday", icon: AlertsIcon },
|
||||
{ label: "Protected Accounts", value: "12", trend: "up" as const, trendLabel: "2 new this week", icon: ShieldIcon },
|
||||
{ label: "Dark Web Scans", value: "1,847", trend: "up" as const, trendLabel: "12% increase", icon: EyeIcon },
|
||||
{ label: "Alerts Today", value: "7", trend: "down" as const, trendLabel: "3 fewer than yesterday", icon: ActivityIcon },
|
||||
];
|
||||
|
||||
const activities = [
|
||||
{ id: "1", title: "New credential leak detected", description: "Your email was found in a data breach on a dark web forum", timestamp: "5m ago", type: "alert" as const },
|
||||
{ id: "2", title: "VoicePrint scan completed", description: "No deepfake voice activity detected in the last 24 hours", timestamp: "1h ago", type: "success" as const },
|
||||
{ id: "3", title: "RemoveBroker opt-out confirmed", description: "Your data has been removed from Whitepages", timestamp: "3h ago", type: "info" as const },
|
||||
{ id: "4", title: "Suspicious call blocked", description: "SpamShield blocked a call from an known scam number", timestamp: "6h ago", type: "warning" as const },
|
||||
{ id: "5", title: "HomeTitle alert", description: "A document was filed against your property address", timestamp: "1d ago", type: "alert" as const },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ label: "Run Scan", href: "/dashboard/darkwatch", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 2l7 3.5v5c0 5.2-3.5 9.5-7 10.5-3.5-1-7-5.3-7-10.5v-5L10 2z" fill="currentColor"/></svg> },
|
||||
{ label: "View Alerts", href: "/dashboard", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 2.5A5.5 5.5 0 004.5 8v3l-1.5 2v1h14v-1l-1.5-2V8A5.5 5.5 0 0010 2.5z" stroke="currentColor" stroke-width="1.5"/></svg> },
|
||||
{ label: "Add Member", href: "/dashboard/settings", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M14 6a3 3 0 11-6 0 3 3 0 016 0zM4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="currentColor" stroke-width="1.5"/></svg> },
|
||||
{ label: "Run Report", href: "/dashboard", icon: () => <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 2h8l4 4v12H4V2z" stroke="currentColor" stroke-width="1.5"/><path d="M8 8h4M8 11h4M8 14h2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg> },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
|
||||
<Title>Dashboard — ShieldAI</Title>
|
||||
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
|
||||
<main class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Overview</h1>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<For each={statCards}>
|
||||
{(card) => (
|
||||
<StatCard
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
trend={card.trend}
|
||||
trendLabel={card.trendLabel}
|
||||
icon={card.icon}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<ActivityFeed activities={activities} class="lg:col-span-2" />
|
||||
<QuickActions actions={quickActions} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
web/src/routes/ads.tsx
Normal file
326
web/src/routes/ads.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A, useSearchParams } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Button, Card } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Basic",
|
||||
price: "$9",
|
||||
period: "/month",
|
||||
description: "Essential identity protection for individuals",
|
||||
features: ["Dark web monitoring", "Email breach alerts", "Basic scam call blocking", "Monthly reports"],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: "Plus",
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "Advanced protection for you and your family",
|
||||
features: ["Everything in Basic", "VoicePrint AI detection", "HomeTitle fraud alerts", "RemoveBrokers automation", "Family sharing (up to 5)"],
|
||||
cta: "Start Free Trial",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Premium",
|
||||
price: "$39",
|
||||
period: "/month",
|
||||
description: "Maximum security for the whole household",
|
||||
features: ["Everything in Plus", "Unlimited family members", "Priority support 24/7", "Real-time alert correlation", "Advanced analytics dashboard", "Data broker suppression"],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: "How does ShieldAI detect voice clones?",
|
||||
a: "VoicePrint analyzes over 200 acoustic features in real-time, including micro-tremors and breathing patterns that AI clones can't replicate accurately.",
|
||||
},
|
||||
{
|
||||
q: "Is my data encrypted?",
|
||||
a: "Yes. All data is encrypted at rest using AES-256 and in transit using TLS 1.3. We never share or sell your personal information.",
|
||||
},
|
||||
{
|
||||
q: "Can I protect my whole family?",
|
||||
a: "Absolutely. Plus and Premium plans include family sharing with centralized monitoring and alert management for all household members.",
|
||||
},
|
||||
{
|
||||
q: "How does dark web monitoring work?",
|
||||
a: "DarkWatch continuously scans dark web forums, marketplaces, and data dumps for your email addresses, phone numbers, and other personal data.",
|
||||
},
|
||||
{
|
||||
q: "What happens after my free trial?",
|
||||
a: "Your trial includes full access to your selected plan for 14 days. You can cancel anytime before the trial ends with no charge.",
|
||||
},
|
||||
{
|
||||
q: "Can I remove my data from brokers?",
|
||||
a: "Yes. RemoveBrokers automates opt-out requests to over 200 data broker sites and verifies removal on your behalf.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [openFaq, setOpenFaq] = createSignal<string | null>(null);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>ShieldAI — Stop AI Scams Before They Reach You</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/10 via-[var(--color-brand-accent)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<Badge variant="info" class="mb-4">AI-Powered Protection</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||
Stop AI Scams Before{" "}
|
||||
<span class="text-gradient-primary">They Reach You</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
ShieldAI uses advanced artificial intelligence to detect and block voice clones, dark web leaks, and identity threats in real-time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||
<A href={`/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`}>
|
||||
<Button variant="primary" size="lg">
|
||||
Start Your Free Trial
|
||||
</Button>
|
||||
</A>
|
||||
<A href="#pricing">
|
||||
<Button variant="ghost" size="lg">
|
||||
See Plans
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/></svg>
|
||||
14-day free trial
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/></svg>
|
||||
Cancel anytime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-bg-secondary)]">
|
||||
<PageContainer>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-[var(--color-brand-primary)] mb-1">99.7%</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Voice clone detection accuracy</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-[var(--color-brand-primary)] mb-1">200+</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Dark web sources monitored</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-[var(--color-brand-primary)] mb-1">50K+</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Threats blocked daily</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section id="pricing" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<Card
|
||||
class={cn(
|
||||
"relative flex flex-col",
|
||||
plan.popular && "ring-2 ring-[var(--color-brand-primary)] shadow-glow-primary",
|
||||
)}
|
||||
>
|
||||
<Show when={plan.popular}>
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge variant="info">Most Popular</Badge>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{plan.name}</h3>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">{plan.description}</p>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<span class="text-4xl font-bold text-[var(--color-text-primary)]">{plan.price}</span>
|
||||
<span class="text-sm text-[var(--color-text-tertiary)]">{plan.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 flex-1">
|
||||
<For each={plan.features}>
|
||||
{(feature) => (
|
||||
<li class="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="mt-0.5 flex-shrink-0">
|
||||
<path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/>
|
||||
</svg>
|
||||
{feature}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<A href={`/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`}>
|
||||
<Button variant={plan.popular ? "primary" : "secondary"} class="w-full">
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</A>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-bg-secondary)]">
|
||||
<PageContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Trusted by Thousands
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
See what our users say about ShieldAI
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<Card>
|
||||
<div class="flex gap-1 mb-3">
|
||||
{[1, 2, 3, 4, 5].map(() => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-brand-primary)">
|
||||
<path d="M8 1.5l1.8 3.7 4.2.6-3 3 1 4.2L8 11l-3 1.8 1-4.2-3-3 4.2-.6L8 1.5z"/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
"VoicePrint caught a deepfake call from someone impersonating my CEO. This technology is incredible."
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-xs font-bold text-[var(--color-brand-primary)]">JD</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">James D.</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">CEO, TechStart</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="flex gap-1 mb-3">
|
||||
{[1, 2, 3, 4, 5].map(() => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-brand-primary)">
|
||||
<path d="M8 1.5l1.8 3.7 4.2.6-3 3 1 4.2L8 11l-3 1.8 1-4.2-3-3 4.2-.6L8 1.5z"/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
"DarkWatch alerted me that my email was in a data breach within hours. ShieldAI saved me from a potential hack."
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-xs font-bold text-[var(--color-brand-primary)]">MK</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">Maria K.</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">Freelancer</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="flex gap-1 mb-3">
|
||||
{[1, 2, 3, 4, 5].map(() => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="var(--color-brand-primary)">
|
||||
<path d="M8 1.5l1.8 3.7 4.2.6-3 3 1 4.2L8 11l-3 1.8 1-4.2-3-3 4.2-.6L8 1.5z"/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
"The family plan is a game-changer. I can monitor my parents' and kids' accounts from one dashboard."
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-xs font-bold text-[var(--color-brand-primary)]">TR</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">Tom R.</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">Father of 3</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section id="faq" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={faqs}>
|
||||
{(faq) => {
|
||||
const isOpen = () => openFaq() === faq.q;
|
||||
return (
|
||||
<div class="border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
onClick={() => setOpenFaq(isOpen() ? null : faq.q)}
|
||||
>
|
||||
{faq.q}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={cn("transition-transform duration-200", isOpen() && "rotate-180")}
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isOpen()}>
|
||||
<div class="px-5 pb-4 text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{faq.a}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-brand-primary)]">
|
||||
<PageContainer>
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-white/80 mb-8 max-w-2xl mx-auto">
|
||||
Join 50,000+ users who trust ShieldAI for AI-powered identity protection.
|
||||
</p>
|
||||
<A href={`/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`}>
|
||||
<Button variant="primary" size="lg" class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
196
web/src/routes/blog.tsx
Normal file
196
web/src/routes/blog.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Button, Card } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
date: string;
|
||||
readingTime: string;
|
||||
coverImage: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const allTags = ["Identity Theft", "AI Safety", "Privacy", "Deepfakes", "Dark Web", "Scam Alerts", "Product News"];
|
||||
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
slug: "ai-scam-trends-2026",
|
||||
title: "AI Scam Trends to Watch in 2026",
|
||||
excerpt: "As AI technology advances, scammers are finding new ways to exploit it. Here are the top threats to watch and how to protect yourself.",
|
||||
author: "Sarah Chen",
|
||||
date: "May 15, 2026",
|
||||
readingTime: "5 min read",
|
||||
coverImage: "",
|
||||
tags: ["AI Safety", "Scam Alerts", "Deepfakes"],
|
||||
},
|
||||
{
|
||||
slug: "dark-web-monitoring-guide",
|
||||
title: "The Complete Guide to Dark Web Monitoring",
|
||||
excerpt: "Learn how dark web monitoring works, what data gets exposed, and how ShieldAI keeps your information safe from cybercriminals.",
|
||||
author: "Mike Reynolds",
|
||||
date: "May 10, 2026",
|
||||
readingTime: "8 min read",
|
||||
coverImage: "",
|
||||
tags: ["Dark Web", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "protecting-family-identity",
|
||||
title: "Protecting Your Family's Digital Identity",
|
||||
excerpt: "Your family's personal data is at risk. Discover the steps you can take to protect everyone in your household from identity theft.",
|
||||
author: "Emily Torres",
|
||||
date: "May 5, 2026",
|
||||
readingTime: "6 min read",
|
||||
coverImage: "",
|
||||
tags: ["Identity Theft", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "deepfake-voice-scams",
|
||||
title: "Deepfake Voice Scams: How They Work and How to Spot Them",
|
||||
excerpt: "AI-generated voice clones are being used to impersonate loved ones. Here's what to listen for and how ShieldAI's VoicePrint can help.",
|
||||
author: "Sarah Chen",
|
||||
date: "April 28, 2026",
|
||||
readingTime: "7 min read",
|
||||
coverImage: "",
|
||||
tags: ["Deepfakes", "AI Safety", "Scam Alerts"],
|
||||
},
|
||||
{
|
||||
slug: "what-is-data-broker",
|
||||
title: "What Is a Data Broker and How to Remove Your Information",
|
||||
excerpt: "Data brokers collect and sell your personal information. Find out how to opt out and reclaim your privacy with RemoveBrokers.",
|
||||
author: "Alex Kim",
|
||||
date: "April 20, 2026",
|
||||
readingTime: "4 min read",
|
||||
coverImage: "",
|
||||
tags: ["Privacy", "Identity Theft"],
|
||||
},
|
||||
{
|
||||
slug: "shieldai-product-update-may-2026",
|
||||
title: "ShieldAI Product Update — May 2026",
|
||||
excerpt: "New features including improved VoicePrint detection, expanded dark web monitoring, and a redesigned dashboard experience.",
|
||||
author: "Product Team",
|
||||
date: "April 15, 2026",
|
||||
readingTime: "3 min read",
|
||||
coverImage: "",
|
||||
tags: ["Product News"],
|
||||
},
|
||||
];
|
||||
|
||||
const POSTS_PER_PAGE = 4;
|
||||
|
||||
export default function BlogPage() {
|
||||
const [selectedTag, setSelectedTag] = createSignal<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = createSignal(POSTS_PER_PAGE);
|
||||
|
||||
const filtered = () => {
|
||||
const tag = selectedTag();
|
||||
if (!tag) return blogPosts;
|
||||
return blogPosts.filter((p) => p.tags.includes(tag));
|
||||
};
|
||||
|
||||
const visible = () => filtered().slice(0, visibleCount());
|
||||
const hasMore = () => visibleCount() < filtered().length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>ShieldAI Blog — AI-Powered Identity Protection</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
ShieldAI Blog
|
||||
</h1>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Insights on identity protection, AI safety, and the latest digital threats
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="pb-20">
|
||||
<PageContainer>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(null); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
!selectedTag()
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<For each={allTags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(tag); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
selectedTag() === tag
|
||||
? "bg-[var(--color-brand-primary)] text-white"
|
||||
: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={visible()}>
|
||||
{(post) => (
|
||||
<A href={`/blog/${post.slug}`}>
|
||||
<Card class="h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="h-40 bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg mb-4 flex items-center justify-center text-[var(--color-text-tertiary)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" class="opacity-40">
|
||||
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 12h24M12 6v20" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<For each={post.tags}>
|
||||
{(tag) => <Badge variant="default">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4 line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)] mt-auto">
|
||||
<span>{post.author}</span>
|
||||
<span>{post.date} · {post.readingTime}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={hasMore()}>
|
||||
<div class="text-center mt-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setVisibleCount((c) => c + POSTS_PER_PAGE)}
|
||||
>
|
||||
Load More Posts
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
363
web/src/routes/blog/[slug].tsx
Normal file
363
web/src/routes/blog/[slug].tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { For, Show, createMemo } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge, Card, Button } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
authorRole: string;
|
||||
date: string;
|
||||
readingTime: string;
|
||||
coverImage: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
slug: "ai-scam-trends-2026",
|
||||
title: "AI Scam Trends to Watch in 2026",
|
||||
excerpt: "As AI technology advances, scammers are finding new ways to exploit it.",
|
||||
content: `## The Rise of AI-Powered Scams
|
||||
|
||||
Artificial intelligence has become a double-edged sword. While it powers innovation across industries, it also arms bad actors with sophisticated tools for deception.
|
||||
|
||||
### Voice Cloning Scams
|
||||
|
||||
One of the most alarming trends is the use of AI voice cloning. Scammers need only a few seconds of audio—often scraped from social media videos—to create convincing voice replicas. These are used to impersonate family members in distress, requesting urgent money transfers.
|
||||
|
||||
### Deepfake Video Conferencing
|
||||
|
||||
In 2025, we saw the first wave of deepfake video calls used in corporate impersonation scams. Attackers use real-time face-swapping technology to pose as executives during Zoom calls, authorizing fraudulent wire transfers.
|
||||
|
||||
### Automated Phishing at Scale
|
||||
|
||||
AI-generated phishing emails are now nearly indistinguishable from legitimate correspondence. Language models craft personalized messages that reference real events, contacts, and context, dramatically increasing click-through rates.
|
||||
|
||||
## How to Protect Yourself
|
||||
|
||||
1. **Verify voice requests** — If someone calls asking for money or sensitive information, hang up and call them back on a trusted number.
|
||||
2. **Use a safe word** — Establish a family safe word that can be used to verify identity in suspicious situations.
|
||||
3. **Enable ShieldAI VoicePrint** — Our AI detects voice clones by analyzing acoustic fingerprints that deepfakes cannot replicate.
|
||||
4. **Stay skeptical of urgency** — Scammers create false urgency to bypass your critical thinking.
|
||||
|
||||
## The ShieldAI Advantage
|
||||
|
||||
ShieldAI's multi-layered protection uses machine learning models trained on millions of scam attempts to identify emerging threats before they reach you. Our DarkWatch service continuously scans the dark web for exposed credentials, while VoicePrint protects against audio deepfakes.`,
|
||||
author: "Sarah Chen",
|
||||
authorRole: "Security Researcher",
|
||||
date: "May 15, 2026",
|
||||
readingTime: "5 min read",
|
||||
coverImage: "",
|
||||
tags: ["AI Safety", "Scam Alerts", "Deepfakes"],
|
||||
},
|
||||
{
|
||||
slug: "dark-web-monitoring-guide",
|
||||
title: "The Complete Guide to Dark Web Monitoring",
|
||||
excerpt: "Learn how dark web monitoring works and how ShieldAI keeps your information safe.",
|
||||
content: `## What Is Dark Web Monitoring?
|
||||
|
||||
The dark web is a hidden part of the internet where cybercriminals trade stolen data. Dark web monitoring services scan these hidden marketplaces, forums, and chat channels for your personal information.
|
||||
|
||||
### What Data Gets Exposed
|
||||
|
||||
When data breaches occur, the following types of information are commonly stolen and sold:
|
||||
|
||||
- **Email addresses and passwords** — The most common credential type traded on dark web markets
|
||||
- **Social Security numbers** — Often used for identity theft and tax fraud
|
||||
- **Credit card details** — Sold in bulk with CVV codes and billing addresses
|
||||
- **Medical records** — Highly valuable on the black market for insurance fraud
|
||||
- **Phone numbers** — Used for SIM swapping and phishing attacks`,
|
||||
author: "Mike Reynolds",
|
||||
authorRole: "Cybersecurity Analyst",
|
||||
date: "May 10, 2026",
|
||||
readingTime: "8 min read",
|
||||
coverImage: "",
|
||||
tags: ["Dark Web", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "protecting-family-identity",
|
||||
title: "Protecting Your Family's Digital Identity",
|
||||
excerpt: "Discover steps to protect everyone in your household from identity theft.",
|
||||
content: `## Why Family Identity Protection Matters
|
||||
|
||||
Identity thieves don't discriminate. Children, elderly parents, and everyone in between are potential targets. In fact, child identity theft is particularly insidious because it often goes undetected for years.
|
||||
|
||||
### Common Family Identity Threats
|
||||
|
||||
- **Child identity theft** — Stolen SSNs used to open credit lines, often discovered when the child applies for college loans
|
||||
- **Elder financial exploitation** — Scammers target seniors with tech support scams, grandparent scams, and Medicare fraud
|
||||
- **Family account sharing** — Shared passwords across family streaming services can expose personal data`,
|
||||
author: "Emily Torres",
|
||||
authorRole: "Privacy Advocate",
|
||||
date: "May 5, 2026",
|
||||
readingTime: "6 min read",
|
||||
coverImage: "",
|
||||
tags: ["Identity Theft", "Privacy"],
|
||||
},
|
||||
{
|
||||
slug: "deepfake-voice-scams",
|
||||
title: "Deepfake Voice Scams: How They Work and How to Spot Them",
|
||||
excerpt: "AI-generated voice clones are being used to impersonate loved ones.",
|
||||
content: `## The Mechanics of Voice Cloning
|
||||
|
||||
Modern AI voice cloning requires surprisingly little source material. Just 30 seconds of audio—from a voicemail, social media video, or recorded phone call—is enough to generate a convincing voice clone.
|
||||
|
||||
### Real Cases
|
||||
|
||||
In 2024, a family in Arizona received a frantic call from what sounded like their daughter, claiming she had been kidnapped and demanding ransom. The voice was so convincing that the family almost transferred $50,000 before reaching the real daughter.
|
||||
|
||||
### How VoicePrint Detects Clones
|
||||
|
||||
VoicePrint analyzes over 200 acoustic features that are nearly impossible for AI to replicate perfectly, including micro-tremors, breathing patterns, and formant transitions unique to each person's vocal tract.`,
|
||||
author: "Sarah Chen",
|
||||
authorRole: "Security Researcher",
|
||||
date: "April 28, 2026",
|
||||
readingTime: "7 min read",
|
||||
coverImage: "",
|
||||
tags: ["Deepfakes", "AI Safety", "Scam Alerts"],
|
||||
},
|
||||
{
|
||||
slug: "what-is-data-broker",
|
||||
title: "What Is a Data Broker and How to Remove Your Information",
|
||||
excerpt: "Data brokers collect and sell your personal information.",
|
||||
content: `## Understanding Data Brokers
|
||||
|
||||
Data brokers are companies that collect personal information from various sources—public records, purchasing history, social media activity, and website tracking—then compile and sell this data to third parties.
|
||||
|
||||
### Why It Matters
|
||||
|
||||
Your data broker profile can include your home address, phone number, email address, income level, purchasing habits, political affiliation, and even health-related interests. This information can be used for targeted scams, identity theft, or unwanted marketing.
|
||||
|
||||
### How RemoveBrokers Helps
|
||||
|
||||
ShieldAI's RemoveBrokers service automates the opt-out process for hundreds of data broker sites, sending removal requests on your behalf and verifying that your information has been deleted.`,
|
||||
author: "Alex Kim",
|
||||
authorRole: "Data Privacy Specialist",
|
||||
date: "April 20, 2026",
|
||||
readingTime: "4 min read",
|
||||
coverImage: "",
|
||||
tags: ["Privacy", "Identity Theft"],
|
||||
},
|
||||
{
|
||||
slug: "shieldai-product-update-may-2026",
|
||||
title: "ShieldAI Product Update — May 2026",
|
||||
excerpt: "New features including improved VoicePrint detection and redesigned dashboard.",
|
||||
content: `## What's New in ShieldAI
|
||||
|
||||
We're excited to announce our May 2026 product update, packed with new features and improvements based on your feedback.
|
||||
|
||||
### Enhanced VoicePrint Detection
|
||||
|
||||
Our voice clone detection model has been retrained on a dataset 3x larger than before, improving detection accuracy to 99.7% while reducing false positives by 40%.
|
||||
|
||||
### Redesigned Dashboard
|
||||
|
||||
The dashboard has been completely redesigned for faster access to critical information. Key metrics are now displayed at a glance, and each service has its own dedicated section with detailed analytics.
|
||||
|
||||
### Expanded Dark Web Coverage
|
||||
|
||||
We've added monitoring for 50 additional dark web forums and marketplaces, bringing our total coverage to over 200 sources.`,
|
||||
author: "Product Team",
|
||||
authorRole: "ShieldAI",
|
||||
date: "April 15, 2026",
|
||||
readingTime: "3 min read",
|
||||
coverImage: "",
|
||||
tags: ["Product News"],
|
||||
},
|
||||
];
|
||||
|
||||
function contentToHtml(markdown: string): string {
|
||||
const lines = markdown.split("\n");
|
||||
let html = "";
|
||||
let inList = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
html += `<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mt-8 mb-3">${line.slice(3)}</h2>`;
|
||||
} else if (line.startsWith("### ")) {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
html += `<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mt-6 mb-2">${line.slice(4)}</h3>`;
|
||||
} else if (line.startsWith("**") && line.endsWith("**")) {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
html += `<p class="font-semibold text-[var(--color-text-primary)] mt-4 mb-1">${line.slice(2, -2)}</p>`;
|
||||
} else if (line.startsWith("- ")) {
|
||||
if (!inList) { html += '<ul class="list-disc pl-6 space-y-1.5 my-3 text-[var(--color-text-secondary)]">'; inList = true; }
|
||||
html += `<li>${line.slice(2)}</li>`;
|
||||
} else if (line.match(/^\d+\. /)) {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
const content = line.replace(/^\d+\. /, "");
|
||||
html += `<p class="text-[var(--color-text-secondary)] my-1">${content}</p>`;
|
||||
} else if (line.trim() === "") {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
} else {
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
html += `<p class="text-[var(--color-text-secondary)] leading-relaxed mb-3">${line}</p>`;
|
||||
}
|
||||
}
|
||||
if (inList) html += "</ul>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function getRelatedPosts(current: BlogPost, all: BlogPost[], count: number): BlogPost[] {
|
||||
const shared = all
|
||||
.filter((p) => p.slug !== current.slug)
|
||||
.map((p) => ({
|
||||
post: p,
|
||||
sharedTags: p.tags.filter((t) => current.tags.includes(t)).length,
|
||||
}))
|
||||
.filter((p) => p.sharedTags > 0)
|
||||
.sort((a, b) => b.sharedTags - a.sharedTags)
|
||||
.slice(0, count);
|
||||
return shared.map((s) => s.post);
|
||||
}
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const params = useParams();
|
||||
const post = createMemo(() => blogPosts.find((p) => p.slug === params.slug));
|
||||
const contentHtml = createMemo(() => post() ? contentToHtml(post()!.content) : "");
|
||||
const relatedPosts = createMemo(() => post() ? getRelatedPosts(post()!, blogPosts, 2) : []);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={post()}
|
||||
fallback={
|
||||
<main class="py-20 text-center">
|
||||
<Title>Post Not Found — ShieldAI</Title>
|
||||
<PageContainer>
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-3">Post Not Found</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-8">The blog post you're looking for doesn't exist or has been removed.</p>
|
||||
<A href="/blog">
|
||||
<Button variant="primary">Back to Blog</Button>
|
||||
</A>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<main>
|
||||
<Title>{post()!.title} — ShieldAI Blog</Title>
|
||||
|
||||
<article>
|
||||
<section class="relative py-16 md:py-20 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<A href="/blog" class="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] mb-6 transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Blog
|
||||
</A>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<For each={post()!.tags}>
|
||||
{(tag) => <Badge variant="info">{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4 max-w-3xl">
|
||||
{post()!.title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-[var(--color-text-tertiary)] mb-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xs font-bold">
|
||||
{post()!.author.split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)]">{post()!.author}</p>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)]">{post()!.authorRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{post()!.date}</span>
|
||||
<span class="text-[var(--color-text-tertiary)]">·</span>
|
||||
<span>{post()!.readingTime}</span>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="pb-16">
|
||||
<PageContainer>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-10">
|
||||
<div class="prose-custom" innerHTML={contentHtml()} />
|
||||
|
||||
<aside class="space-y-6">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-brand-primary)]/20 flex items-center justify-center text-[var(--color-brand-primary)] text-xl font-bold mx-auto mb-3">
|
||||
{post()!.author.split(" ").map(n => n[0]).join("")}
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)]">{post()!.author}</h3>
|
||||
<p class="text-xs text-[var(--color-text-tertiary)] mb-3">{post()!.authorRole}</p>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Security researcher and writer covering digital identity protection and AI safety.</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Share this article</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on Twitter"
|
||||
onClick={() => window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(post()!.title)}&url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Share on LinkedIn"
|
||||
onClick={() => window.open(`https://linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(window.location.href)}`, "_blank")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
aria-label="Copy link"
|
||||
onClick={() => navigator.clipboard.writeText(window.location.href)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={relatedPosts().length > 0}>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-3">Related Posts</h3>
|
||||
<div class="space-y-3">
|
||||
<For each={relatedPosts()}>
|
||||
{(rp) => (
|
||||
<A href={`/blog/${rp.slug}`}>
|
||||
<Card class="hover:shadow-md transition-shadow">
|
||||
<p class="text-sm font-medium text-[var(--color-text-primary)] mb-1">{rp.title}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={rp.tags}>
|
||||
{(tag) => <Badge>{tag}</Badge>}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</aside>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
266
web/src/routes/migrated-pages.test.tsx
Normal file
266
web/src/routes/migrated-pages.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import { MetaProvider } from "@solidjs/meta";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element; class?: string; onClick?: () => void }) => (
|
||||
<a href={props.href || "#"} class={props.class} onClick={props.onClick}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/dashboard" }),
|
||||
useParams: () => ({ slug: "ai-scam-trends-2026" }),
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
import BlogPage from "./blog";
|
||||
import BlogPostPage from "./blog/[slug]";
|
||||
import AdsPage from "./ads";
|
||||
import DashboardPage from "./(webapp)/dashboard";
|
||||
import StatCard from "~/components/dashboard/StatCard";
|
||||
import ActivityFeed from "~/components/dashboard/ActivityFeed";
|
||||
import QuickActions from "~/components/dashboard/QuickActions";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => <MetaProvider>{comp()}</MetaProvider>, container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("BlogPage (listing)", () => {
|
||||
it("renders hero section with blog headline", () => {
|
||||
mount(() => <BlogPage />);
|
||||
expect(document.body.textContent).toContain("ShieldAI Blog");
|
||||
});
|
||||
|
||||
it("renders all 6 blog post cards", () => {
|
||||
mount(() => <BlogPage />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("renders tag filter buttons", () => {
|
||||
mount(() => <BlogPage />);
|
||||
expect(document.body.textContent).toContain("All");
|
||||
expect(document.body.textContent).toContain("AI Safety");
|
||||
expect(document.body.textContent).toContain("Privacy");
|
||||
expect(document.body.textContent).toContain("Deepfakes");
|
||||
});
|
||||
|
||||
it("renders Load More button when there are more posts to show", () => {
|
||||
mount(() => <BlogPage />);
|
||||
expect(document.body.textContent).toContain("Load More Posts");
|
||||
});
|
||||
|
||||
it("renders post titles and excerpts", () => {
|
||||
mount(() => <BlogPage />);
|
||||
expect(document.body.textContent).toContain("AI Scam Trends to Watch in 2026");
|
||||
expect(document.body.textContent).toContain("Sarah Chen");
|
||||
expect(document.body.textContent).toContain("Mike Reynolds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlogPostPage ([slug])", () => {
|
||||
it("renders post content for valid slug", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
expect(document.body.textContent).toContain("AI Scam Trends to Watch in 2026");
|
||||
expect(document.body.textContent).toContain("Sarah Chen");
|
||||
expect(document.body.textContent).toContain("Security Researcher");
|
||||
expect(document.body.textContent).toContain("May 15, 2026");
|
||||
expect(document.body.textContent).toContain("5 min read");
|
||||
});
|
||||
|
||||
it("renders markdown content as HTML", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
expect(document.body.textContent).toContain("The Rise of AI-Powered Scams");
|
||||
expect(document.body.textContent).toContain("Voice Cloning Scams");
|
||||
expect(document.body.textContent).toContain("How to Protect Yourself");
|
||||
});
|
||||
|
||||
it("renders social share buttons", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
const shareBtns = document.querySelectorAll("button[aria-label]");
|
||||
const shareLabels = Array.from(shareBtns).map(b => b.getAttribute("aria-label"));
|
||||
expect(shareLabels).toContain("Share on Twitter");
|
||||
expect(shareLabels).toContain("Share on LinkedIn");
|
||||
expect(shareLabels).toContain("Copy link");
|
||||
});
|
||||
|
||||
it("renders related posts section", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
expect(document.body.textContent).toContain("Related Posts");
|
||||
});
|
||||
|
||||
it("renders author card in sidebar", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
expect(document.body.textContent).toContain("Security Researcher");
|
||||
});
|
||||
|
||||
it("renders back to blog link", () => {
|
||||
mount(() => <BlogPostPage />);
|
||||
expect(document.body.textContent).toContain("Back to Blog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdsPage", () => {
|
||||
it("renders conversion-focused headline", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("Stop AI Scams Before");
|
||||
expect(document.body.textContent).toContain("They Reach You");
|
||||
});
|
||||
|
||||
it("renders pricing section with 3 plans", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("Basic");
|
||||
expect(document.body.textContent).toContain("Plus");
|
||||
expect(document.body.textContent).toContain("Premium");
|
||||
expect(document.body.textContent).toContain("$9");
|
||||
expect(document.body.textContent).toContain("$19");
|
||||
expect(document.body.textContent).toContain("$39");
|
||||
});
|
||||
|
||||
it("marks Plus plan as Most Popular", () => {
|
||||
mount(() => <AdsPage />);
|
||||
const badges = document.body.querySelectorAll("span");
|
||||
const popularBadge = Array.from(badges).find(b => b.textContent === "Most Popular");
|
||||
expect(popularBadge).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders FAQ section with toggle functionality", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("Frequently Asked Questions");
|
||||
expect(document.body.textContent).toContain("How does ShieldAI detect voice clones?");
|
||||
expect(document.body.textContent).toContain("Is my data encrypted?");
|
||||
});
|
||||
|
||||
it("renders trust badges", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("14-day free trial");
|
||||
expect(document.body.textContent).toContain("No credit card required");
|
||||
expect(document.body.textContent).toContain("Cancel anytime");
|
||||
});
|
||||
|
||||
it("renders trust metrics section", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("99.7%");
|
||||
expect(document.body.textContent).toContain("200+");
|
||||
expect(document.body.textContent).toContain("50K+");
|
||||
});
|
||||
|
||||
it("renders testimonials", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("Trusted by Thousands");
|
||||
});
|
||||
|
||||
it("renders CTA section with Get Started Free button", () => {
|
||||
mount(() => <AdsPage />);
|
||||
expect(document.body.textContent).toContain("Ready to protect your identity?");
|
||||
expect(document.body.textContent).toContain("Get Started Free");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardPage", () => {
|
||||
it("renders dashboard title", () => {
|
||||
mount(() => <DashboardPage />);
|
||||
expect(document.body.textContent).toContain("Overview");
|
||||
});
|
||||
|
||||
it("renders stat cards with mock data", () => {
|
||||
mount(() => <DashboardPage />);
|
||||
expect(document.body.textContent).toContain("Active Threats");
|
||||
expect(document.body.textContent).toContain("Protected Accounts");
|
||||
expect(document.body.textContent).toContain("Dark Web Scans");
|
||||
expect(document.body.textContent).toContain("Alerts Today");
|
||||
expect(document.body.textContent).toContain("3");
|
||||
expect(document.body.textContent).toContain("12");
|
||||
expect(document.body.textContent).toContain("1,847");
|
||||
expect(document.body.textContent).toContain("7");
|
||||
});
|
||||
|
||||
it("renders sidebar with navigation links", () => {
|
||||
mount(() => <DashboardPage />);
|
||||
expect(document.body.textContent).toContain("Overview");
|
||||
expect(document.body.textContent).toContain("DarkWatch");
|
||||
expect(document.body.textContent).toContain("VoicePrint");
|
||||
expect(document.body.textContent).toContain("SpamShield");
|
||||
expect(document.body.textContent).toContain("HomeTitle");
|
||||
expect(document.body.textContent).toContain("RemoveBrokers");
|
||||
expect(document.body.textContent).toContain("Settings");
|
||||
});
|
||||
|
||||
it("renders activity feed", () => {
|
||||
mount(() => <DashboardPage />);
|
||||
expect(document.body.textContent).toContain("Recent Activity");
|
||||
expect(document.body.textContent).toContain("New credential leak detected");
|
||||
expect(document.body.textContent).toContain("VoicePrint scan completed");
|
||||
});
|
||||
|
||||
it("renders quick actions", () => {
|
||||
mount(() => <DashboardPage />);
|
||||
expect(document.body.textContent).toContain("Quick Actions");
|
||||
expect(document.body.textContent).toContain("Run Scan");
|
||||
expect(document.body.textContent).toContain("View Alerts");
|
||||
expect(document.body.textContent).toContain("Add Member");
|
||||
expect(document.body.textContent).toContain("Run Report");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatCard", () => {
|
||||
it("renders label and value", () => {
|
||||
mount(() => (
|
||||
<StatCard label="Test Metric" value="42" icon={() => <svg />} />
|
||||
));
|
||||
expect(document.body.textContent).toContain("Test Metric");
|
||||
expect(document.body.textContent).toContain("42");
|
||||
});
|
||||
|
||||
it("renders up trend indicator", () => {
|
||||
mount(() => (
|
||||
<StatCard label="Up Trend" value="10" trend="up" trendLabel="+5%" icon={() => <svg />} />
|
||||
));
|
||||
expect(document.body.textContent).toContain("+5%");
|
||||
});
|
||||
|
||||
it("renders down trend indicator", () => {
|
||||
mount(() => (
|
||||
<StatCard label="Down Trend" value="10" trend="down" trendLabel="-3%" icon={() => <svg />} />
|
||||
));
|
||||
expect(document.body.textContent).toContain("-3%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityFeed", () => {
|
||||
it("renders activities", () => {
|
||||
const activities = [
|
||||
{ id: "1", title: "Test Alert", description: "Test description", timestamp: "5m ago", type: "alert" as const },
|
||||
];
|
||||
mount(() => <ActivityFeed activities={activities} />);
|
||||
expect(document.body.textContent).toContain("Recent Activity");
|
||||
expect(document.body.textContent).toContain("Test Alert");
|
||||
expect(document.body.textContent).toContain("Test description");
|
||||
expect(document.body.textContent).toContain("5m ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickActions", () => {
|
||||
it("renders action buttons", () => {
|
||||
const actions = [
|
||||
{ label: "Action 1", href: "/test", icon: () => <svg /> },
|
||||
{ label: "Action 2", href: "/test2", icon: () => <svg /> },
|
||||
];
|
||||
mount(() => <QuickActions actions={actions} />);
|
||||
expect(document.body.textContent).toContain("Quick Actions");
|
||||
expect(document.body.textContent).toContain("Action 1");
|
||||
expect(document.body.textContent).toContain("Action 2");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user