Project Implementation plan
Project Implementation plan
3. **Authentication System**
- Implement NextAuth.js with the following providers:
- Credentials provider (email/password)
- OAuth providers if needed
- Create login, register, and password reset pages
- Implement session management and protected routes
2. **Admin Authentication**
- Create separate admin authentication flow
- Implement admin session validation middleware
- Set up admin-only protected routes
2. **Profile Management**
- Create profile editing interface
- Implement avatar upload and management
- Add account settings functionality
3. **Link Management**
- Build link creation and editing interface
- Implement link analytics display
- Create link organization features
2. **Theme Customization**
- Create theme selection interface
- Implement font management
- Build color scheme customization
3. **Mobile Responsiveness**
- Ensure all user interfaces work on mobile
- Implement responsive design patterns
- Test across various device sizes
2. **Subscription Management**
- Build subscription plan selection interface
- Implement subscription lifecycle management
- Create billing history and invoice display
3. **Plan Features**
- Implement feature access based on subscription level
- Create upgrade/downgrade flows
- Add trial period functionality
2. **User Management**
- Create user listing with search and filters
- Implement user editing and management
- Add user activity monitoring
3. **Content Management**
- Build page editor for static content
- Implement language management
- Create theme and font management interfaces
2. **Plan Management**
- Build subscription plan editor
- Implement feature management for plans
- Create pricing configuration
3. **Agency Features**
- Implement agency dashboard
- Create client management interface
- Build agency-specific analytics
2. **Bulk Operations**
- Add bulk user management
- Implement batch operations for links
- Create import/export functionality
3. **Admin API**
- Complete remaining admin API endpoints
- Implement comprehensive error handling
- Add rate limiting and security features
2. **Integration Testing**
- Test end-to-end user flows
- Verify admin functionality
- Test payment processing
3. **Performance Testing**
- Analyze and optimize page load times
- Implement code splitting and lazy loading
- Optimize database queries
2. **Accessibility**
- Audit and fix accessibility issues
- Implement keyboard navigation
- Add screen reader support
3. **Documentation**
- Create user documentation
- Write admin guide
- Document API endpoints
2. **Data Migration**
- Create data migration scripts
- Test migration process
- Prepare rollback plan
3. **CI/CD Setup**
- Configure continuous integration
- Set up automated testing
- Implement deployment pipeline
2. **User Migration**
- Migrate existing users
- Send communication about the new platform
- Provide support for transition
3. **Post-Launch Support**
- Monitor system performance
- Address user feedback
- Fix any critical issues
This plan provides a structured approach to migrating your PHP application to NextJS
# OnlyLinks: PHP to NextJS Migration Technical Implementation Guide
This guide provides detailed technical instructions for migrating the Only
```bash
# Initialize NextJS app with TypeScript
abase structure npx create-next-app@latest onlylinks-next --typescript --eslint --app --src
# Set up Husky
npx husky init
```
```javascript
// src/lib/db/index.js
import { drizzle } from 'drizzle-orm/vercel-postgres';
import { sql } from '@vercel/postgres';
```javascript
// src/lib/db/schema/users.js
import { pgTable, serial, varchar, boolean, timestamp, text } from 'drizzle
import { relations } from 'drizzle-orm';
import { links } from './links';
import { subscriptions } from './subscriptions';
Create similar schema files for other tables (links, subscriptions, plans, e
```javascript
// drizzle.config.js
export default {
schema: './src/lib/db/schema',
out: './src/lib/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL,
}
};
```
```bash
# Generate migrations
npx drizzle-kit generate
# Apply migrations
npx drizzle-kit push
```
```javascript
// src/lib/auth/auth-options.js
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
if (!user || !user.active) {
return null;
}
if (!passwordMatch) {
return null;
}
return {
id: user.id.toString(),
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id;
session.user.role = token.role;
}
return session;
},
},
};
```
```javascript
// src/app/api/auth/[...nextauth]/route.js
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth/auth-options";
```jsx
// src/app/(auth)/login/page.js
use client;
try {
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setIsLoading(false);
ur PHP application to NextJS while ensuring all fu return;
}
router.push("/dashboard");
router.refresh();
} catch (error) {
setError("An error occurred. Please try again.");
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col items-center justify-center
<div className="w-full max-w-md space-y-8 rounded-lg border p-6 s
<div className="text-center">
<h1 className="text-2xl font-bold">Login to OnlyLinks</h1>
<p className="mt-2 text-gray-600">Enter your credentials to acce
</div>
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700"
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-med
Password
</label>
<input
id="password"
type="password"
{...register("password")}
className="mt-1 block w-full rounded-md border border-gray-
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.m
)}
</div>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-bl
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="flex w-full justify-center rounded-md bg-blue-600 p
>
{isLoading ? "Logging in..." : "Sign in"}
</button>
</div>
```javascript
// src/app/api/users/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import bcrypt from "bcrypt";
import * as z from "zod";
if (existingUserByEmail) {
return NextResponse.json(
{ error: "Email already in use" },
{ status: 400 }
);
}
if (existingUserByUsername) {
return NextResponse.json(
{ error: "Username already taken" },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const newUser = await db.insert(users).values({
name,
email,
password: hashedPassword,
username,
active: true,
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
}).returning();
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
username: user.username,
}, { status: 201 });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}
```
```javascript
// src/app/api/users/[id]/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
export async function GET(request, { params }) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Only allow users to access their own data or admins to access any d
if (session.user.id !== params.id && session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Only allow users to update their own data or admins to update any
if (session.user.id !== params.id && session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
// Update user
const updatedUser = await db.update(users)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(users.id, parseInt(params.id)))
.returning();
if (!updatedUser.length) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}
```
```javascript
// src/middleware.js
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
return NextResponse.next();
}
```javascript
// src/app/api/admin/login/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
if (!username || !password) {
return NextResponse.json(
{ error: "Username and password are required" },
{ status: 400 }
);
}
if (!user || !user.active) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
// Verify password
const passwordMatch = await bcrypt.compare(password, user.passwo
if (!passwordMatch) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
return response;
} catch (error) {
console.error("Admin login error:", error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 }
);
}
}
```
```javascript
// src/app/api/upload/route.js
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { writeFile } from "fs/promises";
import { join } from "path";
import { v4 as uuidv4 } from "uuid";
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
if (!file) {
return NextResponse.json(
{ error: "No file uploaded" },
{ status: 400 }
);
}
// Save file
await writeFile(filePath, buffer);
```jsx
// src/app/(dashboard)/layout.js
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { redirect } from "next/navigation";
import Sidebar from "@/components/dashboard/Sidebar";
import Header from "@/components/dashboard/Header";
if (!session) {
redirect("/login");
}
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header user={session.user} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</
</div>
</div>
);
}
```
```jsx
// src/components/dashboard/Sidebar.jsx
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Link as LinkIcon,
Settings,
BarChart,
LogOut
} from "lucide-react";
import { signOut } from "next-auth/react";
const navItems = [
{
name: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
},
{
name: "Links",
href: "/links",
icon: LinkIcon,
},
{
name: "Analytics",
href: "/analytics",
icon: BarChart,
},
{
name: "Settings",
href: "/settings",
icon: Settings,
},
];
return (
<aside className="hidden w-64 flex-shrink-0 bg-white shadow-md m
<div className="flex h-16 items-center justify-center border-b">
<Link href="/dashboard" className="text-xl font-bold">OnlyLinks<
</div>
<nav className="flex flex-1 flex-col p-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.name}>
<Link
href={item.href}
className={`flex items-center rounded-md px-4 py-2 text-sm
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
</li>
);
})}
</ul>
<div className="mt-auto">
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="flex w-full items-center rounded-md px-4 py-2 text-s
>
<LogOut className="mr-3 h-5 w-5" />
Sign out
</button>
</div>
</nav>
</aside>
);
}
```
```jsx
// src/components/dashboard/Header.jsx
import { useState } from "react";
import { Menu, Bell, User } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
return (
<header className="flex h-16 items-center justify-between border-b
<button className="rounded-md p-2 text-gray-500 hover:bg-gray-10
<Menu className="h-6 w-6" />
</button>
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-2 rounded-md p-2 text-gra
>
<div className="relative h-8 w-8 overflow-hidden rounded-full b
{user.image ? (
<Image
src={user.image}
alt={user.name || "User"}
fill
className="object-cover"
/>
):(
<User className="h-full w-full p-1" />
)}
</div>
<span className="hidden text-sm font-medium md:block">
{user.name || user.email}
</span>
</button>
{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md bg-whit
<Link
href="/settings/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Your Profile
</Link>
<Link
href="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Settings
</Link>
<button
onClick={() => {
setIsProfileOpen(false);
signOut({ callbackUrl: "/" });
}}
className="block w-full px-4 py-2 text-left text-sm text-gray-70
>
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);
}
```
```javascript
// src/lib/db/schema/links.js
import { pgTable, serial, varchar, text, timestamp, integer, boolean } from
import { relations } from 'drizzle-orm';
import { users } from './users';
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
return NextResponse.json(userLinks);
} catch (error) {
console.error("Error fetching links:", error);
return NextResponse.json(
{ error: "Failed to fetch links" },
{ status: 500 }
);
}
}
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Create link
const newLink = await db.insert(links).values({
userId: parseInt(session.user.id),
title: body.title,
url: body.url,
active: body.active ?? true,
customIcon: body.customIcon,
position: newPosition,
createdAt: new Date(),
updatedAt: new Date(),
}).returning();
useEffect(() => {
const fetchLinks = async () => {
try {
const response = await fetch("/api/links");
if (!response.ok) {
throw new Error("Failed to fetch links");
}
fetchLinks();
}, []);
// Update positions
const updatedItems = items.map((item, index) => ({
...item,
position: index,
}));
setLinks(updatedItems);
// Update positions in database
try {
const movedItem = updatedItems[result.destination.index];
await fetch(`/api/links/${movedItem.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ position: result.destination.index }),
});
} catch (error) {
console.error("Error updating position:", error);
}
};
try {
const response = await fetch(`/api/links/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete link");
}
if (!response.ok) {
throw new Error("Failed to update link");
}
setLinks(
links.map((link) =>
link.id === id ? { ...link, active: !active } : link
)
);
} catch (error) {
console.error("Error updating link:", error);
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 bord
<p className="mt-2 text-gray-600">Loading links...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600">{error}</p>
<button
onClick={() => router.refresh()}
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white h
>
Try again
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Your Links</h1>
<button
onClick={() => router.push("/links/new")}
className="flex items-center rounded-md bg-blue-600 px-4 py-2 t
>
<Plus className="mr-2 h-4 w-4" />
Add Link
</button>
</div>
{links.length === 0 ? (
<div className="rounded-md bg-gray-50 p-8 text-center">
<p className="text-gray-600">You don't have any links yet.</p>
<button
onClick={() => router.push("/links/new")}
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white h
>
Create your first link
</button>
</div>
):(
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="links">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-4"
>
{links.map((link, index) => (
<Draggable
key={link.id}
draggableId={link.id.toString()}
index={index}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`rounded-md border p-4 ${
link.active ? "bg-white" : "bg-gray-50"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div
{...provided.dragHandleProps}
className="mr-3 cursor-move text-gray-400 hover:text
>
<Move className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">{link.title}</h3>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-gray-500 ho
>
{link.url}
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
<span className="mr-2 text-sm text-gray-500">
{link.clicks} clicks
</span>
<label className="relative inline-flex cursor-pointer ite
<input
type="checkbox"
checked={link.active}
onChange={() =>
handleToggleActive(link.id, link.active)
}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-2
</label>
</div>
<button
onClick={() => router.push(`/links/${link.id}`)}
className="rounded-md p-2 text-gray-500 hover:bg-gr
>
<Edit className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteLink(link.id)}
className="rounded-md p-2 text-gray-500 hover:bg-gr
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
);
}
if (!linkId) {
return NextResponse.json(
{ error: "Link ID is required" },
{ status: 400 }
);
}
// Get link
const link = await db.query.links.findFirst({
where: eq(links.id, parseInt(linkId)),
});
if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 }
);
}
// Record click
await db.insert(linkClicks).values({
linkId: parseInt(linkId),
userId: link.userId,
clickedAt: new Date(),
ipAddress,
userAgent: request.headers.get("user-agent") || "",
referrer: request.headers.get("referer") || "",
country,
city,
});
// src/app/api/analytics/track-view/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { pageViews } from "@/lib/db/schema/analytics";
import { getClientIp } from "@/lib/utils/ip";
import { getCountryAndCity } from "@/lib/utils/geo";
if (!userId) {
return NextResponse.json(
{ error: "User ID is required" },
{ status: 400 }
);
}
if (forwarded) {
return forwarded.split(",")[0].trim();
}
// src/lib/utils/geo.js
export async function getCountryAndCity(ip) {
// For production, use a geolocation service like ipinfo.io or maxmind
// This is a simplified example
try {
if (ip === "127.0.0.1") {
return { country: "US", city: "Local" };
}
return {
country: data.country || "",
city: data.city || "",
};
} catch (error) {
console.error("Error getting location:", error);
return { country: "", city: "" };
}
}
if (!session) {
// For public themes, no authentication required
const publicThemes = await db.query.themes.findMany({
where: eq(themes.isPublic, true),
});
return NextResponse.json(publicThemes);
}
return NextResponse.json(userThemes);
} catch (error) {
console.error("Error fetching themes:", error);
return NextResponse.json(
{ error: "Failed to fetch themes" },
{ status: 500 }
);
}
}
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Create theme
const newTheme = await db.insert(themes).values({
name: body.name,
description: body.description,
isPublic: body.isPublic ?? false,
createdBy: parseInt(session.user.id),
styles: body.styles || {},
}).returning();
// src/app/api/themes/[id]/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { themes } from "@/lib/db/schema/themes";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { eq, and } from "drizzle-orm";
export async function GET(request, { params }) {
try {
const theme = await db.query.themes.findFirst({
where: eq(themes.id, parseInt(params.id)),
});
if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}
return NextResponse.json(theme);
} catch (error) {
console.error("Error fetching theme:", error);
return NextResponse.json(
{ error: "Failed to fetch theme" },
{ status: 500 }
);
}
}
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const theme = await db.query.themes.findFirst({
where: eq(themes.id, parseInt(params.id)),
});
if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}
// Update theme
const updatedTheme = await db.update(themes)
.set({
name: body.name ?? theme.name,
description: body.description ?? theme.description,
isPublic: body.isPublic ?? theme.isPublic,
styles: body.styles ?? theme.styles,
})
.where(eq(themes.id, parseInt(params.id)))
.returning();
return NextResponse.json(updatedTheme[0]);
} catch (error) {
console.error("Error updating theme:", error);
return NextResponse.json(
{ error: "Failed to update theme" },
{ status: 500 }
);
}
}
if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}
// Delete theme
await db.delete(themes)
.where(eq(themes.id, parseInt(params.id)));
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
if (!planId) {
return NextResponse.json(
{ error: "Plan ID is required" },
{ status: 400 }
);
}
// Get plan
const plan = await db.query.plans.findFirst({
where: eq(plans.id, parseInt(planId)),
});
if (!plan || !plan.isActive) {
return NextResponse.json(
{ error: "Plan not found or inactive" },
{ status: 404 }
);
}
// Get user
const user = await db.query.users.findFirst({
where: eq(users.id, parseInt(session.user.id)),
});
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json(
{ error: "Webhook signature verification failed" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
case "invoice.payment_succeeded": {
const invoice = event.data.object;
case "invoice.payment_failed": {
const invoice = event.data.object;
case "customer.subscription.updated": {
const subscription = event.data.object;
case "customer.subscription.deleted": {
const subscription = event.data.object;
if (!userId || !planId) {
console.error("Missing metadata in checkout session");
return;
}
if (!subscription) {
console.error("Subscription not found:", subscriptionId);
return;
}
// Update subscription
await db.update(subscriptions)
.set({
status: "active",
currentPeriodStart: new Date(stripeSubscription.current_period_star
currentPeriodEnd: new Date(stripeSubscription.current_period_end
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscriptionId));
if (!subscription) {
console.error("Subscription not found:", subscriptionId);
return;
}
// Update subscription
await db.update(subscriptions)
.set({
status: "past_due",
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscriptionId));
}
if (!dbSubscription) {
console.error("Subscription not found:", subscription.id);
return;
}
// Update subscription
await db.update(subscriptions)
.set({
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 10
currentPeriodEnd: new Date(subscription.current_period_end * 1000
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
}
if (!dbSubscription) {
console.error("Subscription not found:", subscription.id);
return;
}
// Update subscription
await db.update(subscriptions)
.set({
status: "canceled",
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
// src/app/(admin)/layout.js
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { redirect } from "next/navigation";
import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminHeader from "@/components/admin/AdminHeader";
return (
<div className="flex h-screen bg-gray-50">
<AdminSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<AdminHeader user={session.user} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</
</div>
</div>
);
}
const navItems = [
{
name: "Dashboard",
href: "/admin/dashboard",
icon: LayoutDashboard,
},
{
name: "Users",
href: "/admin/users",
icon: Users,
},
{
name: "Plans",
href: "/admin/plans",
icon: CreditCard,
},
{
name: "Subscriptions",
href: "/admin/subscriptions",
icon: CreditCard,
},
{
name: "Themes",
href: "/admin/themes",
icon: Palette,
},
{
name: "Pages",
href: "/admin/pages",
icon: FileText,
},
{
name: "Languages",
href: "/admin/languages",
icon: Globe,
},
{
name: "Settings",
href: "/admin/settings",
icon: Settings,
},
];
return (
<aside className="hidden w-64 flex-shrink-0 bg-white shadow-md m
<div className="flex h-16 items-center justify-center border-b">
<Link href="/admin/dashboard" className="text-xl font-bold">
OnlyLinks Admin
</Link>
</div>
<nav className="flex flex-1 flex-col p-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.name}>
<Link
href={item.href}
className={`flex items-center rounded-md px-4 py-2 text-sm
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
</li>
);
})}
</ul>
<div className="mt-auto">
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="flex w-full items-center rounded-md px-4 py-2 text-s
>
<LogOut className="mr-3 h-5 w-5" />
Sign out
</button>
</div>
</nav>
</aside>
);
}
// src/components/admin/AdminHeader.jsx
import { useState } from "react";
import { Menu, Bell, User } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { signOut } from "next-auth/react";
return (
<header className="flex h-16 items-center justify-between border-b
<button className="rounded-md p-2 text-gray-500 hover:bg-gray-10
<Menu className="h-6 w-6" />
</button>
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-2 rounded-md p-2 text-gra
>
<div className="relative h-8 w-8 overflow-hidden rounded-full b
{user.image ? (
<Image
src={user.image}
alt={user.name || "Admin"}
fill
className="object-cover"
/>
):(
<User className="h-full w-full p-1" />
)}
</div>
<span className="hidden text-sm font-medium md:block">
{user.name || user.email}
</span>
</button>
{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md bg-whit
<Link
href="/admin/settings/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Your Profile
</Link>
<Link
href="/admin/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Settings
</Link>
<button
onClick={() => {
setIsProfileOpen(false);
signOut({ callbackUrl: "/" });
}}
className="block w-full px-4 py-2 text-left text-sm text-gray-70
>
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);
}
// Build query
let query = db.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
active: users.active,
role: users.role,
createdAt: users.createdAt,
pageStatus: users.pageStatus,
}).from(users);
// Add search condition
if (search) {
query = query.where(
sql`${users.name} ILIKE ${'%' + search + '%'} OR ${users.email} ILIKE
);
}
// Add sorting
if (sortOrder === "desc") {
query = query.orderBy(desc(users[sortBy]));
} else {
query = query.orderBy(asc(users[sortBy]));
}
// Add pagination
query = query.limit(limit).offset(offset);
// Execute query
const usersList = await query;
if (search) {
countQuery.where(
sql`${users.name} ILIKE ${'%' + search + '%'} OR ${users.email} ILIKE
);
}
return NextResponse.json({
users: usersList,
pagination: {
page,
limit,
totalItems: count,
totalPages,
},
});
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// Get user
const user = await db.query.users.findFirst({
where: eq(users.id, parseInt(params.id)),
});
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
return NextResponse.json({
user: userWithoutPassword,
subscription,
linksCount: linksCount[0].count,
});
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
if (!existingUser) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
// Update user
const updatedUser = await db.update(users)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(users.id, parseInt(params.id)))
.returning();
return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}
if (!existingUser) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
// Delete user
await db.delete(users)
.where(eq(users.id, parseInt(params.id)));
Configure Jest:
// jest.config.js
const nextJest = require('next/jest');
module.exports = createJestConfig(customJestConfig);
// jest.setup.js
import '@testing-library/jest-dom';
// Mock dependencies
jest.mock('@/lib/db', () => ({
insert: jest.fn(),
query: {
users: {
findFirst: jest.fn(),
},
},
}));
jest.mock('bcrypt', () => ({
hash: jest.fn(() => 'hashed_password'),
}));
// Assertions
expect(db.query.users.findFirst).toHaveBeenCalledTimes(2); // Check
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(db.insert).toHaveBeenCalledTimes(1);
expect(res._getStatusCode()).toBe(201);
return {
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one place
keywords: keywords || 'links, bio links, link management, social media
openGraph: {
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one plac
url: url ? `${baseUrl}${url}` : baseUrl,
siteName: 'OnlyLinks',
images: [
{
url: image || `${baseUrl}/images/og-image.jpg`,
width: 1200,
height: 630,
alt: title || 'OnlyLinks',
},
],
locale: 'en_US',
type,
},
twitter: {
card: 'summary_large_image',
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one plac
images: [image || `${baseUrl}/images/og-image.jpg`],
},
};
}
// Static routes
const routes = [
{
url: `${baseUrl}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/login`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/signup`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/plans`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
];
10.2 Accessibility
Install accessibility testing tools:
npm install -D axe-core @axe-core/react
expect.extend(toHaveNoViolations);
ReactDOM.unmountComponentAtNode(container);
container.remove();
}
expect.extend(toHaveNoViolations);
// vercel.json
{
"version": 2,
"buildCommand": "npm run build",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"NEXT_PUBLIC_APP_URL": "https://onlylinks.vercel.app"
},
"crons": [
{
"path": "/api/cron/subscriptions",
"schedule": "0 0 * * *"
},
{
"path": "/api/cron/sessions",
"schedule": "0 */12 * * *"
}
]
}
return NextResponse.json({
success: true,
processed: expiredSubscriptions.length,
});
} catch (error) {
console.error("Subscription cron error:", error);
return NextResponse.json(
{ error: "Failed to process subscriptions" },
{ status: 500 }
);
}
}
// src/app/api/cron/sessions/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sessions } from "@/lib/db/schema/sessions";
import { lte } from "drizzle-orm";
try {
// Get users from source database
const { rows: sourceUsers } = await sourcePool.query('SELECT * FROM
console.log(`Found ${sourceUsers.length} users to migrate`);
migrateUsers();
nical Implementation Guide
ructions for migrating the OnlyLinks PHP application to NextJS using Drizzle ORM. Each phase is broken down into specific im
rm/resolvers
Null().unique(),
55 }).notNull(),
100 }).unique(),
ers, ({ many }) => ({
h/providers/credentials";
password" }
.password) {
uth-options";
resolvers/zod";
st be at least 6 characters"),
: { errors } } = useForm({
Login to OnlyLinks</h1>
Enter your credentials to access your account</p>
d-50 p-4 text-sm text-red-700">
-red-600">{errors.email.message}</p>
-red-600">{errors.password.message}</p>
stify-between">
der-gray-300 text-blue-600 focus:ring-blue-500"
er rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none fo
st be at least 6 characters"),
ust be at least 3 characters"),
e } = body;
ery.users.findFirst({
db.query.users.findFirst({
ash(password, 10);
uth-options";
authOptions);
rd } = user;
authOptions);
rd } = updatedUser[0];
"/login", request.url));
mpare(password, user.password);
uccess: true });
dminToken, {
roduction",
uth-options";
authOptions);
mage/png", "image/gif"];
e.replace(/\s/g, "-")}`;
ublic", "uploads");
uth-options";
hboard/Sidebar";
hboard/Header";
ayout({ children }) {
uthOptions);
erflow-hidden">
useState(false);
nter space-x-4">
text-gray-500 hover:bg-gray-100">
rofileOpen)}
e-x-2 rounded-md p-2 text-gray-700 hover:bg-gray-100"
ont-medium md:block">
sm text-gray-700 hover:bg-gray-100"
sm text-gray-700 hover:bg-gray-100"
s, ({ one }) => ({
uth-options";
authOptions);
authOptions);
ery.links.findFirst({
? highestPositionLink.position + 1 : 0;
ult.source.index, 1);
reorderedItem);
index) => ({
lt.destination.index];
destination.index }),
er justify-center">
tify-between">
our Links</h1>
eDragEnd}>
rder p-4 ${
enter justify-between">
center space-x-2">
ext-sm text-gray-500">
nk.id, link.active)
6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:b
h(`/links/${link.id}`)}
p-2 text-gray-500 hover:bg-gray-100 hover:text-blue-600"
teLink(link.id)}
p-2 text-gray-500 hover:bg-gray-100 hover:text-red-600"
p, varchar, text } from 'drizzle-orm/pg-core';
b/schema/analytics";
hema/links";
m "@/lib/utils/geo";
yAndCity(ipAddress);
agent") || "",
ma/analytics";
utils/geo";
yAndCity(ipAddress);
agent") || "",
x-forwarded-for");
velopment
ce like ipinfo.io or maxmind
nfo.io/${ip}/json?token=${process.env.IPINFO_TOKEN}`);
authOptions);
hemes.findMany({
on.user.id))
authOptions);
mes).values({
uth-options";
(authOptions);
seInt(session.user.id)) {
authOptions);
n.user.id) && session.user.role !== "admin") {
e.description,
authOptions);
n.user.id) && session.user.role !== "admin") {
nv.STRIPE_SECRET_KEY, {
t API version
e: 2 }).notNull(),
notNull(), // monthly, yearly
ength: 255 }),
criptions', {
uth-options";
ubscriptions";
authOptions);
eckout.sessions.create({
LIC_APP_URL}/dashboard?checkout=success`,
C_APP_URL}/plans?checkout=canceled`,
tSession.url });
hema/subscriptions";
cation failed:", error);
bscription);
ckout session");
bscriptions.retrieve(
scription.current_period_start * 1000),
cription.current_period_end * 1000),
criptions.findFirst({
onId, subscriptionId),
subscriptionId);
bscriptions.retrieve(
bscription.current_period_start * 1000),
scription.current_period_end * 1000),
onId, subscriptionId));
criptions.findFirst({
onId, subscriptionId),
subscriptionId);
onId, subscriptionId));
d(subscription) {
bscriptions.findFirst({
onId, subscription.id),
subscription.id);
tion.current_period_start * 1000),
on.current_period_end * 1000),
_at_period_end,
onId, subscription.id));
(subscription) {
bscriptions.findFirst({
onId, subscription.id),
subscription.id);
onId, subscription.id));
eriod (3 days)
uth-options";
ts/admin/AdminSidebar";
ts/admin/AdminHeader";
t({ children }) {
uthOptions);
erflow-hidden">
useState(false);
nter space-x-4">
text-gray-500 hover:bg-gray-100">
rofileOpen)}
e-x-2 rounded-md p-2 text-gray-700 hover:bg-gray-100"
verflow-hidden rounded-full bg-gray-200">
ont-medium md:block">
sm text-gray-700 hover:bg-gray-100"
sm text-gray-700 hover:bg-gray-100"
authOptions);
("page") || "1");
"limit") || "10");
y") || "createdAt";
rtOrder") || "desc";
+ '%'} OR ${users.email} ILIKE ${'%' + search + '%'} OR ${users.username} ILIKE ${'%' + search + '%'}`
ql`COUNT(*)` }).from(users);
+ '%'} OR ${users.email} ILIKE ${'%' + search + '%'} OR ${users.username} ILIKE ${'%' + search + '%'}`
hema/subscriptions";
uth-options";
authOptions);
rd } = user;
scriptions.findFirst({
nt(params.id)),
nt: sql`COUNT(*)` })
authOptions);
rs.findFirst({
ash(updateData.password, 10);
rd } = updatedUser[0];
authOptions);
rs.findFirst({
@testing-library/jest-dom jest-environment-jsdom
mJestConfig);
brary/react';
eInTheDocument();
ary Button</Button>);
y Button');
lick me</Button>);
n</Button>);
on')).toBeDisabled();
ValueOnce(null); // No existing user
eBeenCalledTimes(2); // Check email and username
With('password123', 10);
getData());
ValueOnce({
C_APP_URL;
image.jpg`,
Links - Manage Your Links',
manage your links in one place with OnlyLinks.',
og-image.jpg`],
C_APP_URL;
.findMany({
APP_URL}/sitemap.xml`,
container);
hema/subscriptions";
rized access
("authorization");
v.CRON_SECRET}`) {
authorized" }, { status: 401 });
uery.subscriptions.findMany({
period (3 days)
acePeriod) {
a/sessions";
rized access
("authorization");
v.CRON_SECRET}`) {
authorized" }, { status: 401 });
DATABASE_URL,
getPool.query(
y exists, skipping`);
uccessfully');
is broken down into specific implementation steps with code examples.
none focus:ring-blue-500"
none focus:ring-blue-500"
blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked
fter:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:
after:border-white peer-focus:outline-none"></div>