Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@ drizzle/meta/
.supabase/


.agents/
.agents/
.playwright-mcp/
.mcp.json
10 changes: 10 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async function DashboardPage() {
return (
<main className="flex min-h-screen flex-col p-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-muted-foreground">
You are admin
</p>
Comment thread
achneerov marked this conversation as resolved.
</main>
);
}
57 changes: 43 additions & 14 deletions app/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,66 @@

import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { loginSchema, signupSchema } from "./schema";

export async function login(formData: FormData) {
const supabase = await createClient();
export type ActionState = {
errors: Partial<Record<string, string[]>>;
} | null;

const { error } = await supabase.auth.signInWithPassword({
email: formData.get("email") as string,
password: formData.get("password") as string,
export async function login(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const result = loginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});

if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}

const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword(result.data);

if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`);
return { errors: { email: [error.message] } };
}

redirect("/");
}

export async function signup(formData: FormData) {
const supabase = await createClient();
export async function signup(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const result = signupSchema.safeParse({
firstName: formData.get("first_name"),
lastName: formData.get("last_name"),
email: formData.get("email"),
password: formData.get("password"),
});

if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}

const { firstName, lastName, email, password } = result.data;

const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email: formData.get("email") as string,
password: formData.get("password") as string,
email,
password,
options: {
data: { first_name: firstName, last_name: lastName },
},
});

if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`);
return { errors: { email: [error.message] } };
}

redirect(
`/login?message=${encodeURIComponent("Check your email to confirm your account.")}`
);
redirect("/login?message=Check+your+email+to+confirm+your+account.");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make a enum file for strings like this? something like constants.js, and maybe we would put it in a constants folder maybe in lib/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, we don't have that much strings, let's try to not over complicate the project for nothing. Later on, if we do have a lot of strings we could yes. Usually having a lib for strings is useful when you have locales translation in multiple languages.

}

export async function signout() {
Expand Down
102 changes: 79 additions & 23 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { login, signup } from "./actions";
import { useActionState, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { login, signup, type ActionState } from "./actions";
import {
Card,
CardContent,
Expand All @@ -14,61 +14,117 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";

function FieldError({ errors }: { errors?: string[] }) {
if (!errors?.length) return null;
return <p className="text-sm text-destructive">{errors[0]}</p>;
}

function LoginForm() {
const searchParams = useSearchParams();
const message = searchParams.get("message");
const error = searchParams.get("error");
const [mode, setMode] = useState<"login" | "signup">("login");

const [loginState, loginAction] = useActionState<ActionState, FormData>(
login,
null
);
const [signupState, signupAction] = useActionState<ActionState, FormData>(
signup,
null
);

const state = mode === "login" ? loginState : signupState;
const action = mode === "login" ? loginAction : signupAction;

return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">Welcome</CardTitle>
<CardTitle className="text-2xl">
{mode === "login" ? "Welcome back" : "Create an account"}
</CardTitle>
<CardDescription>
Sign in to your account or create a new one
{mode === "login"
? "Sign in to your account"
: "Fill in your details to get started"}
Comment on lines +48 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, maybe in a constants file?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment. See previous

</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{message && (
<div className="mb-4 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
{message}
</div>
)}

<form className="space-y-4">
<div className="space-y-2">
<form action={action} className="space-y-4" noValidate>
{mode === "signup" && (
<div className="flex gap-3">
<div className="space-y-1 flex-1">
<Label htmlFor="first_name">First name</Label>
<Input
id="first_name"
name="first_name"
type="text"
placeholder="Jane"
/>
<FieldError errors={signupState?.errors?.firstName} />
</div>
<div className="space-y-1 flex-1">
<Label htmlFor="last_name">Last name</Label>
<Input
id="last_name"
name="last_name"
type="text"
placeholder="Doe"
/>
<FieldError errors={signupState?.errors?.lastName} />
</div>
</div>
)}
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
placeholder="you@example.com"
/>
<FieldError errors={state?.errors?.email} />
</div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
minLength={6}
placeholder="••••••••"
/>
<FieldError errors={state?.errors?.password} />
</div>
<div className="flex gap-2 pt-2">
<Button formAction={login} className="flex-1">
Log in
</Button>
<Button formAction={signup} variant="outline" className="flex-1">
Sign up
</Button>
<div className="flex flex-col gap-2 pt-2">
{mode === "login" ? (
<>
<Button type="submit">Log in</Button>
<Button
type="button"
variant="outline"
onClick={() => setMode("signup")}
>
Create an account
</Button>
</>
) : (
<>
<Button type="submit">Sign up</Button>
<Button
type="button"
variant="outline"
onClick={() => setMode("login")}
>
Already have an account? Log in
</Button>
</>
)}
</div>
</form>
</CardContent>
Expand Down
13 changes: 13 additions & 0 deletions app/login/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from "zod";

export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});

export const signupSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
65 changes: 50 additions & 15 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { pgTable, text, timestamp, uuid, pgEnum, integer, boolean, jsonb } from "drizzle-orm/pg-core";
import {
pgTable,
text,
timestamp,
uuid,
pgEnum,
integer,
boolean,
jsonb,
} from "drizzle-orm/pg-core";

export const roleEnum = pgEnum("role", ["user", "admin", "coach"]);
export const serviceTypeEnum = pgEnum('service_type', ["coaching_session", "booking"]);
export const bookingStatusEnum = pgEnum('booking_status', ["pending", "confirmed", "cancelled"]);
export const webinarTierEnum = pgEnum('webinar_tier', ["free", "premium"]);
export const sessionStatusEnum = pgEnum("session_status", ["pending", "confirmed", "cancelled", "completed"]);

export const serviceTypeEnum = pgEnum("service_type", [
"coaching_session",
"booking",
]);
export const bookingStatusEnum = pgEnum("booking_status", [
"pending",
"confirmed",
"cancelled",
]);
export const webinarTierEnum = pgEnum("webinar_tier", ["free", "premium"]);
export const sessionStatusEnum = pgEnum("session_status", [
"pending",
"confirmed",
"cancelled",
"completed",
]);

export const profiles = pgTable("profiles", {
id: uuid("id").primaryKey(),
Expand All @@ -15,7 +35,7 @@ export const profiles = pgTable("profiles", {
stripeCustomerId: text("stripe_customer_id").unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
});

export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
Expand All @@ -28,12 +48,16 @@ export const services = pgTable("services", {
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
});

export const serviceBookings = pgTable("service_bookings", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
serviceId: uuid("service_id").references(() => services.id, { onDelete: "cascade" }).notNull(),
userId: uuid("user_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull(),
serviceId: uuid("service_id")
.references(() => services.id, { onDelete: "cascade" })
.notNull(),
status: bookingStatusEnum("status").notNull().default("pending"),
notes: text("notes"),
isActive: boolean("is_active").notNull().default(true),
Expand All @@ -55,9 +79,15 @@ export const webinars = pgTable("webinars", {

export const coachingSessions = pgTable("coaching_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
serviceId: uuid("service_id").references(() => services.id, { onDelete: "cascade" }).notNull(),
coachId: uuid("coach_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
serviceId: uuid("service_id")
.references(() => services.id, { onDelete: "cascade" })
.notNull(),
coachId: uuid("coach_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull(),
userId: uuid("user_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull(),
scheduledAt: timestamp("scheduled_at"),
durationMinutes: integer("duration_minutes").notNull().default(60),
status: sessionStatusEnum("status").notNull().default("pending"),
Expand All @@ -70,7 +100,10 @@ export const coachingSessions = pgTable("coaching_sessions", {

export const subscriptions = pgTable("subscriptions", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull().unique(),
userId: uuid("user_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull()
.unique(),
stripeSubscriptionId: text("stripe_subscription_id").unique(),
status: text("status").notNull().default("none"),
stripePriceId: text("stripe_price_id"),
Expand All @@ -83,7 +116,9 @@ export const subscriptions = pgTable("subscriptions", {

export const purchases = pgTable("purchases", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => profiles.id, { onDelete: "cascade" }).notNull(),
userId: uuid("user_id")
.references(() => profiles.id, { onDelete: "cascade" })
.notNull(),
stripePriceId: text("stripe_price_id").notNull(),
stripeSessionId: text("stripe_session_id").notNull().unique(),
productName: text("product_name").notNull(),
Expand Down
7 changes: 7 additions & 0 deletions lib/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const ROLES = {
ADMIN: "admin",
COACH: "coach",
USER: "user",
} as const;

export type Role = (typeof ROLES)[keyof typeof ROLES];
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"react-dom": "19.2.4",
"shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Expand Down
Loading