feat: complete launch & marketing layer

- Add marketing pages: blog, changelog, roadmap, waitlist, security, maintenance
- Add GDPR cookie consent banner with preference modal
- Add referral system with API routes for code generation/tracking
- Add waitlist API with referral support
- Add PWA support: manifest, service worker, install prompt
- Add onboarding flow with 6-step guided tour
- Add in-app notification center with bell dropdown
- Add admin launch checklist (32 items across 8 categories)
- Update landing page with trust badges
- Add Open Graph image and favicon
- Configure ESLint for PWA install patterns
- Add LAUNCH_CHECKLIST.md with go-to-market guide

Supabase migrations for:
- blog_posts and blog_categories tables
- waitlist_signups table
- roadmap_items table
- launch_checklist_items table
This commit is contained in:
2026-06-02 06:19:56 +00:00
parent f6d2dc4e6c
commit 7dfaba6e3d
46 changed files with 4065 additions and 2188 deletions
@@ -0,0 +1,204 @@
// In-App Notification Center - Bell icon with dropdown panel
"use client";
import { useState, useEffect, useRef } from "react";
interface Notification {
id: string;
title: string;
message: string;
type: "info" | "success" | "warning" | "error";
read: boolean;
created_at: string;
link?: string;
}
interface NotificationCenterProps {
brandId?: string;
userId?: string;
}
export default function NotificationCenter({ brandId, userId }: NotificationCenterProps) {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
// Fetch notifications
useEffect(() => {
if (isOpen && brandId) {
fetchNotifications();
}
}, [isOpen, brandId]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const fetchNotifications = async () => {
setLoading(true);
try {
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications || []);
}
} catch (error) {
console.error("Failed to fetch notifications:", error);
} finally {
setLoading(false);
}
};
const markAsRead = async (id: string) => {
try {
await fetch("/api/notifications", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, read: true }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
} catch (error) {
console.error("Failed to mark as read:", error);
}
};
const markAllAsRead = async () => {
try {
await fetch("/api/notifications/mark-all-read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_id: brandId }),
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} catch (error) {
console.error("Failed to mark all as read:", error);
}
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const getTypeStyles = (type: string) => {
switch (type) {
case "success":
return "bg-emerald-100 text-emerald-600";
case "warning":
return "bg-amber-100 text-amber-600";
case "error":
return "bg-red-100 text-red-600";
default:
return "bg-blue-100 text-blue-600";
}
};
return (
<div className="relative" ref={dropdownRef}>
{/* Bell Icon Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 hover:bg-gray-100 rounded-xl transition-colors"
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ""}`}
>
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
Mark all as read
</button>
)}
</div>
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-8 text-center">
<div className="w-6 h-6 border-2 border-gray-300 border-t-[#1a4d2e] rounded-full animate-spin mx-auto" />
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-sm text-gray-500">No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-50">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read ? "bg-emerald-50/50" : ""
}`}
onClick={() => {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}}
>
<div className="flex items-start gap-3">
<span className={`shrink-0 w-2 h-2 mt-2 rounded-full ${getTypeStyles(notification.type)} ${notification.read ? "opacity-30" : ""}`} />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 text-sm">{notification.title}</p>
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-xs text-gray-400 mt-2">{formatTime(notification.created_at)}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3">
<a href="/admin/notifications" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium">
View all notifications
</a>
</div>
</div>
)}
</div>
);
}