feat: referrals

This commit is contained in:
Shreyans Jain 2025-09-03 16:03:52 -07:00
parent 84fea4a698
commit ebc8c1f60d
No known key found for this signature in database
GPG key ID: A1B43A0A7B74CD2C
9 changed files with 380 additions and 259 deletions

View file

@ -21,6 +21,7 @@ import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useReferralTracking } from "@lib/use-referral-tracking"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { InstallPrompt } from "@/components/install-prompt"
@ -744,6 +745,9 @@ export default function Page() {
const router = useRouter()
const { user } = useAuth()
// Track referral if present
useReferralTracking(user)
useEffect(() => {
// save the token for chrome extension
const url = new URL(window.location.href)

View file

@ -1,156 +1,87 @@
"use client";
"use client"
import { $fetch } from "@lib/api";
import { Button } from "@ui/components/button";
import { $fetch } from "@repo/lib/api"
import { useQuery } from "@tanstack/react-query"
import { Button } from "@ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/components/card";
import { CheckIcon, CopyIcon, LoaderIcon, ShareIcon } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
} from "@ui/components/card"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { ShareIcon, LoaderIcon } from "lucide-react"
import Link from "next/link"
import { useParams } from "next/navigation"
export default function ReferralPage() {
const router = useRouter();
const params = useParams();
const referralCode = params.code as string;
export default function ReferralInvitePage() {
const params = useParams()
const code = params.code as string
const [isLoading, setIsLoading] = useState(true);
const [referralData, setReferralData] = useState<{
referrerName?: string;
valid: boolean;
} | null>(null);
const [copiedLink, setCopiedLink] = useState(false);
const referralLink = `https://supermemory.ai/ref/${referralCode}`;
// Verify referral code and get referrer info
useEffect(() => {
async function checkReferral() {
if (!referralCode) {
setIsLoading(false);
return;
const {
data: referrerData,
isLoading,
error,
} = useQuery({
queryKey: ["referral", code],
queryFn: async () => {
if (!code) {
throw new Error("Missing referral code")
}
try {
// Check if referral code is valid
// For now, we'll assume it's valid - in the future this should call an API
setReferralData({
valid: true,
referrerName: "A supermemory user", // Placeholder - should come from API
});
} catch (error) {
console.error("Error checking referral:", error);
setReferralData({ valid: false });
} finally {
setIsLoading(false);
const response = await $fetch("@get/referral/:code", {
params: {
code,
},
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to load referral information",
)
}
}
checkReferral();
}, [referralCode]);
return response.data
},
enabled: !!code,
retry: 1,
})
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(referralLink);
setCopiedLink(true);
toast.success("Referral link copied!");
setTimeout(() => setCopiedLink(false), 2000);
} catch (error) {
toast.error("Failed to copy link");
}
};
const handleShare = () => {
if (navigator.share) {
navigator.share({
title: "Join supermemory",
text: "I'm excited about supermemory - it's going to change how we store and interact with our memories!",
url: referralLink,
});
} else {
handleCopyLink();
}
};
const referrer = referrerData?.referrer
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<div className="flex flex-col items-center gap-4">
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
<p className="text-white/60">Checking invitation...</p>
<p className="text-white/60">Loading invitation...</p>
</div>
</div>
);
)
}
if (!referralData?.valid) {
if (error || !code) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<Card className="max-w-md w-full bg-[#1a1f2a] border-white/10">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold text-white">
Invalid Referral
</CardTitle>
<CardDescription className="text-white/60 mt-2">
This referral link is not valid or has expired.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center">
<Button asChild className="w-full">
<Link href="https://supermemory.ai">Go to supermemory</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<div className="max-w-lg w-full space-y-6">
{/* Welcome Card */}
<Card className="bg-[#1a1f2a] border-white/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<ShareIcon className="w-8 h-8 text-orange-500" />
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
<ShareIcon className="w-8 h-8 text-red-500" />
</div>
<CardTitle className="text-2xl font-bold text-white">
You're invited to supermemory!
Invalid Referral Link
</CardTitle>
<CardDescription className="text-white/60 mt-2">
{referralData.referrerName} invited you to join the future of
memory management.
{error instanceof Error ? error.message : "Invalid referral code"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="bg-[#0f1419] rounded-lg p-4 border border-white/10">
<h3 className="text-white font-semibold mb-2">
What is supermemory?
</h3>
<p className="text-white/70 text-sm leading-relaxed">
supermemory is an AI-powered personal knowledge base that
helps you store, organize, and interact with all your digital
memories - from documents and links to conversations and
ideas.
</p>
</div>
<div className="text-center">
<Link
href="https://supermemory.ai"
className="text-orange-500 hover:text-orange-400 text-sm underline"
href="https://supermemory.ai"
>
Learn more about supermemory
</Link>
@ -158,51 +89,62 @@ export default function ReferralPage() {
</div>
</CardContent>
</Card>
{/* Share Card */}
<Card className="bg-[#1a1f2a] border-white/10">
<CardHeader>
<CardTitle className="text-lg text-white">
Share with friends
</CardTitle>
<CardDescription className="text-white/60">
Help others discover supermemory and earn priority access.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1 px-3 py-2 bg-[#0f1419] border border-white/10 rounded-md">
<p className="text-white/80 text-sm font-mono truncate">
{referralLink}
</p>
</div>
<Button
onClick={handleCopyLink}
size="sm"
variant="outline"
className="shrink-0 border-white/10 hover:bg-white/5"
>
{copiedLink ? (
<CheckIcon className="w-4 h-4" />
) : (
<CopyIcon className="w-4 h-4" />
)}
</Button>
</div>
<Button
onClick={handleShare}
variant="outline"
className="w-full border-white/10 text-white hover:bg-white/5"
>
<ShareIcon className="w-4 h-4" />
Share this link
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<Card className="max-w-md w-full bg-[#1a1f2a] border-white/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<Avatar className="w-20 h-20">
<AvatarImage src={referrer?.image || ""} alt={referrer?.name} />
<AvatarFallback className="bg-orange-500/10 text-orange-500 text-2xl">
{referrer?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
<CardTitle className="text-2xl font-bold text-white">
You've been invited!
</CardTitle>
<CardDescription className="text-white/60 mt-2">
<span className="text-orange-400 font-semibold">
{referrer?.name}
</span>{" "}
invited you to join supermemory
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="bg-[#0f1419] rounded-lg p-4 border border-white/10">
<h3 className="text-white font-semibold mb-2">
What is supermemory?
</h3>
<p className="text-white/70 text-sm leading-relaxed">
supermemory is an AI-powered personal knowledge base that helps
you store, organize, and interact with all your digital
memories.
</p>
</div>
<Link href={`/login?ref=${code}`} className="block">
<Button className="w-full bg-orange-500 hover:bg-orange-600 text-white">
Get Started
</Button>
</Link>
<div className="text-center">
<Link
className="text-orange-500 hover:text-orange-400 text-sm underline"
href="https://supermemory.ai"
>
Learn more about supermemory
</Link>
</div>
</div>
</CardContent>
</Card>
</div>
);
)
}

View file

@ -105,9 +105,6 @@ export default function MigrateMCPPage() {
return (
<div className="min-h-screen bg-[#0f1419] overflow-hidden relative">
{/* Background elements */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/10 via-transparent to-purple-900/10" />
{/* Top navigation */}
<motion.div
initial={{ opacity: 0, y: -20 }}
@ -148,20 +145,9 @@ export default function MigrateMCPPage() {
transition={{ delay: 0.2 }}
className="text-center mb-8"
>
<div className="flex items-center justify-center mb-4">
<div className="relative">
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl" />
<div className="relative bg-blue-500/10 p-3 rounded-full border border-blue-500/20">
<Zap className="w-6 h-6 text-blue-400" />
</div>
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-2">
Upgrade supermemory MCP
Upgrade MCP
</h1>
<p className="text-slate-400 text-sm md:text-base">
Migrate your documents to the new MCP server
</p>
</motion.div>
{/* Form */}
@ -177,8 +163,7 @@ export default function MigrateMCPPage() {
htmlFor="mcpUrl"
className="text-sm font-medium text-slate-200 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
MCP URL
URL
</label>
<div className="relative">
<Input
@ -219,7 +204,7 @@ export default function MigrateMCPPage() {
>
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white border-0 py-3 rounded-xl font-medium shadow-lg hover:shadow-blue-500/25 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={
migrateMutation.isPending || !getUserIdFromUrl(mcpUrl)
}
@ -233,88 +218,12 @@ export default function MigrateMCPPage() {
) : (
<>
Start Upgrade
<ArrowRight className="ml-2 w-4 h-4" />
<ArrowRight className="w-4 h-4" />
</>
)}
</Button>
</motion.div>
</motion.form>
{/* Success/Error States */}
<AnimatePresence mode="wait">
{migrateMutation.isSuccess && migrateMutation.data && (
<motion.div
key="success"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="mt-6"
>
<div className="relative rounded-xl overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-green-500/10 to-emerald-500/10" />
<div className="relative p-4 border border-green-500/20 rounded-xl">
<div className="text-green-400">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-5 h-5" />
<p className="font-medium">
Migration completed successfully!
</p>
</div>
<p className="text-sm text-green-300/80 mb-3">
Migrated {migrateMutation.data.migratedCount}{" "}
documents
</p>
{migrateMutation.data.documentIds &&
migrateMutation.data.documentIds.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer hover:text-green-300 transition-colors text-sm font-medium">
View migrated document IDs
</summary>
<div className="mt-3 space-y-2 max-h-40 overflow-y-auto">
{migrateMutation.data.documentIds.map(
(id) => (
<code
key={id}
className="block text-xs bg-black/30 px-3 py-2 rounded-lg border border-green-500/10 text-green-200"
>
{id}
</code>
),
)}
</div>
</details>
)}
</div>
</div>
</div>
</motion.div>
)}
{migrateMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="mt-6"
>
<div className="relative rounded-xl overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-red-500/10 to-pink-500/10" />
<div className="relative p-4 border border-red-500/20 rounded-xl">
<div className="text-red-400">
<p className="font-medium mb-1">Migration failed</p>
<p className="text-sm text-red-300/80">
{migrateMutation.error?.message ||
"An unexpected error occurred"}
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>

View file

@ -8,6 +8,7 @@ import {
fetchSubscriptionStatus,
} from "@lib/queries"
import { Button } from "@repo/ui/components/button"
import { ReferralLink } from "@repo/ui/components/referral-link"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import {
@ -273,6 +274,9 @@ export function ProfileView() {
</div>
)}
{/* Referral Section */}
<ReferralLink className="mt-4" />
<Button
className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30"
onClick={handleLogout}

View file

@ -21,6 +21,10 @@ import {
SearchResponseSchema,
type SearchResult,
SettingsRequestSchema,
ReferralDetailsResponseSchema,
ReferralCodeResponseSchema,
TrackReferralRequestSchema,
TrackReferralResponseSchema,
} from "../validation/api"
// Settings response schema - this is custom to console (not in shared validation)
@ -191,6 +195,21 @@ export const apiSchema = createSchema({
"@get/waitlist/status": {
output: WaitlistStatusResponseSchema,
},
// Referral operations
"@get/referral/:code": {
output: ReferralDetailsResponseSchema,
params: z.object({
code: z.string(),
}),
},
"@get/referral/user/code": {
output: ReferralCodeResponseSchema,
},
"@post/referral/track": {
input: TrackReferralRequestSchema,
output: TrackReferralResponseSchema,
},
})
export const $fetch = createFetch({

View file

@ -0,0 +1,55 @@
import { $fetch } from "@repo/lib/api"
import { useEffect } from "react"
export function useReferralTracking(user: any) {
useEffect(() => {
if (!user) return
const trackReferral = async () => {
try {
// Check for pending referral code in localStorage
const pendingReferral = localStorage.getItem(
"supermemory-pending-referral",
)
// Also check URL params as a fallback
const urlParams = new URLSearchParams(window.location.search)
const urlReferral = urlParams.get("ref")
const referralCode = pendingReferral || urlReferral
if (!referralCode) return
// Send referral tracking request
const response = await $fetch("@post/referral/track", {
body: { referralCode },
})
if (response.data) {
// Clear the pending referral from localStorage
localStorage.removeItem("supermemory-pending-referral")
// Clear the ref param from URL
if (urlReferral) {
const url = new URL(window.location.href)
url.searchParams.delete("ref")
window.history.replaceState({}, "", url.toString())
}
console.log("Referral tracked successfully")
} else if (response.error) {
console.error(
"Failed to track referral:",
response.error?.message || "Unknown error",
)
}
} catch (error) {
console.error("Error tracking referral:", error)
}
}
// Only track referral for new users (you might want to add a check here)
// For now, we'll attempt to track for any user that has a pending referral
trackReferral()
}, [user])
}

View file

@ -0,0 +1,138 @@
"use client";
import { $fetch } from "@repo/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "./button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./card";
import { Input } from "./input";
import { CheckIcon, CopyIcon, ShareIcon, LoaderIcon } from "lucide-react";
interface ReferralLinkProps {
className?: string;
}
export function ReferralLink({ className }: ReferralLinkProps) {
const [copied, setCopied] = useState(false);
const {
data: referralData,
isLoading,
error,
} = useQuery({
queryKey: ["referral-code"],
queryFn: async () => {
const response = await $fetch("@get/referral/user/code");
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch referral code",
);
}
return response.data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const referralLink = referralData?.referralCode
? `https://app.supermemory.ai/ref/${referralData.referralCode}`
: "";
const copyToClipboard = async () => {
if (!referralLink) return;
try {
await navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const shareReferralLink = async () => {
if (!referralLink) return;
if (navigator.share) {
try {
await navigator.share({
title: "Join supermemory!",
text: "I'm using supermemory to organize and interact with all my digital memories. Join me!",
url: referralLink,
});
} catch (err) {
// User cancelled or share failed
console.log("Share cancelled or failed:", err);
}
} else {
// Fallback to copy
copyToClipboard();
}
};
return (
<Card className={`bg-[#1a1f2a] border-white/10 ${className}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShareIcon className="w-5 h-5 text-orange-500" />
Invite Friends
</CardTitle>
<CardDescription className="text-white/60">
Share your unique referral link with friends to invite them to
supermemory
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<LoaderIcon className="w-6 h-6 text-orange-500 animate-spin" />
</div>
) : error ? (
<div className="text-red-400 text-sm">
{error instanceof Error
? error.message
: "Failed to load referral code"}
</div>
) : (
<>
<div className="flex gap-2">
<Input
value={referralLink}
readOnly
className="bg-[#0f1419] border-white/10 text-white/80"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="border-white/10 hover:bg-white/10"
>
{copied ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon className="w-4 h-4 text-white/60" />
)}
</Button>
</div>
<Button
onClick={shareReferralLink}
className="w-full bg-orange-500 hover:bg-orange-600 text-white"
>
<ShareIcon className="w-4 h-4 mr-2" />
Share Referral Link
</Button>
</>
)}
</CardContent>
</Card>
);
}

View file

@ -43,28 +43,45 @@ export function LoginPage({
// Get redirect URL from query params
const redirectUrl = params.get("redirect");
// Get referral code from query params
const referralCode = params.get("ref");
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin;
let callbackUrl = origin;
if (redirectUrl) {
// Validate that the redirect URL is safe (same origin or allow external based on your security requirements)
try {
const url = new URL(redirectUrl, origin);
return url.toString();
callbackUrl = url.toString();
} catch {
// If redirect URL is invalid, fall back to origin
return origin;
callbackUrl = origin;
}
}
return origin;
// Add referral code to callback URL if present
if (referralCode) {
const url = new URL(callbackUrl);
url.searchParams.set("ref", referralCode);
return url.toString();
}
return callbackUrl;
};
// Load last used method from localStorage on mount
// Load last used method from localStorage on mount and save referral code
useEffect(() => {
const savedMethod = localStorage.getItem("supermemory-last-login-method");
setLastUsedMethod(savedMethod);
}, []);
// Store referral code in localStorage if present
if (referralCode) {
localStorage.setItem("supermemory-pending-referral", referralCode);
}
}, [referralCode]);
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {

View file

@ -1384,3 +1384,36 @@ export const BulkDeleteMemoriesResponseSchema = z
.openapi({
description: "Response for bulk memory deletion",
})
// Referral schemas
export const ReferrerSchema = z.object({
id: z.string(),
name: z.string(),
image: z.string().optional(),
})
export const ReferralDetailsResponseSchema = z.object({
referrer: ReferrerSchema,
})
export const ReferralCodeResponseSchema = z.object({
referralCode: z.string(),
})
export const TrackReferralRequestSchema = z.object({
referralCode: z.string(),
})
export const TrackReferralResponseSchema = z.object({
success: z.boolean(),
referrerId: z.string(),
})
// Referral type exports
export type Referrer = z.infer<typeof ReferrerSchema>
export type ReferralDetailsResponse = z.infer<
typeof ReferralDetailsResponseSchema
>
export type ReferralCodeResponse = z.infer<typeof ReferralCodeResponseSchema>
export type TrackReferralRequest = z.infer<typeof TrackReferralRequestSchema>
export type TrackReferralResponse = z.infer<typeof TrackReferralResponseSchema>