tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
| [role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
--- File: components/ui/tabs.tsx ---
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({ className, ...props }: React.ComponentProps) {
return
}
function TabsList({ className, ...props }: React.ComponentProps) {
return
}
function TabsTrigger({ className, ...props }: React.ComponentProps) {
return (
)
}
function TabsContent({ className, ...props }: React.ComponentProps) {
return
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
--- File: components/ui/textarea.tsx ---
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
)
}
export { Textarea }
--- File: components.json ---
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
--- File: convex/README.md ---
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.
--- File: convex/mailingList.ts ---
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
import {
ConvexResponse,
getCurrentTimestamp,
getBaseFields,
handleError,
handleSuccess,
} from "./utils";
// Types
export type MailingListSubscription = Doc<"mailing_list_subscriptions">;
// Queries
export const getSubscriptions = query({
handler: async (ctx) => {
try {
return await ctx.db
.query("mailing_list_subscriptions")
.order("desc")
.collect();
} catch (error) {
console.error("Error getting subscriptions:", error);
throw error;
}
},
});
export const getSubscriptionByEmail = query({
args: { email: v.string() },
handler: async (ctx, args) => {
try {
return await ctx.db
.query("mailing_list_subscriptions")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
} catch (error) {
console.error("Error getting subscription by email:", error);
throw error;
}
},
});
// Mutations
export const subscribe = mutation({
args: {
userId: v.string(),
email: v.string(),
name: v.optional(v.string()),
preferences: v.object({
marketing: v.boolean(),
updates: v.boolean(),
}),
},
handler: async (ctx, args) => {
try {
// Check if email already exists
const existing = await ctx.db
.query("mailing_list_subscriptions")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (existing) {
throw new Error("Email already subscribed");
}
const now = Date.now();
const subscription = {
userId: args.userId,
email: args.email,
name: args.name,
preferences: args.preferences,
subscribedAt: now,
unsubscribedAt: null,
createdAt: now,
updatedAt: now,
};
const id = await ctx.db.insert("mailing_list_subscriptions", subscription);
return await ctx.db.get(id);
} catch (error) {
console.error("Error subscribing:", error);
throw error;
}
},
});
export const unsubscribe = mutation({
args: {
email: v.string(),
},
handler: async (ctx, args) => {
try {
console.log('[unsubscribe] Attempting to unsubscribe email:', args.email);
const subscription = await ctx.db
.query("mailing_list_subscriptions")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
console.log('[unsubscribe] Subscription found:', subscription);
if (!subscription) {
throw new Error("Subscription not found");
}
const now = Date.now();
await ctx.db.patch(subscription._id, {
unsubscribedAt: now,
updatedAt: now,
});
return true;
} catch (error) {
console.error("Error unsubscribing:", error);
throw error;
}
},
});
export const deleteSubscription = mutation({
args: { id: v.id("mailing_list_subscriptions") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
export const updatePreferences = mutation({
args: {
userId: v.string(),
preferences: v.object({
marketing: v.boolean(),
updates: v.boolean(),
}),
},
handler: async (ctx, args) => {
// Find the active subscription for this user
const subscription = await ctx.db
.query("mailing_list_subscriptions")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.first();
if (!subscription || subscription.unsubscribedAt !== null) {
throw new Error("Active subscription not found");
}
await ctx.db.patch(subscription._id, {
preferences: args.preferences,
updatedAt: Date.now(),
});
return true;
},
});
--- File: convex/schema.ts ---
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
// Define the base fields that all documents will have
const baseFields = {
createdAt: v.number(), // Unix timestamp
updatedAt: v.number(), // Unix timestamp
};
// Define your schema
export default defineSchema({
visits: defineTable({
path: v.string(),
userId: v.union(v.string(), v.null()),
metadata: v.any(),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_user", ["userId"])
.index("by_path", ["path"])
.index("by_created", ["createdAt"]),
mailing_list_subscriptions: defineTable({
userId: v.string(),
email: v.string(),
name: v.optional(v.string()),
preferences: v.object({
marketing: v.boolean(),
updates: v.boolean(),
}),
subscribedAt: v.number(),
unsubscribedAt: v.union(v.number(), v.null()),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_email", ["email"])
.index("by_user", ["userId"])
.index("by_subscribed", ["subscribedAt"]),
});
--- File: convex/testing.ts ---
import { v } from "convex/values"
import { mutation, query } from "./_generated/server"
// Define allowed table names for type safety
const ALLOWED_TABLES = ["visits", "mailing_list_subscriptions"] as const
type TableName = typeof ALLOWED_TABLES[number]
function isTestOrDevEnv() {
return process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development";
}
/**
* Delete all documents from a table
* This mutation should only be available in test environments
*/
export const deleteAll = mutation({
args: { tableName: v.union(v.literal("visits"), v.literal("mailing_list_subscriptions")) },
handler: async (ctx, args) => {
if (!isTestOrDevEnv()) {
throw new Error("This operation is only allowed in test or development environments");
}
const documents = await ctx.db.query(args.tableName).collect()
for (const doc of documents) {
await ctx.db.delete(doc._id)
}
return { success: true }
},
})
/**
* Count documents in a table
* This query should only be available in test environments
*/
export const countDocuments = query({
args: { tableName: v.union(v.literal("visits"), v.literal("mailing_list_subscriptions")) },
handler: async (ctx, args) => {
if (!isTestOrDevEnv()) {
throw new Error("This operation is only allowed in test or development environments");
}
const documents = await ctx.db.query(args.tableName).collect()
return documents.length
},
})
/**
* Seed test data for visits and mailing_list_subscriptions
* This mutation should only be available in test environments
*/
export const seedTestData = mutation({
args: {},
handler: async (ctx) => {
if (!isTestOrDevEnv()) {
throw new Error("This operation is only allowed in test or development environments");
}
// Seed visits
await ctx.db.insert("visits", {
path: "/test-path",
userId: "test-user",
metadata: { test: true },
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Seed mailing list subscriptions
await ctx.db.insert("mailing_list_subscriptions", {
userId: "test-user",
email: "test@example.com",
name: "Test User",
preferences: { marketing: true, updates: true },
subscribedAt: Date.now(),
unsubscribedAt: null,
createdAt: Date.now(),
updatedAt: Date.now(),
});
return { success: true };
},
});
--- File: convex/tsconfig.json ---
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}
--- File: convex/utils.ts ---
import { v } from "convex/values";
import { MutationCtx, QueryCtx } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
// Type for successful responses
type SuccessResponse = {
success: true;
data?: T;
id?: Id;
};
// Type for error responses
type ErrorResponse = {
success: false;
error: string;
};
// Combined response type
export type ConvexResponse = SuccessResponse | ErrorResponse;
// Helper to get current timestamp
export const getCurrentTimestamp = () => Date.now();
// Helper to create base fields for new documents
export const getBaseFields = () => ({
createdAt: getCurrentTimestamp(),
updatedAt: getCurrentTimestamp(),
});
// Helper to update timestamp
export const getUpdateFields = () => ({
updatedAt: getCurrentTimestamp(),
});
// Helper for error handling
export const handleError = (error: unknown): ErrorResponse => ({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
});
// Helper for success response
export const handleSuccess = (data?: T, id?: Id): SuccessResponse => ({
success: true,
...(data && { data }),
...(id && { id }),
});
// Validation helper
export const validateId = (id: Id) => {
if (!id) throw new Error("Invalid ID");
return id;
};
// Query helper for pagination
export type PaginationOptions = {
limit?: number;
cursor?: string;
};
// Helper for handling pagination in queries
export const handlePagination = (
ctx: QueryCtx,
query: any,
options?: PaginationOptions
) => {
if (options?.limit) {
query = query.take(options.limit);
}
if (options?.cursor) {
query = query.continuePaginationFrom(options.cursor);
}
return query;
};
--- File: convex/visits.ts ---
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
// Schema is defined in schema.ts
export type Visit = {
path: string;
userId: string | null;
metadata: Record;
createdAt: number;
updatedAt: number;
};
export const recordVisit = mutation({
args: {
path: v.string(),
userId: v.union(v.string(), v.null()),
metadata: v.any(),
},
handler: async (ctx, args) => {
const now = Date.now();
return await ctx.db.insert("visits", {
path: args.path,
userId: args.userId,
metadata: args.metadata,
createdAt: now,
updatedAt: now,
});
},
});
export const getVisits = query({
args: {
userId: v.optional(v.union(v.string(), v.null())),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const query = args.userId !== undefined
? await ctx.db
.query("visits")
.withIndex("by_user", (q) => q.eq("userId", args.userId as string | null))
.collect()
: await ctx.db
.query("visits")
.withIndex("by_created")
.collect();
return args.limit !== undefined ? query.slice(0, args.limit) : query;
},
});
export const getVisitsByPath = query({
args: {
path: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const results = await ctx.db
.query("visits")
.withIndex("by_path", (q) => q.eq("path", args.path))
.collect();
return args.limit !== undefined ? results.slice(0, args.limit) : results;
},
});
export const deleteVisit = mutation({
args: { id: v.id("visits") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
--- File: eslint.config.mjs ---
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
".next/**/*",
"test-results/**/*",
"tests-examples/**/*",
"tests/**/*",
"coverage/**/*",
"dist/**/*",
"build/**/*",
"**/node_modules/**/*",
"**/*.min.js",
"**/*.bundle.js"
],
rules: {
// Disable noisy rules
"@typescript-eslint/no-this-alias": "off",
// Enable max-lines rule
"max-lines": ["error", {
max: 500,
skipBlankLines: true,
skipComments: true
}]
}
}
];
export default eslintConfig;
--- File: lib/auth-utils.ts ---
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export function isClerkConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY);
}
export function isAdminConfigured(): boolean {
return Boolean(process.env.ADMIN_USER_IDS);
}
export function isDev(): boolean {
return process.env.NODE_ENV === "development";
}
/**
* Checks if a user is an admin
* @param userId - Optional user ID to check. If not provided, checks the current user.
* @returns Promise - True if the user is an admin, false otherwise
*/
export async function isAdmin(userId?: string): Promise {
try {
// If userId is not provided, get the current user's ID
let userIdToCheck = userId;
if (!userIdToCheck) {
const { userId: currentUserId } = await auth();
userIdToCheck = currentUserId || undefined;
}
// If no user is authenticated, they're not an admin
if (!userIdToCheck) {
return false;
}
// Get the list of admin user IDs from environment variables
const adminUserIds = process.env.ADMIN_USER_IDS?.split(",") || [];
// Check if the user's ID is in the admin list
return adminUserIds.includes(userIdToCheck);
} catch (error) {
console.error("Error checking admin status:", error);
return false;
}
}
/**
* Checks if the provided user ID is an admin
* @param userId - The user ID to check
* @returns boolean - True if the user is an admin, false otherwise
*/
export function isUserAdmin(userId: string | null): boolean {
if (!userId) {
return false;
}
// Get the list of admin user IDs from environment variables
const adminUserIds = process.env.ADMIN_USER_IDS?.split(",") || [];
// Check if the provided user ID is in the admin list
return adminUserIds.includes(userId);
}
export type AdminCheckResult = {
isAdmin: boolean
userId: string | null
requiresSetup: boolean
}
export type AuthCheckResult = {
isAuthenticated: boolean
userId: string | null
}
/**
* Checks if the user is authenticated
* @returns The authentication status and user ID
*/
export async function checkAuth(): Promise {
// Get the user's ID from Clerk
const { userId } = await auth()
return {
isAuthenticated: !!userId,
userId
}
}
/**
* Checks if the current user is an admin and redirects if not
* @returns The user ID if the user is an admin
*/
export async function requireAdmin(): Promise {
// Get the user's ID from Clerk
const { userId } = await auth()
// If not authenticated, redirect to sign-in in production, return status in development
if (!userId) {
if (process.env.NODE_ENV === "production") {
redirect("/sign-in")
}
return { isAdmin: false, userId: null, requiresSetup: false }
}
// Get the list of admin user IDs
const adminUserIds = process.env.ADMIN_USER_IDS?.split(",") || []
// In development, if no admin IDs are configured, return a special status
if (process.env.NODE_ENV === "development" && (!adminUserIds.length || adminUserIds[0] === "")) {
return { isAdmin: false, userId, requiresSetup: true }
}
// If the user is not an admin, redirect in production, return status in development
if (!adminUserIds.includes(userId)) {
if (process.env.NODE_ENV === "production") {
redirect("/")
}
return { isAdmin: false, userId, requiresSetup: false }
}
return { isAdmin: true, userId, requiresSetup: false }
}
--- File: lib/aws.ts ---
import { S3Client } from "@aws-sdk/client-s3"
// Environment variables
const awsConfig = {
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_KEY,
secretAccessKey: process.env.AWS_SECRET,
bucketPublic: process.env.AWS_BUCKET_PUBLIC,
cloudfrontDomain: process.env.CLOUDFRONT_DOMAIN,
}
export const AWS_BUCKET_PUBLIC = process.env.AWS_BUCKET_PUBLIC
// Log missing variables in development only
if (process.env.NODE_ENV === 'development') {
const missingVars = Object.entries(awsConfig)
.filter(([, value]) => !value)
.map(([key]) => key)
if (missingVars.length > 0) {
console.warn('Missing AWS configuration variables:', missingVars)
}
}
// Function to check if AWS is configured
export function isAwsConfigured(): boolean {
return Boolean(
awsConfig.region &&
awsConfig.accessKeyId &&
awsConfig.secretAccessKey &&
awsConfig.bucketPublic
)
}
// Create an S3 client if configured
export const s3Client = isAwsConfigured()
? new S3Client({
region: awsConfig.region!,
credentials: {
accessKeyId: awsConfig.accessKeyId!,
secretAccessKey: awsConfig.secretAccessKey!,
},
})
: null
// Function to get asset URL
export function getAssetUrl(key: string, withTimestamp = false): string | null {
if (!isAwsConfigured()) {
return null
}
const timestamp = withTimestamp ? Date.now() : null
const keyWithTimestamp = timestamp
? (key.includes('?') ? `${key}&t=${timestamp}` : `${key}?t=${timestamp}`)
: key
if (awsConfig.cloudfrontDomain) {
return `https://${awsConfig.cloudfrontDomain}/${keyWithTimestamp}`
}
// Fallback to direct S3 URL
return `https://${awsConfig.bucketPublic}.s3.${awsConfig.region}.amazonaws.com/${keyWithTimestamp}`
}
// Function to check AWS connection
export async function checkAwsConnection(): Promise<{
success: boolean
message: string
details?: {
error?: unknown
}
}> {
if (!isAwsConfigured()) {
return {
success: false,
message: 'AWS is not configured',
details: {
error: 'Missing required environment variables'
}
}
}
try {
// Try to make a simple API call to verify connection
await s3Client!.config.credentials()
return {
success: true,
message: 'Successfully connected to AWS'
}
} catch (err) {
console.error('Unexpected error checking AWS connection:', err)
return {
success: false,
message: `Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
details: { error: err }
}
}
}
--- File: lib/clerk.ts ---
import { createClerkClient } from '@clerk/backend'
if (!process.env.CLERK_SECRET_KEY) {
throw new Error('CLERK_SECRET_KEY is not defined')
}
export const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY
})
// Re-export commonly used types
export type { User } from '@clerk/backend'
--- File: lib/config/navigation.ts ---
export const navItems = [
{
title: "About",
href: "/about",
},
{
title: "Contact",
href: "/contact",
},
] as const
export type NavItem = (typeof navItems)[number]
--- File: lib/config.ts ---
export const siteConfig = {
title: "Vibecode Party Starter",
description: "The full stack Next.js starter project for vibe coding.",
shortDescription: "The full stack Next.js starter project for vibe coding.",
url: "https://starter.vibecode.party",
shareImage: "https://starter.vibecode.party/screenshot.png",
x: "johnpolacek",
github: "https://github.com/johnpolacek/vibecode.party.starter",
logo: ""
} as const
export type SiteConfig = {
title: string
description: string
shortDescription: string
url: string
shareImage: string
x: string
github: string
logo: string
}
--- File: lib/convex/server.ts ---
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
export const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export { api };
--- File: lib/email-utils.ts ---
import { createHash } from "crypto"
/**
* Generates a secure token for email unsubscribe links
* @param email The email address to generate a token for
* @returns A secure hash that can be used in unsubscribe links
*/
export function generateUnsubscribeToken(email: string): string {
if (!process.env.UNSUBSCRIBE_SECRET) {
throw new Error("UNSUBSCRIBE_SECRET environment variable is not set")
}
// Combine email with secret and current year-month
// This makes the token valid for one month
const date = new Date()
const yearMonth = `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, "0")}`
const data = `${email}:${yearMonth}:${process.env.UNSUBSCRIBE_SECRET}`
// Create a SHA-256 hash
return createHash("sha256").update(data).digest("hex")
}
/**
* Verifies if an unsubscribe token is valid for a given email
* @param email The email address to verify
* @param token The token to verify
* @returns boolean indicating if the token is valid
*/
export function verifyUnsubscribeToken(email: string, token: string): boolean {
// Generate a token for the current month and previous month
// This gives users a grace period when links cross month boundaries
const currentToken = generateUnsubscribeToken(email)
// Generate token for previous month
const lastMonth = new Date()
lastMonth.setUTCMonth(lastMonth.getUTCMonth() - 1)
const yearMonth = `${lastMonth.getUTCFullYear()}-${(lastMonth.getUTCMonth() + 1).toString().padStart(2, "0")}`
const lastMonthData = `${email}:${yearMonth}:${process.env.UNSUBSCRIBE_SECRET}`
const previousToken = createHash("sha256").update(lastMonthData).digest("hex")
// Check if the token matches either current or previous month
return token === currentToken || token === previousToken
}
/**
* Encodes an email address for use in URLs
* @param email The email address to encode
* @returns URL-safe base64 encoded email
*/
export function encodeEmail(email: string): string {
return Buffer.from(email).toString("base64url")
}
/**
* Decodes an encoded email address from a URL
* @param encoded The encoded email to decode
* @returns The original email address
*/
export function decodeEmail(encoded: string): string {
return Buffer.from(encoded, "base64url").toString("utf-8")
}
--- File: lib/generated/routes.ts ---
// This file is auto-generated. DO NOT EDIT IT MANUALLY.
// It is used to generate the validRoutes for tracking user visits.
// To regenerate, run: pnpm generate:routes
export const validRoutes = new Set([
'/',
'about',
'account/*',
'admin',
'admin/analytics',
'admin/mailing-list',
'admin/users',
'contact',
'demo/ai',
'demo/upload',
'get-started',
'mailing-list',
'pay',
'privacy',
'roadmap',
'terms',
'unsubscribe'
])
--- File: lib/s3-utils.ts ---
import { PutObjectCommand } from "@aws-sdk/client-s3"
import { s3Client, isAwsConfigured, getAssetUrl } from "@/lib/aws"
/**
* Upload a file to S3
* @param file The file to upload
* @param key The S3 object key (path + filename)
* @param contentType Optional content type
* @returns The URL of the uploaded file through CloudFront
*/
export async function uploadFileToS3(
file: File | Blob,
key: string,
contentType?: string
): Promise {
if (!isAwsConfigured() || !s3Client) {
throw new Error("AWS S3 is not configured")
}
// Convert file to buffer
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Set up the upload parameters
const params = {
Bucket: process.env.AWS_BUCKET_PUBLIC,
Key: key,
Body: buffer,
ContentType: contentType || file.type,
CacheControl: key.includes('hackathon/covers') ? 'no-cache' : "public, max-age=31536000",
}
// Upload to S3
await s3Client.send(new PutObjectCommand(params))
// Get the URL (with timestamp for cache busting)
const url = getAssetUrl(key, true)
if (!url) {
throw new Error("Failed to generate asset URL")
}
return url
}
export const transferImageToS3 = async (imageUrl: string, key: string): Promise => {
if (!isAwsConfigured() || !s3Client) {
throw new Error("AWS S3 is not configured")
}
try {
// Download the image from the URL
const response = await fetch(imageUrl)
const arrayBuffer = await response.arrayBuffer()
// Prepare the parameters for uploading to S3
const params = {
Bucket: process.env.AWS_BUCKET_PUBLIC,
Key: key,
Body: Buffer.from(arrayBuffer),
ContentType: response.headers.get("content-type") || "application/octet-stream",
ContentLength: parseInt(response.headers.get("content-length") || "0", 10),
}
// Upload the image to the S3 bucket
const putCommand = new PutObjectCommand(params)
await s3Client.send(putCommand)
// Get the URL
const publicUrl = getAssetUrl(key)
if (!publicUrl) {
throw new Error("Failed to generate asset URL")
}
return publicUrl
} catch (error) {
throw new Error("Error uploading image to S3: " + error)
}
}
--- File: lib/send-email.ts ---
import sgMail from "@sendgrid/mail"
if (!process.env.SENDGRID_API_KEY) {
throw new Error("Missing SENDGRID_API_KEY environment variable")
}
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
interface SendEmailOptions {
to: string
subject: string
text: string
html?: string
}
export async function sendEmail({ to, subject, text, html }: SendEmailOptions) {
const msg = {
to,
from: process.env.SENDGRID_SENDER || "noreply@example.com",
subject,
text,
html: html || text,
}
try {
await sgMail.send(msg)
} catch (error) {
console.error("Error sending email:", error)
throw error
}
}
--- File: lib/services/mailing-list.ts ---
import { convex, api } from "@/lib/convex/server";
import { Doc } from "@/convex/_generated/dataModel";
export type MailingListSubscription = Doc<"mailing_list_subscriptions">;
/**
* Retrieves all mailing list subscriptions from Convex
*/
export async function getMailingListSubscriptions(): Promise {
try {
return await convex.query(api.mailingList.getSubscriptions);
} catch (error) {
console.error('Error getting mailing list subscriptions:', error);
return [];
}
}
/**
* Adds a new email subscription to the mailing list
*/
export async function addMailingListSubscription(data: {
userId: string,
email: string,
name?: string | undefined,
preferences: {
marketing: boolean,
updates: boolean
}
}): Promise {
try {
return await convex.mutation(api.mailingList.subscribe, data);
} catch (error) {
console.error('Error adding mailing list subscription:', error);
return null;
}
}
/**
* Removes an email subscription from the mailing list
*/
export async function removeMailingListSubscription(email: string): Promise {
try {
return await convex.mutation(api.mailingList.unsubscribe, { email });
} catch (error) {
console.error('Error removing mailing list subscription:', error);
return false;
}
}
/**
* Updates preferences for a user's mailing list subscription
*/
export async function updateMailingListPreferences(userId: string, preferences: { marketing: boolean, updates: boolean }): Promise {
try {
return await convex.mutation(api.mailingList.updatePreferences, { userId, preferences });
} catch (error) {
console.error('Error updating mailing list preferences:', error);
return false;
}
}
--- File: lib/services/visits.ts ---
import { convex, api } from "@/lib/convex/server";
/**
* Records a new visit to a page
*/
export async function recordVisit(data: {
path: string;
userId: string | null;
metadata: Record;
}) {
return await convex.mutation(api.visits.recordVisit, data);
}
/**
* Gets visits for a specific user
*/
export async function getVisitsByUser(userId: string | null, limit?: number) {
return await convex.query(api.visits.getVisits, { userId, limit });
}
/**
* Gets visits for a specific path
*/
export async function getVisitsByPath(path: string, limit?: number) {
return await convex.query(api.visits.getVisitsByPath, { path, limit });
}
/**
* Gets all visits
*/
export async function getAllVisits(limit?: number) {
return await convex.query(api.visits.getVisits, { limit });
}
--- File: lib/stripe.ts ---
import Stripe from "stripe"
// Environment variables
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
// Log missing variables in development only
if (process.env.NODE_ENV === 'development' && !stripeSecretKey) {
console.warn('Missing Stripe secret key environment variable')
}
// Function to check if Stripe is configured
export function isStripeConfigured(): boolean {
return Boolean(stripeSecretKey)
}
// Create a Stripe instance if configured
export const stripe = stripeSecretKey
? new Stripe(stripeSecretKey, {
apiVersion: "2025-04-30.basil",
})
: null
// Function to check Stripe connection
export async function checkStripeConnection(): Promise<{
success: boolean
message: string
details?: {
error?: unknown
}
}> {
if (!isStripeConfigured()) {
return {
success: false,
message: 'Stripe is not configured',
details: {
error: 'Missing required environment variables'
}
}
}
try {
// Try to make a simple API call to verify connection
await stripe?.balance.retrieve()
return {
success: true,
message: 'Successfully connected to Stripe'
}
} catch (err) {
console.error('Unexpected error checking Stripe connection:', err)
return {
success: false,
message: `Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
details: { error: err }
}
}
}
--- File: lib/upload-utils.ts ---
"use client"
import { useState } from "react"
interface UploadOptions {
folder?: string
maxSize?: number // in bytes
allowedTypes?: string[]
}
const defaultOptions: UploadOptions = {
folder: "uploads",
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"],
}
export async function uploadFile(file: File, options: UploadOptions = {}): Promise {
const { folder = "uploads", maxSize, allowedTypes } = { ...defaultOptions, ...options }
if (!file) {
throw new Error("No file provided")
}
// Validate file type if allowedTypes is provided
if (allowedTypes && !allowedTypes.includes(file.type)) {
throw new Error(`File type not allowed. Please upload one of: ${allowedTypes.join(", ")}`)
}
// Validate file size if maxSize is provided
if (maxSize && file.size > maxSize) {
throw new Error(`File size exceeds ${Math.round(maxSize / (1024 * 1024))}MB limit`)
}
const formData = new FormData()
formData.append("file", file)
formData.append("folder", folder)
try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || "Failed to upload file")
}
const data = await response.json()
return data.url
} catch (error) {
console.error("Error uploading file:", error)
throw error
}
}
export function useFileUpload(options: UploadOptions = {}) {
const [isUploading, setIsUploading] = useState(false)
const upload = async (file: File): Promise => {
setIsUploading(true)
try {
const url = await uploadFile(file, options)
return url
} finally {
setIsUploading(false)
}
}
return {
upload,
isUploading,
}
}
--- File: lib/utils/case-transforms.ts ---
import { camelCase, snakeCase } from 'lodash-es'
// Type transformations for TypeScript type system
type CamelToSnakeCase = S extends `${infer T}${infer U}`
? T extends Uppercase
? `_${Lowercase}${CamelToSnakeCase}`
: `${T}${CamelToSnakeCase}`
: S
type SnakeToCamelCase = S extends `${infer T}_${infer U}`
? `${T}${Capitalize>}`
: S
type TransformKeys string> = T extends object
? {
[K in keyof T as K extends string
? string extends K
? string
: Transform extends typeof snakeCase
? Uncapitalize>
: Uncapitalize>
: K]: T[K] extends object
? TransformKeys
: T[K]
}
: T
/**
* Transforms an object's keys from camelCase to snake_case recursively
* @deprecated Use toSnakeCase instead
*/
export function toDatabaseCase(obj: T): TransformKeys {
return toSnakeCase(obj)
}
/**
* Transforms an object's keys from snake_case to camelCase recursively
* @deprecated Use toCamelCase instead
*/
export function toClientCase(obj: T): TransformKeys {
return toCamelCase(obj)
}
/**
* Transforms an object's keys from camelCase to snake_case recursively
*/
export function toSnakeCase(obj: T): TransformKeys {
if (Array.isArray(obj)) {
return obj.map((item) =>
typeof item === 'object' ? toSnakeCase(item) : item
) as TransformKeys
}
if (obj === null || typeof obj !== 'object') {
return obj as TransformKeys
}
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
snakeCase(key),
typeof value === 'object' ? toSnakeCase(value) : value,
])
) as TransformKeys
}
/**
* Transforms an object's keys from snake_case to camelCase recursively
*/
export function toCamelCase(obj: T): TransformKeys {
if (Array.isArray(obj)) {
return obj.map((item) =>
typeof item === 'object' ? toCamelCase(item) : item
) as TransformKeys
}
if (obj === null || typeof obj !== 'object') {
return obj as TransformKeys
}
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
camelCase(key),
typeof value === 'object' ? toCamelCase(value) : value,
])
) as TransformKeys
}
/**
* Alias for toCamelCase, used for consistency with existing codebase
*/
export const deepToCamelCase = toCamelCase
/**
* Type helper to convert a type from camelCase to snake_case
* @example
* interface UserInput {
* firstName: string
* lastName: string
* }
*
* type DatabaseUser = ToSnakeCase
* // Result: { first_name: string, last_name: string }
*/
export type ToSnakeCase = TransformKeys
/**
* Type helper to convert a type from snake_case to camelCase
* @example
* interface DatabaseUser {
* first_name: string
* last_name: string
* }
*
* type ClientUser = ToCamelCase
* // Result: { firstName: string, lastName: string }
*/
export type ToCamelCase = TransformKeys
// Types for backward compatibility
export type Primitive = string | number | boolean | null | undefined
export type TransformableObject = { [key: string]: Transformable }
export type TransformableArray = Transformable[]
export type Transformable = Primitive | TransformableObject | TransformableArray
--- File: lib/utils.ts ---
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Format a date string to a more readable format
* @param dateString ISO date string
* @returns Formatted date string (e.g., "Jan 1, 2023")
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
export function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount)
}
export const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.substring(0, 2)
}
--- File: middleware.ts ---
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware()
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
}
--- File: next.config.ts ---
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'vibecodeparty-public.s3.us-east-1.amazonaws.com',
},
{
protocol: 'https',
hostname: 'vibecodeparty-public.s3.amazonaws.com',
},
{
protocol: 'https',
hostname: 'img.clerk.com',
},
{
protocol: 'https',
hostname: 'drgfjd45uzjza.cloudfront.net',
},
],
},
};
export default nextConfig;
--- File: package.json ---
{
"name": "vibecode-party-starter",
"version": "0.4.6",
"license": "MIT",
"description": "A Next.js starter project for vibecoding full stack web apps with auth, backend, payments, email, and more",
"keywords": [
"nextjs",
"template",
"starter",
"vibecoding"
],
"author": "John Polacek",
"scripts": {
"kill:ports": "kill-port 3000 4000 4400 4500 5001 8080 8085 9000 9099 9199 || true",
"dev": "pnpm kill:ports && concurrently \"pnpm convex:dev\" \"next dev -p ${PORT:-3000}\" --kill-others --names \"convex,next\" --prefix-colors \"yellow.bold,cyan.bold\"",
"convex:dev": "convex dev",
"convex:deploy": "convex deploy",
"build": "pnpm generate:routes && next build",
"postinstall": "npx convex codegen",
"start": "next start",
"lint": "next lint",
"boot:convex": "sh ./scripts/boot-convex.sh",
"boot:github": "brew install gh && gh auth login",
"boot:vercel": "pnpm i -g vercel",
"boot:repo": "chmod +x ./scripts/init-repo.sh && ./scripts/init-repo.sh",
"boot": "pnpm boot:github && pnpm boot:vercel && pnpm boot:convex && pnpm boot:repo",
"go": "pnpm generate:routes && git add . && aicommits && pnpm generate:llm && git add . && git commit -m 'update llm.txt' && git push origin main",
"test:run": "playwright test",
"test:wait": "wait-on tcp:3000 tcp:4000 && pnpm test:run",
"test": "pnpm build && concurrently \"pnpm dev\" \"pnpm test:wait\" --success first --kill-others --names \"dev,test\" --prefix-colors \"yellow.bold,cyan.bold\"",
"ship": "pnpm test && pnpm go",
"test:clean": "rm -rf ~/.cache/ms-playwright-tests test-results/ playwright-report/",
"pw": "playwright test --ui --debug",
"pw:headless": "playwright test",
"db:admin": "open https://dashboard.convex.dev",
"generate:routes": "tsx scripts/generate-routes.ts",
"generate:llm": "tsx scripts/bundle-code.ts . llm.txt",
"test:setup": "pnpm dev",
"test:full": "pnpm test:setup && pnpm test"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.16",
"@ai-sdk/react": "^1.2.9",
"@ai-sdk/replicate": "^0.2.7",
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@clerk/backend": "^1.29.1",
"@clerk/clerk-sdk-node": "^5.1.6",
"@clerk/nextjs": "^6.16.0",
"@heroicons/react": "^2.2.0",
"@octokit/rest": "^21.1.1",
"@radix-ui/react-accordion": "^1.2.7",
"@radix-ui/react-alert-dialog": "^1.1.10",
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.2.2",
"@radix-ui/react-collapsible": "^1.1.7",
"@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.10",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-scroll-area": "^1.2.5",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.8",
"@sendgrid/mail": "^8.1.5",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^6.1.0",
"@types/lodash-es": "^4.17.12",
"@types/react-google-recaptcha": "^2.1.9",
"ai": "^4.3.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.23.0",
"date-fns": "^4.1.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"ignore": "^7.0.3",
"lodash-es": "^4.17.21",
"lucide-react": "^0.501.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"playwright": "link:@clerk/testing/playwright",
"prettier": "^3.5.3",
"react": "^19.1.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.1.0",
"react-google-recaptcha": "^3.1.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"server": "link:@clerk/nextjs/server",
"slugify": "^1.6.6",
"sonner": "^2.0.3",
"stripe": "^17.7.0",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@clerk/testing": "^1.4.41",
"@eslint/eslintrc": "^3.3.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/uuid": "^10.0.0",
"concurrently": "^9.1.2",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^9.25.0",
"eslint-config-next": "15.3.1",
"kill-port": "^2.0.1",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"wait-on": "^8.0.3"
}
}
--- File: playwright.config.ts ---
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import dotenv from 'dotenv';
// Load environment variables in order of precedence (later files take precedence)
dotenv.config({ path: path.resolve(__dirname, '.env') });
dotenv.config({ path: path.resolve(__dirname, '.env.test') });
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel - except in UI mode */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI or in UI mode */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Since we're running the Next.js server separately, we don't need the webServer config */
// webServer: {
// command: 'pnpm dev',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// timeout: 120000,
// },
/* Global setup to run before all tests */
globalSetup: process.env.PLAYWRIGHT_UI_MODE ? undefined : './tests/global-setup.ts',
});
--- File: postcss.config.mjs ---
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
--- File: scripts/boot-convex.sh ---
#!/bin/bash
# Install Convex CLI globally
pnpm add -g convex@latest
# Check for CONVEX_DEPLOY_KEY in Convex production environment
DEPLOY_KEY=$(npx convex env --prod get CONVEX_DEPLOY_KEY 2>&1)
if echo "$DEPLOY_KEY" | grep -q 'not found'; then
echo "\n\033[0;31m✖ Environment variable 'CONVEX_DEPLOY_KEY' not found.\033[0m"
echo "\nTo deploy with Convex, you need to set your deploy key."
echo "1. Visit your Convex dashboard: https://dashboard.convex.dev"
echo "2. Go to your project settings and copy the Deploy Key."
echo "3. Add it to your environment variables as CONVEX_DEPLOY_KEY."
echo "\nExample (.env):\nCONVEX_DEPLOY_KEY=your-deploy-key-here\n"
# Try to get CONVEX_DEPLOY_KEY from local .env
if [ -f .env ]; then
LOCAL_KEY=$(grep '^CONVEX_DEPLOY_KEY=' .env | cut -d '=' -f2-)
if [ -n "$LOCAL_KEY" ]; then
echo "\033[0;34mFound CONVEX_DEPLOY_KEY in local .env. Attempting to add to Vercel production env...\033[0m"
TMPFILE=$(mktemp)
echo "$LOCAL_KEY" > "$TMPFILE"
if vercel env add CONVEX_DEPLOY_KEY production < "$TMPFILE"; then
echo "\033[0;32m✓ Successfully added CONVEX_DEPLOY_KEY to Vercel production environment.\033[0m"
else
echo "\033[0;31m✖ Failed to add CONVEX_DEPLOY_KEY to Vercel. Please add it manually.\033[0m"
fi
rm "$TMPFILE"
fi
fi
else
echo "\033[0;32m✓ CONVEX_DEPLOY_KEY found in Convex production environment.\033[0m"
fi
--- File: scripts/bundle-code.ts ---
#!/usr/bin/env node
import fs from 'fs/promises';
import path from 'path';
import ignore from 'ignore';
import type { Dirent } from 'fs'; // Import Dirent type
// --- Configuration ---
// Directories to exclude entirely
const EXCLUDED_DIRS: Set = new Set([
'node_modules',
'.git',
'.next',
'dist',
'build',
'out',
'coverage',
'.vscode',
'.idea',
'public', // Often contains large assets, adjust if needed
// Add any other directories you want to skip
]);
// Specific files or patterns to exclude
const EXCLUDED_FILES_PATTERNS: string[] = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'.DS_Store',
// Add any specific files or patterns
];
// File extensions or specific filenames to include (add more as needed)
const INCLUDED_EXTENSIONS: Set = new Set([
// Extensions
'.js',
'.jsx',
'.ts',
'.tsx',
'.mjs',
'.cjs',
'.css',
'.scss',
'.sass',
'.less',
'.html',
'.md',
'.json',
'.yaml',
'.yml',
'.sh',
'.env',
'.env.local',
'.env.development',
'.env.production',
'.env.example', // Often useful for context
'.gitignore', // Useful for context
'.npmrc',
// Specific filenames (often config files)
'next.config.js',
'next.config.mjs',
'postcss.config.js',
'tailwind.config.js',
'tailwind.config.ts',
'tsconfig.json',
'jsconfig.json',
'.eslintrc.json',
'.prettierrc',
'Dockerfile',
// Add specific filenames relevant to your project
]);
// Initialize gitignore
let ig = ignore();
async function loadGitignore(projectRoot: string): Promise {
try {
const gitignorePath = path.join(projectRoot, '.gitignore');
const gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
ig = ignore().add(gitignoreContent);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
console.log('No .gitignore file found, continuing without it');
} else if (err instanceof Error) {
console.error('Error reading .gitignore:', err.message);
}
}
}
// --- Helper Functions ---
function isExcluded(entryPath: string, entryName: string, isDirectory: boolean): boolean {
// Check gitignore patterns first
const relativePath = entryPath.replace(/\\/g, '/'); // Normalize path separators
if (ig.ignores(relativePath)) {
return true;
}
// Then check our manual exclusions
if (isDirectory && EXCLUDED_DIRS.has(entryName)) {
return true;
}
if (!isDirectory && EXCLUDED_FILES_PATTERNS.some(pattern => entryName === pattern)) {
// Add more complex pattern matching here if needed (e.g., regex)
return true;
}
// Check if the path contains an excluded directory component
const pathParts = entryPath.split(path.sep);
if (pathParts.some(part => EXCLUDED_DIRS.has(part))) {
return true;
}
return false;
}
function isIncluded(entryName: string): boolean {
const ext = path.extname(entryName).toLowerCase();
// Check by specific name first, then by extension
return INCLUDED_EXTENSIONS.has(entryName) || (ext !== '' && INCLUDED_EXTENSIONS.has(ext));
}
async function walkDir(dir: string, projectRoot: string, allContents: string[]): Promise {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: unknown) {
if (err instanceof Error) {
console.error(`Error reading directory ${dir}: ${err.message}`);
}
return; // Skip directories we can't read
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(projectRoot, fullPath);
if (isExcluded(relativePath, entry.name, entry.isDirectory())) {
console.log(`(-) Excluding: ${relativePath}`);
continue;
}
if (entry.isDirectory()) {
await walkDir(fullPath, projectRoot, allContents);
} else if (entry.isFile() && isIncluded(entry.name)) {
try {
console.log(`(+) Including: ${relativePath}`);
const content = await fs.readFile(fullPath, 'utf-8');
// Normalize line endings to prevent excessive diffs if files have mixed endings
const normalizedContent = content.replace(/\r\n/g, '\n');
allContents.push(`--- File: ${relativePath} ---\n\n${normalizedContent}\n\n`);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(`Error reading file ${fullPath}: ${err.message}`);
allContents.push(`--- File: ${relativePath} ---\n\n!!! Error reading file: ${err.message} !!!\n\n`);
}
}
} else {
// Optional: Log files that are neither excluded nor included
// console.log(`(?) Skipping (not included): ${relativePath}`);
}
}
}
// --- Main Execution ---
async function main(): Promise {
// Basic argument parsing: tsx scripts/bundle-code.ts [targetDir] [outputFile]
const args: string[] = process.argv.slice(2);
const targetDirArg: string | undefined = args[0];
const outputFileArg: string | undefined = args[1];
const targetDir: string = path.resolve(targetDirArg || '.'); // Default to current directory
const outputFilePath: string | null = outputFileArg ? path.resolve(outputFileArg) : null; // Default to console output
// Load .gitignore before processing
await loadGitignore(targetDir);
console.log(`Scanning directory: ${targetDir}`);
if (outputFilePath) {
console.log(`Output will be written to: ${outputFilePath}`);
} else {
console.log(`Output will be printed to console.`);
}
const allContents: string[] = [];
try {
// Check if target directory exists
const stats = await fs.stat(targetDir);
if (!stats.isDirectory()) {
throw new Error(`Target path is not a directory: ${targetDir}`);
}
await walkDir(targetDir, targetDir, allContents);
const combinedOutput: string = allContents.join('');
if (outputFilePath) {
await fs.writeFile(outputFilePath, combinedOutput);
console.log(`\n✅ Successfully wrote bundled code to ${outputFilePath}`);
} else {
console.log("\n--- BUNDLED CODE OUTPUT ---");
console.log(combinedOutput);
console.log("--- END BUNDLED CODE OUTPUT ---");
console.log(`\n✅ Successfully generated bundled code.`);
}
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
console.error(`\n❌ Error: Target directory not found: ${targetDir}`);
} else if (err instanceof Error) {
console.error(`\n❌ An error occurred: ${err.message}`);
console.error(err.stack); // Print stack trace for debugging
}
process.exit(1); // Exit with error code
}
}
main();
--- File: scripts/generate-routes.ts ---
import fs from 'fs'
import path from 'path'
// Function to get all route paths from the app directory
function getRoutePaths(dir: string, basePath: string = ''): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true })
const paths: string[] = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const routePath = path.join(basePath, entry.name)
if (entry.isDirectory()) {
// Skip private folders (starting with _) and api routes
if (entry.name.startsWith('_') || entry.name === 'api') {
continue
}
// Handle dynamic routes
const routeName = entry.name.startsWith('[') ? entry.name : null
if (routeName) {
// Add the dynamic route pattern
paths.push(path.join(basePath, '*'))
} else {
// Recursively get routes from subdirectories
paths.push(...getRoutePaths(fullPath, routePath))
}
} else if (entry.name === 'page.tsx' || entry.name === 'page.ts') {
// Add the route path for page files
paths.push(basePath)
}
}
return paths
}
// Generate the routes file
function generateRoutesFile() {
const appDir = path.join(process.cwd(), 'app')
const routePaths = getRoutePaths(appDir)
// Format the paths
const formattedPaths = routePaths
.map(p => p.replace(/\\/g, '/')) // Convert Windows paths to forward slashes
.map(p => p || '/') // Convert empty string to root path
.sort()
// Generate the file content
const fileContent = `// This file is auto-generated. DO NOT EDIT IT MANUALLY.
// It is used to generate the validRoutes for tracking user visits.
// To regenerate, run: pnpm generate:routes
export const validRoutes = new Set([
${formattedPaths.map(p => `'${p}'`).join(',\n ')}
])
`
// Write the file
const outputPath = path.join(process.cwd(), 'lib', 'generated', 'routes.ts')
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, fileContent)
console.log('✅ Generated routes file')
}
generateRoutesFile()
--- File: scripts/init-repo.sh ---
#!/bin/bash
# --- Configuration ---
# Set this to the main branch name you prefer (usually main or master)
MAIN_BRANCH_NAME="main"
# Set to 'true' if you want to automatically confirm Vercel deployments
AUTO_CONFIRM_VERCEL="true"
# --- Error Handling ---
# Exit immediately if a command exits with a non-zero status.
set -e
# Keep track of errors
ERRORS=()
# Function to report errors and exit
handle_error() {
local command="$1"
local message="$2"
ERRORS+=("Error running command: $command")
ERRORS+=("Details: $message")
echo "-----------------------------------------------------" >&2
echo "❌ FATAL ERROR during setup process ❌" >&2
echo "Command failed: $command" >&2
echo "Error details: $message" >&2
echo "Setup process aborted." >&2
echo "-----------------------------------------------------" >&2
if [ ${#ERRORS[@]} -gt 0 ]; then
echo "Summary of errors:" >&2
for error in "${ERRORS[@]}"; do
echo "- $error" >&2
done
fi
exit 1
}
# Function to open URL in browser based on OS
open_url() {
local url=$1
case "$OSTYPE" in
"darwin"*) # macOS
open "$url"
;;
"linux"*) # Linux
if command -v xdg-open > /dev/null; then
xdg-open "$url"
elif command -v gnome-open > /dev/null; then
gnome-open "$url"
else
echo "Could not detect the web browser to use."
fi
;;
*) # Other OS
echo "Could not detect the web browser to use."
;;
esac
}
# Function to read value from .env file
get_env_value() {
local key=$1
local value=""
if [ -f ".env" ]; then
value=$(grep "^${key}=" .env | cut -d '=' -f2)
fi
echo "$value"
}
# Function to add or update value in .env file
update_env_file() {
local key=$1
local value=$2
local env_file=".env"
# Create .env if it doesn't exist
if [ ! -f "$env_file" ]; then
touch "$env_file"
fi
# Check if key exists and replace, otherwise add
if grep -q "^${key}=" "$env_file"; then
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|^${key}=.*|${key}=${value}|" "$env_file"
else
sed -i "s|^${key}=.*|${key}=${value}|" "$env_file"
fi
else
echo "${key}=${value}" >> "$env_file"
fi
}
# Function to deploy all env vars to Vercel
deploy_env_to_vercel() {
local env_file=".env"
if [ ! -f "$env_file" ]; then
echo "No .env file found. Skipping environment variable deployment."
return
fi
echo "Deploying environment variables to Vercel..."
# Read each line from .env
while IFS= read -r line || [ -n "$line" ]; do
# Skip empty lines and comments
if [ -z "$line" ] || [[ $line == \#* ]]; then
continue
fi
# Extract key and value
if [[ $line =~ ^([^=]+)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
# Remove any surrounding quotes from the value
value=$(echo "$value" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")
echo "Setting $key in Vercel..."
# Add to environment
vercel env add "$key" production "$value" > /dev/null 2>&1
fi
done < "$env_file"
echo "✅ Environment variables deployed to Vercel"
}
# --- Get User Input ---
echo "🚀 Starting Project Initialization 🚀"
echo ""
# Get current directory name as default repo name
DEFAULT_REPO_NAME=$(basename "$PWD")
# Prompt for the GitHub repository name with default suggestion
read -p "Enter the desired GitHub repository name [$DEFAULT_REPO_NAME]: " REPO_NAME
REPO_NAME=${REPO_NAME:-$DEFAULT_REPO_NAME}
if [ -z "$REPO_NAME" ]; then
handle_error "User Input" "No repository name provided."
fi
# Prompt for repository visibility with public as default
read -p "Enter repository visibility [public/private]: " REPO_VISIBILITY_INPUT
REPO_VISIBILITY=${REPO_VISIBILITY_INPUT:-"public"}
# Validate repository visibility input
REPO_VISIBILITY=$(echo "$REPO_VISIBILITY" | tr '[:upper:]' '[:lower:]') # Convert to lowercase
if [[ "$REPO_VISIBILITY" != "public" && "$REPO_VISIBILITY" != "private" ]]; then
handle_error "User Input" "Invalid visibility '$REPO_VISIBILITY_INPUT'. Please enter 'public' or 'private'."
fi
# Get the GitHub username using the authenticated GH CLI
GITHUB_USERNAME=$(gh api user --jq .login 2>/dev/null) || handle_error "gh api user" "Could not retrieve GitHub username. Is 'gh auth login' complete?"
FULL_REPO_NAME="$GITHUB_USERNAME/$REPO_NAME"
GIT_REMOTE_URL="git@github.com:$FULL_REPO_NAME.git" # Using SSH URL
echo ""
echo "--- Project Details ---"
echo "Local Directory: $(pwd)"
echo "GitHub Repository: $FULL_REPO_NAME ($REPO_VISIBILITY)"
echo "Vercel Linking Git URL: $GIT_REMOTE_URL"
echo "Main Branch: $MAIN_BRANCH_NAME"
echo "-----------------------"
echo ""
read -p "Does this look correct? [Y/n]: " confirm
if [[ "$confirm" =~ ^[Nn]$ ]]; then
echo "Setup cancelled by user."
exit 1
fi
echo "" # Add space before next section
# --- Step 1: Initialize Git Repository (if not already) ---
echo "--- 1/4: Setting up local Git repository ---"
# Check if .git directory exists. If not, initialize.
if [ ! -d ".git" ]; then
git init || handle_error "git init" "Failed to initialize Git repository."
echo "Git repository initialized."
else
echo "Existing Git repository found."
fi
# Update config.ts with GitHub URL before initial commit if public repo
if [ "$REPO_VISIBILITY" == "public" ]; then
config_file="lib/config.ts"
if [ -f "$config_file" ]; then
# Use sed to update the github field in config.ts
# The pattern looks for the 'github: ""' line and replaces it with the new URL
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS requires an empty string after -i
sed -i '' "s|github: \".*\"|github: \"https://github.com/$FULL_REPO_NAME\"|" "$config_file"
else
# Linux version
sed -i "s|github: \".*\"|github: \"https://github.com/$FULL_REPO_NAME\"|" "$config_file"
fi
echo "Updated $config_file with GitHub repository URL"
else
ERRORS+=("Warning: Could not find $config_file to update GitHub URL")
fi
fi
# Add all current files and commit
git add . || handle_error "git add ." "Failed to add files to staging."
# Check if there are staged changes before committing
if git diff --cached --quiet; then
echo "No new changes to commit. Skipping commit step."
else
git commit -m "Initial commit from init script" || handle_error "git commit" "Failed to create initial commit."
echo "Initial commit created."
fi
# Ensure the main branch exists and is named correctly
# Use '--force' to rename if it exists with a different case (like master)
if git branch --list | grep -q "\b$MAIN_BRANCH_NAME\b"; then
echo "Branch '$MAIN_BRANCH_NAME' already exists."
elif git branch --list | grep -q "\bmaster\b" && [ "$MAIN_BRANCH_NAME" == "main" ]; then
git branch -m $MAIN_BRANCH_NAME || handle_error "git branch -m" "Failed to rename branch from 'master' to '$MAIN_BRANCH_NAME'."
echo "Renamed 'master' branch to '$MAIN_BRANCH_NAME'."
else
# Create the branch if it doesn't exist and there's no master to rename
# This case is less common after git init and commit, but good practice
# Check if HEAD exists first (implies a commit has been made)
if git rev-parse --verify HEAD > /dev/null 2>&1; then
git branch $MAIN_BRANCH_NAME || handle_error "git branch" "Failed to create '$MAIN_BRANCH_NAME' branch."
git checkout $MAIN_BRANCH_NAME || handle_error "git checkout" "Failed to checkout '$MAIN_BRANCH_NAME' branch."
echo "Created and checked out '$MAIN_BRANCH_NAME' branch."
else
# No commits yet, branch will be created upon first commit/push
echo "No commits yet, branch '$MAIN_BRANCH_NAME' will be set on push."
fi
fi
git checkout $MAIN_BRANCH_NAME > /dev/null 2>&1 || handle_error "git checkout" "Failed to ensure checkout of '$MAIN_BRANCH_NAME'."
echo "Ensured checkout of branch '$MAIN_BRANCH_NAME'."
echo "Local Git setup complete."
echo ""
# --- Step 2: Create GitHub Repository and Push ---
echo "--- 2/4: Creating GitHub repository and pushing ---"
# Check if origin remote already exists and points to the correct repo
echo "Checking for existing git remote..."
CURRENT_ORIGIN=$(git remote get-url origin 2>/dev/null || echo "")
if [ -n "$CURRENT_ORIGIN" ]; then
echo "Found existing remote: $CURRENT_ORIGIN"
# Normalize the URLs for comparison (both SSH format)
EXPECTED_URL="git@github.com:$FULL_REPO_NAME.git"
if [ "$CURRENT_ORIGIN" = "$EXPECTED_URL" ]; then
echo "✓ Git remote 'origin' already exists and points to correct repository"
echo "Continuing with existing remote..."
else
echo "Warning: Git remote 'origin' exists but points to unexpected repository"
echo "Current: $CURRENT_ORIGIN"
echo "Expected: $EXPECTED_URL"
handle_error "Git Remote Conflict" "Existing 'origin' remote points to unexpected repository. Please resolve manually."
fi
else
echo "No existing remote found. Creating new repository..."
# Create the repository and push in one command
echo "Creating GitHub repository '$FULL_REPO_NAME' ($REPO_VISIBILITY)..."
CREATE_COMMAND=""
if [ "$REPO_VISIBILITY" == "private" ]; then
CREATE_COMMAND="gh repo create \"$REPO_NAME\" --private --source=. --remote=origin --push"
else
CREATE_COMMAND="gh repo create \"$REPO_NAME\" --public --source=. --remote=origin --push"
fi
echo "Executing: $CREATE_COMMAND"
eval "$CREATE_COMMAND" || {
echo "Error occurred while creating repository. Exit code: $?"
handle_error "gh repo create" "Failed to create and push to $REPO_VISIBILITY GitHub repository '$FULL_REPO_NAME'."
}
echo "GitHub repository created and code pushed successfully."
fi
echo "GitHub setup and push complete."
echo ""
# --- Step 3: Link Project to Vercel and GitHub ---
echo "--- 3/4: Linking project to Vercel and GitHub ---"
# Check if Vercel project is already linked
if vercel status --connected 2>/dev/null; then
echo "Vercel project is already linked."
else
echo "Linking project to Vercel..."
# First link the project
echo "Running: vercel link"
vercel link || handle_error "vercel link" "Failed to link project to Vercel."
fi
# Check if Git is already connected
echo "Checking Git connection status..."
if vercel git ls 2>&1 | grep -q "$FULL_REPO_NAME"; then
echo "✓ GitHub repository $FULL_REPO_NAME is already connected to Vercel"
else
# Connect to GitHub
echo "Connecting to GitHub repository..."
echo "Running: vercel git connect"
if ! output=$(vercel git connect 2>&1); then
# Check if the error is just that it's already connected
if echo "$output" | grep -q "is already connected to your project"; then
echo "✓ GitHub repository is already connected to Vercel"
else
echo "$output"
handle_error "vercel git connect" "Failed to connect GitHub repository to Vercel."
fi
fi
fi
echo "Project linked to Vercel and GitHub connection established."
echo ""
# Set up Clerk environment variables if not in .env
echo ""
echo "Setting up Clerk environment variables..."
echo "These are required for authentication to work. You can find them in your Clerk Dashboard."
echo "Visit https://dashboard.clerk.com/ to get your keys."
echo ""
# Try to get values from .env first
CLERK_PUB_KEY=$(get_env_value "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")
CLERK_SECRET_KEY=$(get_env_value "CLERK_SECRET_KEY")
# Function to validate Clerk key format
validate_clerk_key() {
local key=$1
local key_type=$2
if [ -z "$key" ]; then
return 1
fi
# Public key should start with pk_test_ or pk_live_
if [ "$key_type" = "public" ] && ! [[ $key =~ ^pk_(test|live)_ ]]; then
return 1
fi
# Secret key should start with sk_test_ or sk_live_
if [ "$key_type" = "secret" ] && ! [[ $key =~ ^sk_(test|live)_ ]]; then
return 1
fi
return 0
}
# Handle public key
if validate_clerk_key "$CLERK_PUB_KEY" "public"; then
echo "✓ Using existing NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY from .env"
else
while ! validate_clerk_key "$CLERK_PUB_KEY" "public"; do
if [ -n "$CLERK_PUB_KEY" ]; then
echo "❌ Invalid Clerk publishable key format. It should start with pk_test_ or pk_live_"
else
echo "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY not found in .env"
fi
echo "Visit https://dashboard.clerk.com/last-active/api-keys to get your publishable key"
read -p "Enter your NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: " CLERK_PUB_KEY
done
# Only update .env if we had to ask for a new value
update_env_file "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "$CLERK_PUB_KEY"
fi
# Handle secret key
if validate_clerk_key "$CLERK_SECRET_KEY" "secret"; then
echo "✓ Using existing CLERK_SECRET_KEY from .env"
else
while ! validate_clerk_key "$CLERK_SECRET_KEY" "secret"; do
if [ -n "$CLERK_SECRET_KEY" ]; then
echo "❌ Invalid Clerk secret key format. It should start with sk_test_ or sk_live_"
else
echo "CLERK_SECRET_KEY not found in .env"
fi
echo "Visit https://dashboard.clerk.com/last-active/api-keys to get your secret key"
read -p "Enter your CLERK_SECRET_KEY: " CLERK_SECRET_KEY
done
# Only update .env if we had to ask for a new value
update_env_file "CLERK_SECRET_KEY" "$CLERK_SECRET_KEY"
fi
# Function to add env var to Vercel for production
add_vercel_env() {
local key=$1
local value=$2
echo "----------------------------------------"
echo "🔄 Setting up $key for production environment"
# First try to remove any existing value
echo "Removing existing $key from production environment if it exists..."
vercel env rm "$key" production || {
echo "Note: No existing variable to remove or removal failed (this is usually ok)"
}
# Create a temporary file
local tmp_file
tmp_file=$(mktemp)
echo "$value" > "$tmp_file"
# Add the new value
echo "Adding $key to production environment..."
echo "Running environment variable add command..."
if vercel env add "$key" production < "$tmp_file"; then
echo "✅ Successfully added $key to production environment"
rm "$tmp_file"
else
local exit_code=$?
echo "❌ Failed to add environment variable"
echo "Exit code: $exit_code"
rm "$tmp_file"
handle_error "vercel env add" "Failed to add $key to Vercel production environment (exit code: $exit_code)"
fi
echo "----------------------------------------"
}
# Add variables to production environment
echo "🔐 Setting up Clerk environment variables in Vercel..."
echo ""
add_vercel_env "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" "$CLERK_PUB_KEY"
add_vercel_env "CLERK_SECRET_KEY" "$CLERK_SECRET_KEY"
echo ""
echo "✅ Clerk environment variables have been set up successfully."
echo ""
# --- Step 4: Final Vercel Deployment ---
echo "--- 4/4: Initiating Vercel deployment ---"
VERCEL_DEPLOY_COMMAND="vercel deploy --confirm"
echo "Running: $VERCEL_DEPLOY_COMMAND"
$VERCEL_DEPLOY_COMMAND || handle_error "$VERCEL_DEPLOY_COMMAND" "Failed to initiate Vercel deployment."
echo "Vercel deployment initiated."
echo ""
# --- Completion ---
echo "🎉 Project Initialization Complete! 🎉"
echo "Your project is now:"
echo "- Local Git repository initialized and committed."
echo "- GitHub repository created at https://github.com/$FULL_REPO_NAME"
echo "- Code pushed to the '$MAIN_BRANCH_NAME' branch on GitHub."
echo "- Vercel project linked to the GitHub repository."
echo "- Vercel deployment triggered. Visit your Vercel dashboard to see the status."
# Open Vercel project in browser
echo ""
echo "Opening Vercel project in browser..."
VERCEL_PROJECT_URL=$(vercel project ls --json | grep -o '"url":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$VERCEL_PROJECT_URL" ]; then
open_url "https://$VERCEL_PROJECT_URL"
else
echo "Could not determine Vercel project URL."
fi
if [ ${#ERRORS[@]} -gt 0 ]; then
echo ""
echo "⚠️ Warnings and Non-Fatal Issues Encountered: ⚠️"
for error in "${ERRORS[@]}"; do
# Filter out the fatal error header if it was added by handle_error
if [[ "$error" != *"FATAL ERROR"* && "$error" != *"Summary of errors:"* && "$error" != *"- Error running command:"* && "$error" != *"- Details:"* ]]; then
echo "- $error"
fi
done
echo "Please review the output above for details."
fi
exit 0
--- File: tailwind.config.ts ---
import type { Config } from "tailwindcss"
import tailwindcssAnimate from "tailwindcss-animate"
import typography from '@tailwindcss/typography'
const config = {
darkMode: "class",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"*.{js,ts,jsx,tsx,mdx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))",
border: "hsl(var(--sidebar-border))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
float: {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-20px)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
float: "float 15s ease-in-out infinite",
},
typography: {
DEFAULT: {
css: {
maxWidth: 'none',
color: 'hsl(var(--foreground))',
a: {
color: 'hsl(var(--primary))',
textDecoration: 'underline',
fontWeight: '500',
},
h1: {
color: 'hsl(var(--foreground))',
},
h2: {
color: 'hsl(var(--foreground))',
},
h3: {
color: 'hsl(var(--foreground))',
},
h4: {
color: 'hsl(var(--foreground))',
},
code: {
color: 'hsl(var(--foreground))',
backgroundColor: 'hsl(var(--muted))',
borderRadius: '0.25rem',
padding: '0.15rem 0.3rem',
},
pre: {
backgroundColor: 'hsl(var(--muted))',
borderRadius: '0.5rem',
padding: '1rem',
},
blockquote: {
color: 'hsl(var(--muted-foreground))',
borderLeftColor: 'hsl(var(--border))',
},
hr: {
borderColor: 'hsl(var(--border))',
},
strong: {
color: 'hsl(var(--foreground))',
},
thead: {
color: 'hsl(var(--foreground))',
borderBottomColor: 'hsl(var(--border))',
},
tbody: {
tr: {
borderBottomColor: 'hsl(var(--border))',
},
},
},
},
},
},
},
plugins: [
tailwindcssAnimate,
typography,
],
} satisfies Config
export default config
--- File: tests/admin.spec.ts ---
import { test, expect } from '@playwright/test';
import { setupAuthenticatedUser, setupCleanDatabase } from './utils/test-helpers';
test('should not be public', async ({ page }) => {
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Access Denied' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Development Environment' })).toBeVisible();
});
test('should load the admin dashboard', async ({ page }) => {
// Reset database before this test
await setupCleanDatabase();
// Login as test user
await setupAuthenticatedUser(page);
// Navigate to the page
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
});
test('can view analytics', async ({ page }) => {
// Reset database before this test
await setupCleanDatabase();
// Login as test user
await setupAuthenticatedUser(page);
// Navigate to the page
await page.goto('/admin/analytics');
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Total Visits (30 Days)' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Recent Visits' })).toBeVisible();
});
test('can view mailing list', async ({ page }) => {
// Reset database before this test
await setupCleanDatabase();
// Login as test user
await setupAuthenticatedUser(page);
// Navigate to the page
await page.goto('/admin/mailing-list');
await expect(page.getByRole('heading', { name: 'Mailing List' })).toBeVisible();
});
--- File: tests/auth.spec.ts ---
import { test, expect } from '@playwright/test';
test('should be able to fill out the sign up form', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.getByRole('textbox', { name: 'Email address' }).fill('hey@gmail.com');
await page.getByRole('textbox', { name: 'Password' }).fill('GoodVibrations');
await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible();
});
--- File: tests/contact.spec.ts ---
import { test, expect } from '@playwright/test';
test('should be able to fill out the contact form', async ({ page }) => {
await page.goto('http://localhost:3000/contact');
await expect(page.getByText('Contact Us')).toBeVisible();
await page.getByRole('textbox', { name: 'Name' }).fill('Kickin Poppin');
await page.getByRole('textbox', { name: 'Email' }).fill('kickin@poppin.com');
await page.getByRole('textbox', { name: 'Message' }).fill('Hey Yo');
});
--- File: tests/global-setup.ts ---
import { setupTestDatabase } from "./utils/db-reset"
/**
* Global setup function that runs before all tests
*/
export default async function globalSetup() {
await setupTestDatabase()
}
--- File: tests/subscribe.spec.ts ---
import { test, expect } from '@playwright/test';
import { setupAuthenticatedUser, setupCleanDatabase } from './utils/test-helpers';
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
import { Doc } from "@/convex/_generated/dataModel";
type MailingListSubscription = Doc<"mailing_list_subscriptions">;
// Initialize Convex client for test verification
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
// Helper function to safely cast Convex response
async function getSubscriptions() {
const response = await convex.query(api.mailingList.getSubscriptions);
return response as unknown as MailingListSubscription[];
}
test('should have to sign in to subscribe', async ({ page }) => {
await page.goto('http://localhost:3000/mailing-list');
await expect(page.getByText('Please sign in to subscribe')).toBeVisible();
});
test('should be able to subscribe to the mailing list when signed in', async ({ page }) => {
// Reset database before this test
await setupCleanDatabase();
// Login as test user
await setupAuthenticatedUser(page);
// Subscribe
await page.goto('http://localhost:3000/mailing-list');
// Wait for the form to be ready
await page.waitForSelector('button:has-text("Subscribe")');
// Click subscribe and wait for navigation/refresh
await page.getByRole('button', { name: 'Subscribe' }).click();
await page.waitForLoadState('networkidle');
// Check for success message
const toastText = await page.getByText('Successfully subscribed').textContent();
console.log('Toast message:', toastText);
// Check subscription status
const statusText = await page.getByText('You are currently subscribed').textContent();
console.log('Status message:', statusText);
// Verify the document in Convex
const subscriptions = await getSubscriptions();
const subscription = subscriptions.find(s => s.email === 'john.polacek@gmail.com');
expect(subscription).toBeTruthy();
expect(subscription?.email).toBe('john.polacek@gmail.com');
expect(subscription?.subscribedAt).toBeTruthy();
expect(subscription?.unsubscribedAt).toBeNull();
// Navigate to admin and check list
await page.goto('http://localhost:3000/admin/mailing-list');
await page.waitForLoadState('networkidle');
// Wait for page to load and data to be fetched
await expect(page.getByRole('heading', { name: 'Mailing List Subscribers' })).toBeVisible();
// Check if email is in the list
const emailCell = page.getByRole('cell', { name: 'john.polacek@gmail.com' });
const isEmailVisible = await emailCell.isVisible();
console.log('Email visible in admin list:', isEmailVisible);
await expect(emailCell).toBeVisible();
// Unsubscribe
await page.goto('http://localhost:3000/mailing-list');
await page.getByRole('button', { name: 'Unsubscribe' }).click();
await expect(page.getByText('Subscribe to Our Mailing List')).toBeVisible();
await page.goto('http://localhost:3000/admin/mailing-list');
await expect(page.getByRole('cell', { name: 'john.polacek@gmail.com' })).toBeVisible();
await expect(page.getByText('Unsubscribed')).toBeVisible();
// Verify unsubscribe in Convex
const updatedSubscriptions = await getSubscriptions();
const unsubscription = updatedSubscriptions.find(s => s.email === 'john.polacek@gmail.com');
expect(unsubscription).toBeTruthy();
expect(unsubscription?.unsubscribedAt).toBeTruthy();
});
--- File: tests/utils/auth-helpers.ts ---
import { Page, expect } from '@playwright/test';
import { clerk, clerkSetup } from '@clerk/testing/playwright';
/**
* Login credentials for test user
*/
export const TEST_USER = {
email: process.env.TEST_USER_EMAIL!,
password: process.env.TEST_USER_PASSWORD!,
fullName: process.env.TEST_USER_FULL_NAME!,
username: process.env.TEST_USER_USERNAME!,
userId: process.env.TEST_USER_ID!
};
/**
* Login a test user using Clerk authentication
* @param page - Playwright page object
*/
export async function loginTestUser(page: Page): Promise {
// Navigate to an unprotected page that loads Clerk
await page.goto('/');
// Setup Clerk for testing
await clerkSetup();
// Use Clerk's testing utilities to sign in
await clerk.signIn({
page,
signInParams: {
strategy: 'password',
identifier: TEST_USER.email,
password: TEST_USER.password
}
});
// Navigate to the home page and verify we're logged in
await page.goto('/');
await expect(page.getByRole('button', { name: 'Open user button' })).toBeVisible({timeout: 30000});
}
/**
* Helper function to fill in login credentials and wait for successful login
*/
export async function fillLoginCredentials(page: Page): Promise {
await page.getByRole('textbox', { name: 'Email address' }).fill(TEST_USER.email);
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Continue' }).click();
await page.waitForTimeout(500);
await page.getByRole('textbox', { name: 'Password' }).fill(TEST_USER.password);
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Open user button' })).toBeVisible({timeout: 30000});
}
/**
* Logout the current user
* @param page - Playwright page object
*/
export async function logoutUser(page: Page): Promise {
// Navigate to an unprotected page that loads Clerk
await page.goto('/');
await clerk.signOut({ page });
await page.waitForTimeout(500);
}
--- File: tests/utils/db-reset.ts ---
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import dotenv from 'dotenv'
dotenv.config()
const NODE_ENV = process.env.NODE_ENV || 'test'
// Ensure we're in test environment
if (NODE_ENV !== 'test' && NODE_ENV !== 'development') {
throw new Error('Database reset utilities should only be used in test or development environment')
}
// Initialize Convex client
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!)
const TABLES_TO_RESET = ['mailing_list_subscriptions', 'visits'] as const
// Restrict to allowed table names for type safety
type TableName = typeof TABLES_TO_RESET[number]
/**
* Delete all documents in a table
*/
export async function deleteCollection(tableName: TableName): Promise {
try {
await convex.mutation(api.testing.deleteAll, { tableName })
console.log(`Deleted all documents from ${tableName}`)
} catch (error) {
console.warn(`Warning: Failed to delete table ${tableName}:`, error)
}
}
/**
* Reset database for testing
*/
export async function resetDatabase(): Promise {
console.log('Resetting database...')
for (const table of TABLES_TO_RESET) {
await deleteCollection(table)
}
console.log('Database reset complete')
}
/**
* Seed test data using Convex test mutation
*/
export async function seedTestData(): Promise {
console.log('Seeding test data...')
await convex.mutation(api.testing.seedTestData, {})
console.log('Test data seeding complete')
}
/**
* Verify that all tables are empty
*/
export async function verifyDatabaseReset(): Promise {
try {
for (const table of TABLES_TO_RESET) {
const count = await convex.query(api.testing.countDocuments, { tableName: table })
if (count > 0) {
console.error(`Table ${table} is not empty`)
return false
}
}
return true
} catch (error) {
console.error('Error verifying database reset:', error)
return false
}
}
/**
* Reset database for testing
*/
export async function setupTestDatabase(): Promise {
await resetDatabase()
const isReset = await verifyDatabaseReset()
if (!isReset) {
throw new Error('Failed to reset database')
}
}
--- File: tests/utils/test-helpers.ts ---
import { test, expect, Page } from '@playwright/test';
import { resetDatabase, seedTestData } from './db-reset';
import { loginTestUser, logoutUser } from './auth-helpers';
/**
* Reset the database before a test or group of tests
*/
export async function setupCleanDatabase() {
await resetDatabase();
}
/**
* Reset the database and seed it with test data
*/
export async function setupSeededDatabase() {
await resetDatabase();
await seedTestData();
}
/**
* Login a test user
* @param page - Playwright page object
*/
export async function setupAuthenticatedUser(page: Page) {
await loginTestUser(page);
}
/**
* Logout a user
* @param page - Playwright page object
*/
export async function teardownAuthenticatedUser(page: Page) {
await logoutUser(page).catch(e => console.warn('Failed to logout:', e));
}
// Export test and expect for convenience
export { test, expect };
--- File: tsconfig.json ---
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
--- File: types/mailing-list.ts ---
export interface MailingListPreferences {
marketing: boolean
updates: boolean
}
export interface MailingListSubscription {
id: string
user_id: string
email: string
name: string | null
subscribed_at: string
unsubscribed_at: string | null
preferences: MailingListPreferences
created_at: string
updated_at: string
}
export type CreateMailingListSubscription = Pick & {
preferences?: Partial
}
export type UpdateMailingListSubscription = Partial> & {
preferences?: Partial
}
--- File: types/supabase.ts ---
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
user_visits: {
Row: {
id: string
user_id: string | null
path: string
referrer: string | null
timestamp: string
created_at: string
updated_at: string
user_agent: string | null
}
Insert: {
id?: string
user_id?: string | null
path: string
referrer?: string | null
timestamp?: string
created_at?: string
updated_at?: string
user_agent?: string | null
}
Update: {
id?: string
user_id?: string | null
path?: string
referrer?: string | null
timestamp?: string
created_at?: string
updated_at?: string
user_agent?: string | null
}
}
mailing_list_subscriptions: {
Row: {
id: string
user_id: string
email: string
name: string | null
subscribed_at: string
unsubscribed_at: string | null
preferences: {
marketing: boolean
updates: boolean
}
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
email: string
name?: string | null
subscribed_at?: string
unsubscribed_at?: string | null
preferences?: {
marketing: boolean
updates: boolean
}
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
email?: string
name?: string | null
subscribed_at?: string
unsubscribed_at?: string | null
preferences?: {
marketing: boolean
updates: boolean
}
created_at?: string
updated_at?: string
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
}
}
|