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,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