Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
14 changes: 12 additions & 2 deletions apps/app/app/(dashboard)/[orgSlug]/components/repo-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@repo/design-system/components/ui/sidebar";
import { LifeBuoyIcon } from "lucide-react";
import { GiftIcon, LifeBuoyIcon } from "lucide-react";
import Link from "next/link";
import { getUserOrganizations } from "@/lib/auth";
import { OrganizationSidebarGroup } from "./organization-sidebar-group";

export const RepoSidebar = async () => {
interface RepoSidebarProps {
orgSlug: string;
}

export const RepoSidebar = async ({ orgSlug }: RepoSidebarProps) => {
const userOrgs = await getUserOrganizations();
const organizations = await database.organization.findMany({
where: {
Expand Down Expand Up @@ -43,6 +47,12 @@ export const RepoSidebar = async () => {
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href={`/${orgSlug}/settings/referrals`}>
<GiftIcon className="size-4" />
<span>Referrals</span>
</Link>
</SidebarMenuButton>
<SidebarMenuButton asChild>
<Link href={"mailto:hayden@ultracite.ai"}>
<LifeBuoyIcon className="size-4" />
Expand Down
2 changes: 1 addition & 1 deletion apps/app/app/(dashboard)/[orgSlug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const OrgLayout = async ({ children, params }: LayoutProps<"/[orgSlug]">) => {

return (
<SidebarProvider className="min-h-auto flex-1">
<RepoSidebar />
<RepoSidebar orgSlug={orgSlug} />
<SidebarInset>
{!isSubscribed && (
<SubscriptionBanner organizationId={organization.id} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"use client";

import { Badge } from "@repo/design-system/components/ui/badge";
import { Button } from "@repo/design-system/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@repo/design-system/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/design-system/components/ui/table";
import {
CheckCircleIcon,
ClockIcon,
CopyIcon,
DollarSignIcon,
GiftIcon,
UsersIcon,
} from "lucide-react";
import { useState } from "react";
import type { ReferralCode } from "@/lib/referral/get-referral-code";
import type { ReferralStats } from "@/lib/referral/get-referral-stats";

interface ReferralDashboardProps {
referralCode: ReferralCode;
stats: ReferralStats;
}

const formatCurrency = (cents: number) => `$${(cents / 100).toFixed(2)}`;

const formatDate = (date: Date) => new Date(date).toLocaleDateString();

export const ReferralDashboard = ({
referralCode,
stats,
}: ReferralDashboardProps) => {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
await navigator.clipboard.writeText(referralCode.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};

const getStatusBadge = (status: "PENDING" | "COMPLETED" | "INVALID") => {
switch (status) {
case "COMPLETED":
return (
<Badge variant="default">
<CheckCircleIcon className="mr-1 size-3" />
Completed
</Badge>
);
case "PENDING":
return (
<Badge variant="secondary">
<ClockIcon className="mr-1 size-3" />
Pending
</Badge>
);
default:
return <Badge variant="destructive">Invalid</Badge>;
}
};

return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<GiftIcon className="size-5" />
Your Referral Link
</CardTitle>
<CardDescription>
Share this link to earn $5 credit for both you and your referral
when they subscribe.
</CardDescription>
</div>
<Button onClick={handleCopy} variant="outline">
<CopyIcon className="size-4" />
{copied ? "Copied!" : "Copy"}
</Button>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm">
{referralCode.url}
</code>
</div>
</CardContent>
</Card>

<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="font-medium text-sm">
Credit Balance
</CardTitle>
<DollarSignIcon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="font-bold text-2xl">
{formatCurrency(stats.creditBalanceCents)}
</p>
<p className="text-muted-foreground text-xs">
Applied to future invoices
</p>
</CardContent>
</Card>

<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="font-medium text-sm">
Total Referrals
</CardTitle>
<UsersIcon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="font-bold text-2xl">{stats.totalReferrals}</p>
<p className="text-muted-foreground text-xs">
{stats.completedReferrals} completed, {stats.pendingReferrals}{" "}
pending
</p>
</CardContent>
</Card>

<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="font-medium text-sm">Total Earned</CardTitle>
<GiftIcon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="font-bold text-2xl">
{formatCurrency(stats.totalEarnedCents)}
</p>
<p className="text-muted-foreground text-xs">
From referral bonuses
</p>
</CardContent>
</Card>
</div>

{stats.referralReceived && (
<Card>
<CardHeader>
<CardTitle className="text-lg">You Were Referred</CardTitle>
<CardDescription>
You were referred by {stats.referralReceived.referrerName}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
{getStatusBadge(stats.referralReceived.status)}
<span className="text-muted-foreground text-sm">
{stats.referralReceived.status === "COMPLETED"
? "You received $5 credit"
: "Credit applied after first payment"}
</span>
</div>
</CardContent>
</Card>
)}

{stats.referralsGiven.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Referral History</CardTitle>
<CardDescription>
Organizations you have referred to Ultracite
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization</TableHead>
<TableHead>Status</TableHead>
<TableHead>Referred On</TableHead>
<TableHead>Credited</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.referralsGiven.map((referral) => (
<TableRow key={referral.id}>
<TableCell className="font-medium">
{referral.referredName}
</TableCell>
<TableCell>{getStatusBadge(referral.status)}</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(referral.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground">
{referral.creditedAt
? formatDate(referral.creditedAt)
: "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
};
48 changes: 48 additions & 0 deletions apps/app/app/(dashboard)/[orgSlug]/settings/referrals/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { getCurrentUser, getOrganizationBySlug } from "@/lib/auth";
import { getReferralCode } from "@/lib/referral/get-referral-code";
import { getReferralStats } from "@/lib/referral/get-referral-stats";
import { ReferralDashboard } from "./components/referral-dashboard";

export const metadata: Metadata = {
title: "Referrals",
description: "Manage your referral program and earn credits.",
};

const ReferralsPage = async ({
params,
}: PageProps<"/[orgSlug]/settings/referrals">) => {
const user = await getCurrentUser();

if (!user) {
redirect("/auth/login");
}

const { orgSlug } = await params;
const organization = await getOrganizationBySlug(orgSlug);

if (!organization) {
notFound();
}

const [referralCode, stats] = await Promise.all([
getReferralCode(organization.id),
getReferralStats(organization.id, organization.stripeCustomerId),
]);

return (
<div className="container relative mx-auto grid w-full gap-8 px-4 py-8 2xl:max-w-4xl">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Referrals</h1>
<p className="text-muted-foreground">
Earn $5 credit for each organization you refer. Your referral gets $5
too.
</p>
</div>
<ReferralDashboard referralCode={referralCode} stats={stats} />
</div>
);
};

export default ReferralsPage;
45 changes: 45 additions & 0 deletions apps/app/app/api/referral/code/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { database } from "@repo/backend/database";
import { type NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import {
getOrCreateReferralCode,
getReferralUrl,
} from "@/lib/referral/generate-code";

export const GET = async (request: NextRequest) => {
const user = await getCurrentUser();

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const organizationId = request.nextUrl.searchParams.get("organizationId");

if (!organizationId) {
return NextResponse.json(
{ error: "Organization ID required" },
{ status: 400 }
);
}

// Verify user is a member of this organization
const membership = await database.organizationMember.findFirst({
where: {
userId: user.id,
organizationId,
},
});

if (!membership) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}

const referralCode = await getOrCreateReferralCode(organizationId);
const referralUrl = getReferralUrl(referralCode.code);

return NextResponse.json({
code: referralCode.code,
url: referralUrl,
timesUsed: referralCode.timesUsed,
});
};
Loading