-
-
Notifications
You must be signed in to change notification settings - Fork 93
526 feat referral system for ultracite cloud #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
haydenbleasel
merged 18 commits into
main
from
526-feat-referral-system-for-ultracite-cloud
Feb 7, 2026
Merged
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
cdc4eb1
Draft referral system
haydenbleasel 8737914
Update sidebar
haydenbleasel 201742c
Fix env vars
haydenbleasel 050664e
Shift to RSC data fetching
haydenbleasel 8ee2e58
Update repo-sidebar.tsx
haydenbleasel 5940fba
Revert "Fix env vars"
haydenbleasel 836a21f
Update initialize.test.ts
haydenbleasel e9a3d93
Fix Cursor issues
haydenbleasel 7b8e3eb
Fix Cursor issues
haydenbleasel a8d05a1
Fix Cursor issues
haydenbleasel 6798f25
Merge branch 'main' into 526-feat-referral-system-for-ultracite-cloud
haydenbleasel 2c82154
Cursor fixes
haydenbleasel de4a039
More Cursor fixes
haydenbleasel eeed7fc
Misc fixes
haydenbleasel 043a195
Ultracite fixes
haydenbleasel f886b89
Cursor bugbot fix
haydenbleasel f463ae0
Update generate-code.ts
haydenbleasel 750d614
Update bun.lock
haydenbleasel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
215 changes: 215 additions & 0 deletions
215
apps/app/app/(dashboard)/[orgSlug]/settings/referrals/components/referral-dashboard.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
48
apps/app/app/(dashboard)/[orgSlug]/settings/referrals/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.