feat: add referral user count (#7233)

* feat: add referral user count

* feat: add referrals leaderboard

* fix: update UI

* Update referral design

* Update invite friends UI

* Add leaderboard page

* Update leaderboard page

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/7322/head
Arik Chakma 2 months ago committed by GitHub
parent 06c242cf32
commit cc817b060c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      src/api/leaderboard.ts
  2. 22
      src/components/AccountStreak/AccountStreak.tsx
  3. 92
      src/components/AccountStreak/InviteFriends.tsx
  4. 17
      src/components/AuthenticationFlow/EmailSignupForm.tsx
  5. 33
      src/components/Leaderboard/LeaderboardPage.tsx
  6. 1
      src/stores/streak.ts

@ -20,6 +20,10 @@ export type ListLeaderboardStatsResponse = {
githubContributors: { githubContributors: {
currentMonth: LeaderboardUserDetails[]; currentMonth: LeaderboardUserDetails[];
}; };
referrals: {
currentMonth: LeaderboardUserDetails[];
lifetime: LeaderboardUserDetails[];
};
}; };
export function leaderboardApi(context: APIContext) { export function leaderboardApi(context: APIContext) {

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { Flame, X, Zap, ZapOff } from 'lucide-react'; import { Zap, ZapOff } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { StreakDay } from './StreakDay'; import { StreakDay } from './StreakDay';
import { import {
@ -11,15 +11,8 @@ import {
} from '../../stores/page.ts'; } from '../../stores/page.ts';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { $accountStreak } from '../../stores/streak.ts'; import { $accountStreak, type StreakResponse } from '../../stores/streak.ts';
import { InviteFriends } from './InviteFriends.tsx';
type StreakResponse = {
count: number;
longestCount: number;
previousCount?: number | null;
firstVisitAt: Date;
lastVisitAt: Date;
};
type AccountStreakProps = {}; type AccountStreakProps = {};
@ -184,11 +177,10 @@ export function AccountStreak(props: AccountStreakProps) {
<p className="-mt-[0px] mb-[1.5px] text-center text-xs tracking-wide text-slate-500"> <p className="-mt-[0px] mb-[1.5px] text-center text-xs tracking-wide text-slate-500">
Visit every day to keep your streak going! Visit every day to keep your streak going!
</p> </p>
<p className='text-xs mt-1.5 text-center'>
<a href="/leaderboard" className="text-purple-400 hover:underline underline-offset-2"> <InviteFriends
See how you compare to others refByUserCount={accountStreak?.refByUserCount || 0}
</a> />
</p>
</div> </div>
</div> </div>
)} )}

@ -0,0 +1,92 @@
import { Copy, Heart } from 'lucide-react';
import { useAuth } from '../../hooks/use-auth';
import { useCopyText } from '../../hooks/use-copy-text';
import { cn } from '../../lib/classname';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import {TrophyEmoji} from "../ReactIcons/TrophyEmoji.tsx";
type InviteFriendsProps = {
refByUserCount: number;
};
export function InviteFriends(props: InviteFriendsProps) {
const { refByUserCount } = props;
const user = useAuth();
const { copyText, isCopied } = useCopyText();
const referralLink = new URL(
`/signup?rc=${user?.id}`,
import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh',
).toString();
return (
<div className="-mx-4 mt-6 flex flex-col border-t border-dashed border-t-slate-700 px-4 pt-5 text-center text-sm">
<p className="font-medium text-slate-500">
Invite people to join roadmap.sh
</p>
<div className="my-4 flex flex-col items-center gap-3.5 rounded-lg bg-slate-900/40 pb-4 pt-5">
<div className="flex flex-row items-center justify-center gap-1.5">
{Array.from({ length: 10 }).map((_, index) => (
<Heart
key={index}
className={cn(
'size-[20px] fill-current',
index < refByUserCount ? 'text-yellow-300' : 'text-slate-700',
refByUserCount === 0 && index === 0 ? 'text-slate-500' : '',
)}
/>
))}
</div>
{refByUserCount === 0 && (
<p className="text-slate-500">You haven't invited anyone yet.</p>
)}
{refByUserCount > 0 && refByUserCount < 10 && (
<p className="text-slate-500">{refByUserCount} of 10 users joined</p>
)}
{refByUserCount >= 10 && (
<p className="text-slate-500">
🎉 You've invited {refByUserCount} users
</p>
)}
</div>
<p className="leading-normal text-slate-500">
Share{' '}
<button
onClick={() => {
copyText(referralLink);
}}
className={cn(
'rounded-md bg-slate-700 px-1.5 py-[0.5px] text-slate-300 hover:bg-slate-600',
{
'bg-green-500 text-black hover:bg-green-500': isCopied,
},
)}
>
{!isCopied ? 'this link' : 'the copied link'}{' '}
{!isCopied && (
<Copy
className="relative -top-[1.25px] inline-block size-3"
strokeWidth={3}
/>
)}
{isCopied && (
<CheckIcon additionalClasses="relative -top-[1.25px] inline-block size-3" />
)}
</button>{' '}
with anyone you think would benefit from roadmap.sh
</p>
<p className="mt-6 text-center text-xs">
<a
href="/leaderboard"
className="text-purple-400 underline-offset-2 hover:underline"
>
See how you rank on the leaderboard
</a>
</p>
</div>
);
}

@ -1,5 +1,7 @@
import { type FormEvent, useState } from 'react'; import { type FormEvent, useEffect, useState } from 'react';
import { httpPost } from '../../lib/http'; import { httpPost } from '../../lib/http';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { isLoggedIn, setAIReferralCode } from '../../lib/jwt';
type EmailSignupFormProps = { type EmailSignupFormProps = {
isDisabled?: boolean; isDisabled?: boolean;
@ -9,6 +11,9 @@ type EmailSignupFormProps = {
export function EmailSignupForm(props: EmailSignupFormProps) { export function EmailSignupForm(props: EmailSignupFormProps) {
const { isDisabled, setIsDisabled } = props; const { isDisabled, setIsDisabled } = props;
const { rc: referralCode } = getUrlParams() as {
rc?: string;
};
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
@ -47,6 +52,16 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
)}`; )}`;
}; };
useEffect(() => {
if (!referralCode || isLoggedIn()) {
deleteUrlParam('rc');
return;
}
setAIReferralCode(referralCode);
deleteUrlParam('rc');
}, []);
return ( return (
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}> <form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
<label htmlFor="name" className="sr-only"> <label htmlFor="name" className="sr-only">

@ -4,7 +4,7 @@ import type {
ListLeaderboardStatsResponse, ListLeaderboardStatsResponse,
} from '../../api/leaderboard'; } from '../../api/leaderboard';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { FolderKanban, GitPullRequest, Users2, Zap } from 'lucide-react'; import { FolderKanban, GitPullRequest, Users, Users2, Zap } from 'lucide-react';
import { TrophyEmoji } from '../ReactIcons/TrophyEmoji'; import { TrophyEmoji } from '../ReactIcons/TrophyEmoji';
import { SecondPlaceMedalEmoji } from '../ReactIcons/SecondPlaceMedalEmoji'; import { SecondPlaceMedalEmoji } from '../ReactIcons/SecondPlaceMedalEmoji';
import { ThirdPlaceMedalEmoji } from '../ReactIcons/ThirdPlaceMedalEmoji'; import { ThirdPlaceMedalEmoji } from '../ReactIcons/ThirdPlaceMedalEmoji';
@ -59,6 +59,23 @@ export function LeaderboardPage(props: LeaderboardPageProps) {
}, },
]} ]}
/> />
<LeaderboardLane
title="Most Referrals"
tabs={[
{
title: 'This Month',
users: stats.referrals.currentMonth,
emptyIcon: <Users className="size-16 text-gray-300" />,
emptyText: 'No referrals this month',
},
{
title: 'Lifetime',
users: stats.referrals.lifetime,
emptyIcon: <Users className="size-16 text-gray-300" />,
emptyText: 'No referrals yet',
},
]}
/>
<LeaderboardLane <LeaderboardLane
title="Top Contributors" title="Top Contributors"
subtitle="Past 2 weeks" subtitle="Past 2 weeks"
@ -97,15 +114,17 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
return ( return (
<div className="flex min-h-[450px] flex-col overflow-hidden rounded-xl border bg-white shadow-sm"> <div className="flex min-h-[450px] flex-col overflow-hidden rounded-xl border bg-white shadow-sm">
<div className="mb-3 flex items-center justify-between gap-2 px-3 py-3"> <div className="mb-3 flex items-center justify-between gap-2 px-3 py-3">
<h3 className="text-base font-medium"> <h3 className="text-sm font-medium">
{title}{' '} {title}{' '}
{subtitle && ( {subtitle && (
<span className="text-sm font-normal text-gray-400 ml-1">{subtitle}</span> <span className="ml-1 text-sm font-normal text-gray-400">
{subtitle}
</span>
)} )}
</h3> </h3>
{tabs.length > 1 && ( {tabs.length > 1 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab === activeTab; const isActive = tab === activeTab;
@ -114,10 +133,10 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
key={tab.title} key={tab.title}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={cn( className={cn(
'text-sm font-medium underline-offset-2 transition-colors', 'text-xs transition-colors py-0.5 px-2 rounded-full',
{ {
'text-black underline': isActive, 'text-white bg-black': isActive,
'text-gray-400 hover:text-gray-600': !isActive, 'hover:bg-gray-200': !isActive,
}, },
)} )}
> >

@ -6,6 +6,7 @@ export type StreakResponse = {
previousCount?: number | null; previousCount?: number | null;
firstVisitAt: Date; firstVisitAt: Date;
lastVisitAt: Date; lastVisitAt: Date;
refByUserCount: number;
}; };
export const $accountStreak = atom<StreakResponse | undefined>(); export const $accountStreak = atom<StreakResponse | undefined>();

Loading…
Cancel
Save