| name | agency-frontend |
| description | Build and maintain the Social Media Agency Next.js frontend with dashboard UI, content calendar, analytics, and responsive design. Use when creating pages, components, layouts, forms, or frontend configuration. |
Social Media Agency Next.js Frontend
Tech Stack
{
"dependencies": {
"next": "^14.2",
"react": "^18.3",
"react-dom": "^18.3",
"@radix-ui/react-dialog": "^1.1",
"@radix-ui/react-dropdown-menu": "^2.1",
"@radix-ui/react-tabs": "^1.1",
"@radix-ui/react-select": "^2.1",
"class-variance-authority": "^0.7",
"clsx": "^2.1",
"tailwind-merge": "^2.5",
"lucide-react": "^0.460",
"recharts": "^2.13",
"zustand": "^4.5",
"@tanstack/react-query": "^5.60",
"@dnd-kit/core": "^6.1",
"@dnd-kit/sortable": "^8.0",
"react-hook-form": "^7.53",
"@hookform/resolvers": "^3.9",
"zod": "^3.23",
"sonner": "^1.7",
"date-fns": "^3.6"
}
}
API Client
Typed client wrapping fetch with automatic auth headers.
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8001";
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = typeof window !== "undefined"
? localStorage.getItem("token")
: null;
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!res.ok) {
const body = await res.json().( ({}));
(res., body. || );
}
res.();
}
api = {
: request<{ : }>(),
:
request<{ : ; : }>(, {
: , : .({ email, password }),
}),
:
request<{ : }>(, {
: , : .(data),
}),
:
request<>(),
:
request<>(),
:
request<>(, {
: , : .(data),
}),
:
request<>(),
:
request<>(, {
: , : .(data),
}),
:
request<>(),
:
request<>(, {
: , : .(data),
}),
:
request<>(, {
: , : .(data),
}),
:
request<[]>(),
:
request<>(, {
: , : .({ : scheduledAt }),
}),
:
request<>(),
:
request<>(, { : }),
:
request<>(, {
: , : .({ feedback }),
}),
:
request<>(),
:
request<>(),
:
request<>(, {
: ,
: formData,
: {},
}),
:
request<>(),
:
request<{ : }>(, {
: , : .({ : planId }),
}),
:
request<{ : ; : }>(, {
: ,
}),
:
request<{ : ; : | }>(),
};
Page Template
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "@/lib/api";
export default function FeaturePage() {
const [data, setData] = useState<DataType[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getData()
.then(setData)
.catch((err) => toast.error(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <PageSkeleton />;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Feature</h1>
{/* Content */}
);
}
Dashboard Layout
"use client";
import { useState } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { MobileNav } from "@/components/layout/mobile-nav";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-indigo-50/30">
<Sidebar className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64" />
<MobileNav open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="lg:pl-64">
<Topbar onMenuClick={() => setSidebarOpen(true)} />
<main className="p-4 sm:p-6 lg:p-8">{children}
);
}
KPI Card
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown } from "lucide-react";
interface KPICardProps {
label: string;
value: string;
changePct: number;
icon: React.ReactNode;
loading?: boolean;
}
export function KPICard({ label, value, changePct, icon, loading }: KPICardProps) {
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="h-4 w-24 animate-pulse rounded bg-slate-200" />
<div className="mt-3 h-8 w-32 animate-pulse rounded bg-slate-200" />
</CardContent>
</Card>
);
}
const isPositive = changePct >= ;
(
);
}
Content Editor with AI
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Sparkles } from "lucide-react";
import { api } from "@/lib/api";
const schema = z.object({
body: z.string().min(1, "Content body is required"),
platform: z.string().min(1, "Select a platform"),
hashtags: z.string().optional(),
scheduled_at: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
export function ContentEditor({ clientId, onSuccess }: { clientId: string; onSuccess: () => }) {
[generating, setGenerating] = ();
{
register,
handleSubmit,
setValue,
watch,
: { errors, isSubmitting },
} = useForm<>({ : (schema) });
() {
();
{
result = api.({
: clientId,
: (),
: () || ,
: ,
: ,
});
(, result.);
(result.) (, result..());
toast.();
} (: ) {
toast.(err.);
} {
();
}
}
() {
{
api.({
: clientId,
: data.,
: data.,
: data.?.().() || [],
: data. || ,
});
toast.();
();
} (: ) {
toast.(err.);
}
}
(
);
}
Calendar View (Drag & Drop)
"use client";
import { DndContext, DragEndEvent } from "@dnd-kit/core";
import { toast } from "sonner";
import { api } from "@/lib/api";
interface CalendarEvent {
id: string;
title: string;
platform: string;
scheduledAt: string;
status: string;
clientName: string;
}
export function ContentCalendar({ events }: { events: CalendarEvent[] }) {
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
try {
await api.rescheduleContent(active.id as string, over.id as string);
toast.success();
} (: ) {
toast.(err.);
}
}
(
);
}
Loading Skeleton
function PageSkeleton() {
return (
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded-lg bg-slate-200" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-28 animate-pulse rounded-xl bg-slate-200" />
))}
</div>
<div className="h-64 animate-pulse rounded-xl bg-slate-200" />
</div>
);
}
Error Boundary
"use client";
export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
<h2 className="text-xl font-semibold text-slate-900">Something went wrong</h2>
<p className="text-slate-500">{error.message}</p>
<button onClick={reset} className="rounded-lg bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-500">
Try again
</button>
</div>
);
}
Platform Color Coding
export const PLATFORM_COLORS: Record<string, { bg: string; text: string; border: string }> = {
instagram: { bg: "bg-pink-50", text: "text-pink-700", border: "border-pink-200" },
facebook: { bg: "bg-blue-50", text: "text-blue-700", border: "border-blue-200" },
twitter: { bg: "bg-sky-50", text: "text-sky-700", border: "border-sky-200" },
linkedin: { bg: "bg-indigo-50", text: "text-indigo-700", border: "border-indigo-200" },
tiktok: { bg: "bg-slate-50", text: "text-slate-700", border: "border-slate-200" },
};
Key Rules
- Never hardcode backend URLs — use
NEXT_PUBLIC_API_URL
- Always use the
api client — never raw fetch in components
- Always provide loading skeletons — never blank screens
- Always define TypeScript interfaces for API responses
- Dashboard uses light theme — slate/indigo palette
- Toast notifications via sonner — never
alert()
- Icons from
lucide-react only
- Mobile-first — design for 375px, scale up with
sm:, md:, lg:
- Sidebar collapses on screens < 1024px
- Drag-and-drop via
@dnd-kit for calendar and content ordering
- Color-code content by platform — use the
PLATFORM_COLORS map