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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Toast Notification System - Slide-in notifications from top-right
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastNotificationProps {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: ToastNotificationProps) {
|
||||
const [progress, setProgress] = useState(100);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const duration = toast.duration ?? 5000;
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
|
||||
setProgress(remaining);
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
setIsExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 300);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [toast.id, duration, onDismiss]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 300);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
warning: "bg-amber-50 border-amber-200 text-amber-800",
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
success: "text-emerald-500",
|
||||
error: "text-red-500",
|
||||
warning: "text-amber-500",
|
||||
info: "text-blue-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative w-80 bg-white rounded-xl shadow-xl border overflow-hidden
|
||||
transition-all duration-300
|
||||
${isExiting ? "opacity-0 translate-x-full" : "opacity-100 translate-x-0"}
|
||||
`}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-[#1a4d2e] transition-all duration-50"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`shrink-0 ${iconColors[toast.type]}`}>{icons[toast.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 text-sm">{toast.title}</p>
|
||||
{toast.message && <p className="mt-1 text-sm text-gray-500">{toast.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-1 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast container
|
||||
let toastCounter = 0;
|
||||
const toastListeners: Set<(toast: Toast) => void> = new Set();
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "success", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
error: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "error", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
warning: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "warning", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
info: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "info", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
};
|
||||
|
||||
export default function ToastNotificationContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const handleNewToast = (newToast: Toast) => {
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
};
|
||||
|
||||
toastListeners.add(handleNewToast);
|
||||
return () => {
|
||||
toastListeners.delete(handleNewToast);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="pointer-events-auto">
|
||||
<ToastItem toast={t} onDismiss={handleDismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user