Initial Setup für Dokploy

This commit is contained in:
Ahmed Darrazi 2025-11-18 23:24:41 +01:00
commit 85f20147ed
72 changed files with 12623 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,45 @@
import { Card } from "@/components/ui/card";
interface AccountCardProps {
params: {
header: string;
description: string;
price?: number;
};
children: React.ReactNode;
}
export function AccountCard({ params, children }: AccountCardProps) {
const { header, description } = params;
return (
<Card>
<div id="body" className="p-4 ">
<h3 className="text-xl font-semibold">{header}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
{children}
</Card>
);
}
export function AccountCardBody({ children }: { children: React.ReactNode }) {
return <div className="p-4">{children}</div>;
}
export function AccountCardFooter({
description,
children,
}: {
children: React.ReactNode;
description: string;
}) {
return (
<div
className="bg-muted p-4 border dark:bg-card flex justify-between items-center rounded-b-lg"
id="footer"
>
<p className="text-muted-foreground text-sm">{description}</p>
{children}
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import {
AccountCard,
AccountCardBody,
AccountCardFooter,
} from "./AccountCard";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { AuthSession } from "@/lib/auth/utils";
interface PlanSettingsProps {
stripeSubscriptionId: string | null;
stripeCurrentPeriodEnd: Date | null;
stripeCustomerId: string | null;
isSubscribed: boolean | "" | null;
isCanceled: boolean;
id?: string | undefined;
name?: string | undefined;
description?: string | undefined;
stripePriceId?: string | undefined;
price?: number | undefined;
}
export default function PlanSettings({
subscriptionPlan,
session,
}: {
subscriptionPlan: PlanSettingsProps;
session: AuthSession["session"];
}) {
return (
<AccountCard
params={{
header: "Your Plan",
description: subscriptionPlan.isSubscribed
? `You are currently on the ${subscriptionPlan.name} plan.`
: `You are not subscribed to any plan.`.concat(
!session?.user?.email || session?.user?.email.length < 5
? " Please add your email to upgrade your account."
: ""
),
}}
>
<AccountCardBody>
{subscriptionPlan.isSubscribed ? (
<h3 className="font-semibold text-lg">
${subscriptionPlan.price ? subscriptionPlan.price / 100 : 0} / month
</h3>
) : null}
{subscriptionPlan.stripeCurrentPeriodEnd ? (
<p className="text-sm mb-4 text-muted-foreground ">
Your plan will{" "}
{!subscriptionPlan.isSubscribed
? null
: subscriptionPlan.isCanceled
? "cancel"
: "renew"}
{" on "}
<span className="font-semibold">
{subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString(
"en-us"
)}
</span>
</p>
) : null}
</AccountCardBody>
<AccountCardFooter description="Manage your subscription on Stripe.">
<Link href="/account/billing">
<Button variant="outline">Go to billing</Button>
</Link>
</AccountCardFooter>
</AccountCard>
);
}

View File

@ -0,0 +1,52 @@
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
export default function UpdateEmailCard({ email }: { email: string }) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
const target = event.target as HTMLFormElement;
const form = new FormData(target);
const { email } = Object.fromEntries(form.entries()) as { email: string };
if (email.length < 3) {
toast.error("Email must be longer than 3 characters.");
return;
}
startTransition(async () => {
const res = await fetch("/api/account", {
method: "PUT",
body: JSON.stringify({ email }),
headers: { "Content-Type": "application/json" },
});
if (res.status === 200)
toast.success("Successfully updated email!");
router.refresh();
});
};
return (
<AccountCard
params={{
header: "Your Email",
description:
"Please enter the email address you want to use with your account.",
}}
>
<form onSubmit={handleSubmit}>
<AccountCardBody>
<Input defaultValue={email ?? ""} name="email" disabled={true} />
</AccountCardBody>
<AccountCardFooter description="We will email vou to verify the change.">
<Button disabled={true}>Update Email</Button>
</AccountCardFooter>
</form>
</AccountCard>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
export default function UpdateNameCard({ name }: { name: string }) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault();
const target = event.target as HTMLFormElement;
const form = new FormData(target);
const { name } = Object.fromEntries(form.entries()) as { name: string };
if (name.length < 3) {
toast.error("Name must be longer than 3 characters.");
return;
}
startTransition(async () => {
const res = await fetch("/api/account", {
method: "PUT",
body: JSON.stringify({ name }),
headers: { "Content-Type": "application/json" },
});
if (res.status === 200)
toast.success("Successfully updated name!");
router.refresh();
});
};
return (
<AccountCard
params={{
header: "Your Name",
description:
"Please enter your full name, or a display name you are comfortable with.",
}}
>
<form onSubmit={handleSubmit}>
<AccountCardBody>
<Input defaultValue={name ?? ""} name="name" disabled={true} />
</AccountCardBody>
<AccountCardFooter description="64 characters maximum">
<Button disabled={true}>Update Name</Button>
</AccountCardFooter>
</form>
</AccountCard>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import UpdateNameCard from "./UpdateNameCard";
import UpdateEmailCard from "./UpdateEmailCard";
import { AuthSession } from "@/lib/auth/utils";
export default function UserSettings({
session,
}: {
session: AuthSession["session"];
}) {
return (
<>
<UpdateNameCard name={session?.user.name ?? ""} />
<UpdateEmailCard email={session?.user.email ?? ""} />
</>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { Button } from "@/components/ui/button";
import React from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface ManageUserSubscriptionButtonProps {
userId: string;
email: string;
isCurrentPlan: boolean;
isSubscribed: boolean;
stripeCustomerId?: string | null;
stripePriceId: string;
}
export function ManageUserSubscriptionButton({
userId,
email,
isCurrentPlan,
isSubscribed,
stripeCustomerId,
stripePriceId,
}: ManageUserSubscriptionButtonProps) {
const [isPending, startTransition] = React.useTransition();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
startTransition(async () => {
try {
const res = await fetch("/api/billing/manage-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
userId,
isSubscribed,
isCurrentPlan,
stripeCustomerId,
stripePriceId,
}),
});
const session: { url: string } = await res.json();
if (session) {
window.location.href = session.url ?? "/dashboard/billing";
}
} catch (err) {
console.error((err as Error).message);
toast.error("Something went wrong, please try again later.");
}
});
};
return (
<form onSubmit={handleSubmit} className="w-full">
<Button
disabled={isPending}
className="w-full"
variant={isCurrentPlan ? "default" : "outline"}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isCurrentPlan ? "Manage Subscription" : "Subscribe"}
</Button>
</form>
);
}

View File

@ -0,0 +1,18 @@
"use client";
import { toast } from "sonner";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
export default function SuccessToast() {
const searchParams = useSearchParams();
const success = searchParams.get("success") as Boolean | null;
useEffect(() => {
if (success) {
toast.success("Successfully updated subscription.");
}
}, [success]);
return null;
}

View File

@ -0,0 +1,112 @@
import SuccessToast from "./SuccessToast";
import { ManageUserSubscriptionButton } from "./ManageSubscription";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { storeSubscriptionPlans } from "@/config/subscriptions";
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";
import { CheckCircle2Icon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
export default async function Billing() {
await checkAuth();
const { session } = await getUserAuth();
const subscriptionPlan = await getUserSubscriptionPlan();
if (!session) return redirect("/");
return (
<div className="min-h-[calc(100vh-57px)] ">
<SuccessToast />
<Link href="/account">
<Button variant={"link"} className="px-0">
Back
</Button>
</Link>
<h1 className="text-3xl font-semibold mb-4">Billing</h1>
<Card className="p-6 mb-2">
<h3 className="uppercase text-xs font-bold text-muted-foreground">
Subscription Details
</h3>
<p className="text-lg font-semibold leading-none my-2">
{subscriptionPlan.name}
</p>
<p className="text-sm text-muted-foreground">
{!subscriptionPlan.isSubscribed
? "You are not subscribed to any plan."
: subscriptionPlan.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{subscriptionPlan?.stripeCurrentPeriodEnd
? subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString()
: null}
</p>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-4">
{storeSubscriptionPlans.map((plan) => (
<Card
key={plan.id}
className={
plan.name === subscriptionPlan.name ? "border-primary" : ""
}
>
{plan.name === subscriptionPlan.name ? (
<div className="w-full relative">
<div className="text-center px-3 py-1 bg-secondary-foreground text-secondary text-xs w-fit rounded-l-lg rounded-t-none absolute right-0 font-semibold">
Current Plan
</div>
</div>
) : null}
<CardHeader className="mt-2">
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="mt-2 mb-8">
<h3 className="font-bold">
<span className="text-3xl">${plan.price / 100}</span> / month
</h3>
</div>
<ul className="space-y-2">
{plan.features.map((feature, i) => (
<li key={`feature_${i + 1}`} className="flex gap-x-2 text-sm">
<CheckCircle2Icon className="text-green-400 h-5 w-5" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex items-end justify-center">
{session?.user.email ? (
<ManageUserSubscriptionButton
userId={session.user.id}
email={session.user.email || ""}
stripePriceId={plan.stripePriceId}
stripeCustomerId={subscriptionPlan?.stripeCustomerId}
isSubscribed={!!subscriptionPlan.isSubscribed}
isCurrentPlan={subscriptionPlan?.name === plan.name}
/>
) : (
<div>
<Link href="/account">
<Button className="text-center" variant="ghost">
Add Email to Subscribe
</Button>
</Link>
</div>
)}
</CardFooter>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import UserSettings from "./UserSettings";
import PlanSettings from "./PlanSettings";
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";
export default async function Account() {
await checkAuth();
const { session } = await getUserAuth();
const subscriptionPlan = await getUserSubscriptionPlan();
return (
<main>
<h1 className="text-2xl font-semibold my-4">Account</h1>
<div className="space-y-4">
<PlanSettings subscriptionPlan={subscriptionPlan} session={session} />
<UserSettings session={session} />
</div>
</main>
);
}

View File

@ -0,0 +1,17 @@
import SignIn from "@/components/auth/SignIn";
import { getUserAuth } from "@/lib/auth/utils";
export default async function Home() {
const { session } = await getUserAuth();
return (
<main className="space-y-4">
{session ? (
<pre className="bg-secondary p-4 rounded-sm shadow-sm text-secondary-foreground break-all whitespace-break-spaces">
{JSON.stringify(session, null, 2)}
</pre>
) : null}
<SignIn />
</main>
);
}

23
app/(app)/layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import { checkAuth } from "@/lib/auth/utils";
import { Toaster } from "@/components/ui/sonner";
import Navbar from "@/components/Navbar";
import Sidebar from "@/components/Sidebar";
import NextAuthProvider from "@/lib/auth/Provider";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
await checkAuth();
return ( <main>
<NextAuthProvider><div className="flex h-screen">
<Sidebar />
<main className="flex-1 md:p-8 pt-2 p-8 overflow-y-auto">
<Navbar />
{children}
</main>
</div></NextAuthProvider>
<Toaster richColors />
</main> )
}

124
app/(app)/resend/page.tsx Normal file
View File

@ -0,0 +1,124 @@
"use client";
import Link from "next/link"
import { emailSchema } from "@/lib/email/utils";
import { useRef, useState } from "react";
import { z } from "zod";
type FormInput = z.infer<typeof emailSchema>;
type Errors = { [K in keyof FormInput]: string[] };
export default function Home() {
const [sending, setSending] = useState(false);
const [errors, setErrors] = useState<Errors | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
const sendEmail = async () => {
setSending(true);
setErrors(null);
try {
const payload = emailSchema.parse({
name: nameInputRef.current?.value,
email: emailInputRef.current?.value,
});
console.log(payload);
const req = await fetch("/api/email", {
method: "POST",
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
});
const { id } = await req.json();
if (id) alert("Successfully sent!");
} catch (err) {
if (err instanceof z.ZodError) {
setErrors(err.flatten().fieldErrors as Errors);
}
} finally {
setSending(false);
}
};
return (
<main className="p-4 md:p-0">
<div>
<h1 className="text-2xl font-bold my-4">Send Email with Resend</h1>
<div>
<ol className="list-decimal list-inside space-y-1">
<li>
<Link
className="text-primary hover:text-muted-foreground underline"
href="https://resend.com/signup"
>
Sign up
</Link>{" "}
or{" "}
<Link
className="text-primary hover:text-muted-foreground underline"
href="https://resend.com/login"
>
Login
</Link>{" "}
to your Resend account
</li>
<li>Add and verify your domain</li>
<li>
Create an API Key and add to{" "}
<span className="ml-1 font-mono font-thin text-neutral-600 bg-neutral-100 p-0.5">
.env
</span>
</li>
<li>
Update &quot;from:&quot; in{" "}
<span className="ml-1 font-mono font-thin text-neutral-600 bg-neutral-100 p-0.5">
app/api/email/route.ts
</span>
</li>
<li>Send email 🎉</li>
</ol>
</div>
</div>
<form
onSubmit={(e) => e.preventDefault()}
className="space-y-3 pt-4 border-t mt-4"
>
{errors && (
<p className="bg-neutral-50 p-3">{JSON.stringify(errors, null, 2)}</p>
)}
<div>
<label className="text-neutral-700 text-sm">Name</label>
<input
type="text"
placeholder="Tim"
name="name"
ref={nameInputRef}
className={`
w-full px-3 py-2 text-sm rounded-md border focus:outline-neutral-700 ${
!!errors?.name ? "border-red-700" : "border-neutral-200"
}`}
/>
</div>
<div>
<label className="text-muted-foreground">Email</label>
<input
type="email"
placeholder="tim@apple.com"
name="email"
ref={emailInputRef}
className={`
w-full px-3 py-2 text-sm rounded-md border focus:outline-neutral-700 ${
!!errors?.email ? "border-red-700" : "border-neutral-200"
}`}
/>
</div>
<button
onClick={() => sendEmail()}
className="text-sm bg-black text-white px-4 py-2.5 rounded-lg hover:bg-gray-800 disabled:opacity-70"
disabled={sending}
>
{sending ? "sending..." : "Send Email"}
</button>
</form>
</main>
);
}

106
app/(app)/settings/page.tsx Normal file
View File

@ -0,0 +1,106 @@
"use client";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
export default function Page() {
const { setTheme } = useTheme();
return (
<div>
<h1 className="text-2xl font-semibold">Settings</h1>
<div className="space-y-4 my-4">
<div>
<h3 className="text-lg font-medium">Appearance</h3>
<p className="text-sm text-muted-foreground">
Customize the appearance of the app. Automatically switch between
day and night themes.
</p>
</div>
<Button
asChild
variant={"ghost"}
className="w-fit h-fit"
onClick={() => setTheme("light")}
>
<div className="flex flex-col">
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Light
</span>
</div>
</Button>
<Button
asChild
variant={"ghost"}
onClick={() => setTheme("dark")}
className="w-fit h-fit"
>
<div className="flex flex-col">
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-neutral-950 p-2">
<div className="space-y-2 rounded-md bg-neutral-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Dark
</span>
</div>
</Button>
<Button
asChild
variant={"ghost"}
onClick={() => setTheme("system")}
className="w-fit h-fit"
>
<div className="flex flex-col">
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-neutral-300 p-2">
<div className="space-y-2 rounded-md bg-neutral-600 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-neutral-400" />
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
System
</span>
</div>
</Button>
</div>
</div>
);
}

13
app/(auth)/layout.tsx Normal file
View File

@ -0,0 +1,13 @@
import { getUserAuth } from "@/lib/auth/utils";
import { redirect } from "next/navigation";
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getUserAuth();
if (session?.session) redirect("/dashboard");
return ( <div className="bg-muted h-screen pt-8">{children}</div> );
}

View File

@ -0,0 +1,23 @@
"use client";
import { signIn } from "next-auth/react";
const Page = () => {
return (
<main className="bg-popover max-w-lg mx-auto my-4 rounded-lg p-10">
<h1 className="text-2xl font-bold text-center">
Sign in to your account
</h1>
<div className="mt-4">
<button
onClick={() => signIn(undefined, { callbackUrl: "/dashboard" })}
className="w-full bg-primary text-primary-foreground text-center hover:opacity-90 font-medium px-4 py-2 rounded-lg block"
>
Sign In
</button>
</div>
</main>
);
};
export default Page;

36
app/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# 1. Stage: Abhängigkeiten installieren
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# 2. Stage: Builder (Der Code wird kompiliert)
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Hier werden auch die Environment Variables für den Build gebraucht
# (In Dokploy setzt du diese später, aber für den Build ignorieren wir ESLint Fehler oft)
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# 3. Stage: Runner (Das eigentliche Image für Dokploy - winzig klein)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Wir kopieren nur das Nötigste aus dem Builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

15
app/api/account/route.ts Normal file
View File

@ -0,0 +1,15 @@
import { getUserAuth } from "@/lib/auth/utils";
import { db } from "@/lib/db/index";
import { users } from "@/lib/db/schema/auth";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function PUT(request: Request) {
const { session } = await getUserAuth();
if (!session) return new Response("Error", { status: 400 });
const body = (await request.json()) as { name?: string; email?: string };
await db.update(users).set({ ...body }).where(eq(users.id, session.user.id));
revalidatePath("/account");
return new Response(JSON.stringify({ message: "ok" }), { status: 200 });
}

View File

@ -0,0 +1,14 @@
import { DefaultSession } from "next-auth";
import NextAuth from "next-auth/next";
import { authOptions } from "@/lib/auth/utils";
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
};
}
}
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -0,0 +1,51 @@
import { stripe } from "@/lib/stripe/index";
import { absoluteUrl } from "@/lib/utils";
interface ManageStripeSubscriptionActionProps {
isSubscribed: boolean;
stripeCustomerId?: string | null;
isCurrentPlan: boolean;
stripePriceId: string;
email: string;
userId: string;
}
export async function POST(req: Request) {
const body: ManageStripeSubscriptionActionProps = await req.json();
const { isSubscribed, stripeCustomerId, userId, stripePriceId, email } = body;
console.log(body);
const billingUrl = absoluteUrl("/account/billing");
if (isSubscribed && stripeCustomerId) {
const stripeSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: billingUrl,
});
return new Response(JSON.stringify({ url: stripeSession.url }), {
status: 200,
});
}
const stripeSession = await stripe.checkout.sessions.create({
success_url: billingUrl.concat("?success=true"),
cancel_url: billingUrl,
payment_method_types: ["card"],
mode: "subscription",
billing_address_collection: "auto",
customer_email: email,
line_items: [
{
price: stripePriceId,
quantity: 1,
},
],
metadata: {
userId,
},
});
return new Response(JSON.stringify({ url: stripeSession.url }), {
status: 200,
});
}

22
app/api/email/route.ts Normal file
View File

@ -0,0 +1,22 @@
import { EmailTemplate } from "@/components/emails/FirstEmail";
import { resend } from "@/lib/email/index";
import { emailSchema } from "@/lib/email/utils";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
const { name, email } = emailSchema.parse(body);
try {
const data = await resend.emails.send({
from: "Kirimase <onboarding@resend.dev>",
to: [email],
subject: "Hello world!",
react: EmailTemplate({ firstName: name }),
text: "Email powered by Resend.",
});
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error });
}
}

View File

@ -0,0 +1,98 @@
import { db } from "@/lib/db/index";
import { stripe } from "@/lib/stripe/index";
import { headers } from "next/headers";
import type Stripe from "stripe";
import { subscriptions } from "@/lib/db/schema/subscriptions";
import { eq } from "drizzle-orm";
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get("Stripe-Signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET || ""
);
console.log(event.type);
} catch (err) {
return new Response(
`Webhook Error: ${err instanceof Error ? err.message : "Unknown Error"}`,
{ status: 400 }
);
}
const session = event.data.object as Stripe.Checkout.Session;
// console.log("this is the session metadata -> ", session);
if (!session?.metadata?.userId && session.customer == null) {
console.error("session customer", session.customer);
console.error("no metadata for userid");
return new Response(null, {
status: 200,
});
}
if (event.type === "checkout.session.completed") {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
const updatedData = {
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
};
if (session?.metadata?.userId != null) {
const [sub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, session.metadata.userId));
if (sub != undefined) {
await db
.update(subscriptions)
.set(updatedData)
.where(eq(subscriptions.userId, sub.userId!));
} else {
await db
.insert(subscriptions)
.values({ ...updatedData, userId: session.metadata.userId });
}
} else if (
typeof session.customer === "string" &&
session.customer != null
) {
await db
.update(subscriptions)
.set(updatedData)
.where(eq(subscriptions.stripeCustomerId, session.customer));
}
}
if (event.type === "invoice.payment_succeeded") {
// Retrieve the subscription details from Stripe.
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
// Update the price id and set the new period end.
await db
.update(subscriptions)
.set({
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
}
return new Response(null, { status: 200 });
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

76
app/globals.css Normal file
View File

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

37
app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>{children}</ThemeProvider>
</body>
</html>
);
}

25
app/loading.tsx Normal file
View File

@ -0,0 +1,25 @@
export default function Loading() {
return (
<div className="grid place-items-center animate-pulse text-neutral-300 p-4">
<div role="status">
<svg
aria-hidden="true"
className="w-8 h-8 text-neutral-200 dark:text-neutral-600 fill-neutral-600 animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}

183
app/page.tsx Normal file
View File

@ -0,0 +1,183 @@
/**
* v0 by Vercel.
* @see https://v0.dev/t/PmwTvNfrVgf
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
*/
import Link from "next/link";
export default function LandingPage() {
return (
<div className="flex flex-col min-h-screen">
<header className="px-4 lg:px-6 h-14 flex items-center">
<Link className="flex items-center justify-center" href="#">
<MountainIcon className="h-6 w-6" />
<span className="sr-only">Acme Inc</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6">
<Link
className="text-sm font-medium hover:underline underline-offset-4"
href="#features"
>
Features
</Link>
<Link
className="text-sm font-medium hover:underline underline-offset-4"
href="/sign-in"
>
Sign In
</Link>
</nav>
</header>
<main className="flex-1">
<section className="w-full py-6 sm:py-12 md:py-24 lg:py-32 xl:py-48">
<div className="container px-4 md:px-6">
<div className="grid gap-6 lg:grid-cols-[1fr_400px] lg:gap-12 xl:grid-cols-[1fr_600px]">
<div className="bg-neutral-100 dark:bg-neutral-800 mx-auto aspect-video overflow-hidden rounded-xl object-cover sm:w-full lg:order-last lg:aspect-square" />
<div className="flex flex-col justify-center space-y-4">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none">
The complete platform <br />
for building the Web
</h1>
<p className="max-w-[600px] text-neutral-500 md:text-xl dark:text-neutral-400">
Give your team the toolkit to stop configuring and start
innovating. Securely build, deploy, and scale the best web
experiences.
</p>
</div>
<div className="flex flex-col gap-2 min-[400px]:flex-row">
<Link
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-8 text-sm font-medium text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
href="#"
>
Get Started
</Link>
<Link
className="inline-flex h-10 items-center justify-center rounded-md border border-neutral-200 bg-white px-8 text-sm font-medium shadow-sm transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50 dark:focus-visible:ring-neutral-300"
href="#"
>
Contact Sales
</Link>
</div>
</div>
</div>
</div>
</section>
<section id="features" className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-lg bg-neutral-100 px-3 py-1 text-sm dark:bg-neutral-800">
Key Features
</div>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight">
Faster iteration. More innovation.
</h2>
<p className="max-w-[900px] text-neutral-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-neutral-400">
The platform for rapid progress. Let your team focus on
shipping features instead of managing infrastructure with
automated CI/CD.
</p>
</div>
</div>
<div className="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-10">
<div className="mx-auto aspect-video overflow-hidden bg-neutral-100 dark:bg-neutral-800 rounded-xl object-cover object-center sm:w-full lg:order-last" />
<div className="flex flex-col justify-center space-y-4">
<ul className="grid gap-6">
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Collaboration</h3>
<p className="text-neutral-500 dark:text-neutral-400">
Make collaboration seamless with built-in code review
tools.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Automation</h3>
<p className="text-neutral-500 dark:text-neutral-400">
Automate your workflow with continuous integration.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Scale</h3>
<p className="text-neutral-500 dark:text-neutral-400">
Deploy to the cloud with a single click and scale with
ease.
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 border-t">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl">
Sign Up for Updates
</h2>
<p className="max-w-[600px] text-neutral-500 md:text-xl dark:text-neutral-400">
Stay updated with the latest product news and updates.
</p>
</div>
<div className="w-full max-w-sm space-y-2">
<form className="flex sm:flex-row flex-col space-y-2 sm:space-y-0 sm:space-x-2">
<input
className="max-w-lg flex-1 px-4 py-2 border-border border rounded-md "
placeholder="Enter your email"
type="email"
/>
<button
type="submit"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
>
Sign Up
</button>
</form>
</div>
</div>
</div>
</section>
</main>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
© 2024 Acme Inc. All rights reserved.
</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4" href="#">
Terms of Service
</Link>
<Link className="text-xs hover:underline underline-offset-4" href="#">
Privacy
</Link>
</nav>
</footer>
</div>
);
}
function MountainIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m8 3 4 8 5-5 5 15H2L8 3z" />
</svg>
);
}

16
components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

45
components/Navbar.tsx Normal file
View File

@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import { AlignRight } from "lucide-react";
import { defaultLinks } from "@/config/nav";
export default function Navbar() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
return (
<div className="md:hidden border-b mb-4 pb-2 w-full">
<nav className="flex justify-between w-full items-center">
<div className="font-semibold text-lg">Logo</div>
<Button variant="ghost" onClick={() => setOpen(!open)}>
<AlignRight />
</Button>
</nav>
{open ? (
<div className="my-4 p-4 bg-muted">
<ul className="space-y-2">
{defaultLinks.map((link) => (
<li key={link.title} onClick={() => setOpen(false)} className="">
<Link
href={link.href}
className={
pathname === link.href
? "text-primary hover:text-primary font-semibold"
: "text-muted-foreground hover:text-primary"
}
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}

55
components/Sidebar.tsx Normal file
View File

@ -0,0 +1,55 @@
import Link from "next/link";
import SidebarItems from "./SidebarItems";
import { Avatar, AvatarFallback } from "./ui/avatar";
import { AuthSession, getUserAuth } from "@/lib/auth/utils";
const Sidebar = async () => {
const session = await getUserAuth();
if (session.session === null) return null;
return (
<aside className="h-screen min-w-52 bg-muted hidden md:block p-4 pt-8 border-r border-border shadow-inner">
<div className="flex flex-col justify-between h-full">
<div className="space-y-4">
<h3 className="text-lg font-semibold ml-4">Logo</h3>
<SidebarItems />
</div>
<UserDetails session={session} />
</div>
</aside>
);
};
export default Sidebar;
const UserDetails = ({ session }: { session: AuthSession }) => {
if (session.session === null) return null;
const { user } = session.session;
if (!user?.name || user.name.length == 0) return null;
return (
<Link href="/account">
<div className="flex items-center justify-between w-full border-t border-border pt-4 px-2">
<div className="text-muted-foreground">
<p className="text-xs">{user.name ?? "John Doe"}</p>
<p className="text-xs font-light pr-4">
{user.email ?? "john@doe.com"}
</p>
</div>
<Avatar className="h-10 w-10">
<AvatarFallback className="border-border border-2 text-muted-foreground">
{user.name
? user.name
?.split(" ")
.map((word) => word[0].toUpperCase())
.join("")
: "~"}
</AvatarFallback>
</Avatar>
</div>
</Link>
);
};

View File

@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { defaultLinks, additionalLinks } from "@/config/nav";
export interface SidebarLink {
title: string;
href: string;
icon: LucideIcon;
}
const SidebarItems = () => {
return (
<>
<SidebarLinkGroup links={defaultLinks} />
{additionalLinks.length > 0
? additionalLinks.map((l) => (
<SidebarLinkGroup
links={l.links}
title={l.title}
border
key={l.title}
/>
))
: null}
</>
);
};
export default SidebarItems;
const SidebarLinkGroup = ({
links,
title,
border,
}: {
links: SidebarLink[];
title?: string;
border?: boolean;
}) => {
const fullPathname = usePathname();
const pathname = "/" + fullPathname.split("/")[1];
return (
<div className={border ? "border-border border-t my-8 pt-4" : ""}>
{title ? (
<h4 className="px-2 mb-2 text-xs uppercase text-muted-foreground tracking-wider">
{title}
</h4>
) : null}
<ul>
{links.map((link) => (
<li key={link.title}>
<SidebarLink link={link} active={pathname === link.href} />
</li>
))}
</ul>
</div>
);
};
const SidebarLink = ({
link,
active,
}: {
link: SidebarLink;
active: boolean;
}) => {
return (
<Link
href={link.href}
className={`group transition-colors p-2 inline-block hover:bg-popover hover:text-primary text-muted-foreground text-xs hover:shadow rounded-md w-full${
active ? " text-primary font-semibold" : ""
}`}
>
<div className="flex items-center">
<div
className={cn(
"opacity-0 left-0 h-6 w-[4px] absolute rounded-r-lg bg-primary",
active ? "opacity-100" : "",
)}
/>
<link.icon className="h-3.5 mr-1" />
<span>{link.title}</span>
</div>
</Link>
);
};

View File

@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,29 @@
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
export default function SignIn() {
const { data: session, status } = useSession();
if (status === "loading") return <div>Loading...</div>;
if (session) {
return (
<div className="space-y-3">
<p>
Signed in as{" "}
<span className="font-medium">{session.user?.email}</span>
</p>
<Button variant={"destructive"} onClick={() => signOut({ callbackUrl: "/" })}>
Sign out
</Button>
</div>
);
}
return (
<div className="space-y-3">
<p>Not signed in </p>
<Button onClick={() => signIn()}>Sign in</Button>
</div>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
export default function SignOutBtn() {
const router = useRouter();
const handleSignOut = async () => {
const response = await fetch("/api/sign-out", {
method: "POST",
redirect: "manual",
});
if (response.status === 0) {
// redirected
// when using `redirect: "manual"`, response status 0 is returned
return router.refresh();
}
};
return (
<button onClick={handleSignOut} className="w-full text-left">
Sign out
</button>
);
}

View File

@ -0,0 +1,27 @@
import * as React from "react";
interface EmailTemplateProps {
firstName: string;
}
export const EmailTemplate: React.FC<Readonly<EmailTemplateProps>> = ({
firstName,
}) => (
<div>
<h1>Welcome, {firstName}!</h1>
<p>
Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim
labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet.
Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum
Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident.
Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex
occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat
officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in
Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non
excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco
ut ea consectetur et est culpa et culpa duis.
</p>
<hr />
<p>Sent with help from Resend and Kirimase 😊</p>
</div>
);

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

50
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

56
components/ui/button.tsx Normal file
View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

79
components/ui/card.tsx Normal file
View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

22
components/ui/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

45
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,45 @@
"use client"
import {
CircleCheck,
Info,
LoaderCircle,
OctagonX,
TriangleAlert,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheck className="h-4 w-4" />,
info: <Info className="h-4 w-4" />,
warning: <TriangleAlert className="h-4 w-4" />,
error: <OctagonX className="h-4 w-4" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

15
config/nav.ts Normal file
View File

@ -0,0 +1,15 @@
import { SidebarLink } from "@/components/SidebarItems";
import { Cog, Globe, User, HomeIcon } from "lucide-react";
type AdditionalLinks = {
title: string;
links: SidebarLink[];
};
export const defaultLinks: SidebarLink[] = [
{ href: "/dashboard", title: "Home", icon: HomeIcon },
{ href: "/account", title: "Account", icon: User },
{ href: "/settings", title: "Settings", icon: Cog },
];
export const additionalLinks: AdditionalLinks[] = [];

35
config/subscriptions.ts Normal file
View File

@ -0,0 +1,35 @@
export interface SubscriptionPlan {
id: string;
name: string;
description: string;
stripePriceId: string;
price: number;
features: Array<string>;
}
export const storeSubscriptionPlans: SubscriptionPlan[] = [
{
id: "pro",
name: "Pro",
description: "Pro tier that offers x, y, and z features.",
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID ?? "",
price: 1000,
features: ["Feature 1", "Feature 2", "Feature 3"],
},
{
id: "max",
name: "Max",
description: "Super Pro tier that offers x, y, and z features.",
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID ?? "",
price: 3000,
features: ["Feature 1", "Feature 2", "Feature 3"],
},
{
id: "ultra",
name: "Ultra",
description: "Ultra Pro tier that offers x, y, and z features.",
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID ?? "",
price: 5000,
features: ["Feature 1", "Feature 2", "Feature 3"],
},
];

11
drizzle.config.ts Normal file
View File

@ -0,0 +1,11 @@
import type { Config } from "drizzle-kit";
import { env } from "@/lib/env.mjs";
export default {
schema: "./lib/db/schema",
dialect: "postgresql",
out: "./lib/db/migrations",
dbCredentials: {
url: env.DATABASE_URL,
}
} satisfies Config;

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

20
kirimase.config.json Normal file
View File

@ -0,0 +1,20 @@
{
"hasSrc": false,
"packages": [
"shadcn-ui",
"drizzle",
"next-auth",
"resend",
"stripe"
],
"preferredPackageManager": "npm",
"t3": false,
"alias": "@",
"analytics": true,
"rootPath": "",
"componentLib": "shadcn-ui",
"driver": "pg",
"provider": "node-postgres",
"orm": "drizzle",
"auth": "next-auth"
}

11
lib/auth/Provider.tsx Normal file
View File

@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
type Props = {
children?: React.ReactNode;
};
export default function NextAuthProvider({ children }: Props) {
return <SessionProvider>{children}</SessionProvider>;
};

50
lib/auth/utils.ts Normal file
View File

@ -0,0 +1,50 @@
import { db } from "@/lib/db/index";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { DefaultSession, getServerSession, NextAuthOptions } from "next-auth";
import { Adapter } from "next-auth/adapters";
import { redirect } from "next/navigation";
import { env } from "@/lib/env.mjs"
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
};
}
}
export type AuthSession = {
session: {
user: {
id: string;
name?: string;
email?: string;
};
} | null;
};
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db) as Adapter,
callbacks: {
session: ({ session, user }) => {
session.user.id = user.id;
return session;
},
},
providers: [
],
};
export const getUserAuth = async () => {
const session = await getServerSession(authOptions);
return { session } as AuthSession;
};
export const checkAuth = async () => {
const { session } = await getUserAuth();
if (!session) redirect("/api/auth/signin");
};

8
lib/db/index.ts Normal file
View File

@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg"
import { env } from "@/lib/env.mjs";
export const pool = new Pool({
connectionString: env.DATABASE_URL,
});
export const db = drizzle(pool);

39
lib/db/migrate.ts Normal file
View File

@ -0,0 +1,39 @@
import { env } from "@/lib/env.mjs";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Client } from "pg";
const runMigrate = async () => {
if (!env.DATABASE_URL) {
throw new Error("DATABASE_URL is not defined");
}
const client = new Client({
connectionString: env.DATABASE_URL,
});
await client.connect();
const db = drizzle(client);
console.log("⏳ Running migrations...");
const start = Date.now();
await migrate(db, { migrationsFolder: 'lib/db/migrations' });
const end = Date.now();
console.log("✅ Migrations completed in", end - start, "ms");
process.exit(0);
};
runMigrate().catch((err) => {
console.error("❌ Migration failed");
console.error(err);
process.exit(1);
});

58
lib/db/schema/auth.ts Normal file
View File

@ -0,0 +1,58 @@
import {
timestamp,
pgTable,
text,
primaryKey,
integer,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "@auth/core/adapters";
export const users = pgTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
email: text("email").notNull(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey(account.provider, account.providerAccountId),
})
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey(vt.identifier, vt.token),
})
);

View File

@ -0,0 +1,27 @@
import {
pgTable,
primaryKey,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./auth";
export const subscriptions = pgTable(
"subscriptions",
{
userId: varchar("user_id", { length: 255 })
.unique()
.references(() => users.id),
stripeCustomerId: varchar("stripe_customer_id", { length: 255 }).unique(),
stripeSubscriptionId: varchar("stripe_subscription_id", {
length: 255,
}).unique(),
stripePriceId: varchar("stripe_price_id", { length: 255 }),
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
},
(table) => {
return {
pk: primaryKey(table.userId, table.stripeCustomerId),
};
}
);

4
lib/email/index.ts Normal file
View File

@ -0,0 +1,4 @@
import { Resend } from "resend";
import { env } from "@/lib/env.mjs";
export const resend = new Resend(env.RESEND_API_KEY);

6
lib/email/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { z } from "zod";
export const emailSchema = z.object({
name: z.string().min(3),
email: z.string().email(),
});

44
lib/env.mjs Normal file
View File

@ -0,0 +1,44 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import "dotenv/config";
export const env = createEnv({
server: {
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string().min(1),
NEXTAUTH_SECRET: process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL_URL ? z.string().min(1) : z.string().url()
),
RESEND_API_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
},
client: {
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: z.string().min(1),
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: z.string().min(1),
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: z.string().min(1), // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1),
},
// If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually
// runtimeEnv: {
// DATABASE_URL: process.env.DATABASE_URL,
// NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
// },
// For Next.js >= 13.4.4, you only need to destructure client variables:
experimental__runtimeEnv: {
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID,
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID,
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID, // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
},
});

6
lib/stripe/index.ts Normal file
View File

@ -0,0 +1,6 @@
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
apiVersion: "2024-06-20",
typescript: true,
});

View File

@ -0,0 +1,61 @@
import { storeSubscriptionPlans } from "@/config/subscriptions";
import { db } from "@/lib/db/index";
import { subscriptions } from "@/lib/db/schema/subscriptions";
import { eq } from "drizzle-orm";
import { stripe } from "@/lib/stripe/index";
import { getUserAuth } from "@/lib/auth/utils";
export async function getUserSubscriptionPlan() {
const { session } = await getUserAuth();
if (!session || !session.user) {
throw new Error("User not found.");
}
const [ subscription ] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.userId, session.user.id));
if (!subscription)
return {
id: undefined,
name: undefined,
description: undefined,
stripePriceId: undefined,
price: undefined,
stripeSubscriptionId: null,
stripeCurrentPeriodEnd: null,
stripeCustomerId: null,
isSubscribed: false,
isCanceled: false,
};
const isSubscribed =
subscription.stripePriceId &&
subscription.stripeCurrentPeriodEnd &&
subscription.stripeCurrentPeriodEnd.getTime() + 86_400_000 > Date.now();
const plan = isSubscribed
? storeSubscriptionPlans.find(
(plan) => plan.stripePriceId === subscription.stripePriceId
)
: null;
let isCanceled = false;
if (isSubscribed && subscription.stripeSubscriptionId) {
const stripePlan = await stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
);
isCanceled = stripePlan.cancel_at_period_end;
}
return {
...plan,
stripeSubscriptionId: subscription.stripeSubscriptionId,
stripeCurrentPeriodEnd: subscription.stripeCurrentPeriodEnd,
stripeCustomerId: subscription.stripeCustomerId,
isSubscribed,
isCanceled,
};
}

15
lib/utils.ts Normal file
View File

@ -0,0 +1,15 @@
import { customAlphabet } from "nanoid";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789");
export function absoluteUrl(path: string) {
return `${
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
}${path}`;
}

8
next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;

9784
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
package.json Normal file
View File

@ -0,0 +1,62 @@
{
"name": "tenantpilot",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx lib/db/migrate.ts",
"db:drop": "drizzle-kit drop",
"db:pull": "drizzle-kit introspect",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
},
"dependencies": {
"@auth/core": "^0.34.3",
"@auth/drizzle-adapter": "^1.11.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@stripe/stripe-js": "^8.5.2",
"@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.44.7",
"drizzle-zod": "^0.8.3",
"lucide-react": "^0.554.0",
"nanoid": "^5.1.6",
"next": "16.0.3",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"resend": "^6.5.0",
"sonner": "^2.0.7",
"stripe": "^20.0.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.15.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.7",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

76
tailwind.config.ts Normal file
View File

@ -0,0 +1,76 @@
const { fontFamily } = require("tailwindcss/defaultTheme")
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
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))",
},
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
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 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "esnext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"baseUrl": "./"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}