mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-29 12:18:23 +01:00
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>
This commit is contained in:
parent
06c242cf32
commit
cc817b060c
@ -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>
|
||||||
)}
|
)}
|
||||||
|
92
src/components/AccountStreak/InviteFriends.tsx
Normal file
92
src/components/AccountStreak/InviteFriends.tsx
Normal file
@ -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">
|
||||||
@ -72,7 +87,7 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||||
placeholder="Email Address"
|
placeholder="Email Address"
|
||||||
value={email}
|
value={email}
|
||||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||||
|
@ -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…
x
Reference in New Issue
Block a user