mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-16 21:58:30 +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: {
|
||||
currentMonth: LeaderboardUserDetails[];
|
||||
};
|
||||
referrals: {
|
||||
currentMonth: LeaderboardUserDetails[];
|
||||
lifetime: LeaderboardUserDetails[];
|
||||
};
|
||||
};
|
||||
|
||||
export function leaderboardApi(context: APIContext) {
|
||||
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
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 { StreakDay } from './StreakDay';
|
||||
import {
|
||||
@ -11,15 +11,8 @@ import {
|
||||
} from '../../stores/page.ts';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { $accountStreak } from '../../stores/streak.ts';
|
||||
|
||||
type StreakResponse = {
|
||||
count: number;
|
||||
longestCount: number;
|
||||
previousCount?: number | null;
|
||||
firstVisitAt: Date;
|
||||
lastVisitAt: Date;
|
||||
};
|
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak.ts';
|
||||
import { InviteFriends } from './InviteFriends.tsx';
|
||||
|
||||
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">
|
||||
Visit every day to keep your streak going!
|
||||
</p>
|
||||
<p className='text-xs mt-1.5 text-center'>
|
||||
<a href="/leaderboard" className="text-purple-400 hover:underline underline-offset-2">
|
||||
See how you compare to others
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<InviteFriends
|
||||
refByUserCount={accountStreak?.refByUserCount || 0}
|
||||
/>
|
||||
</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 { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn, setAIReferralCode } from '../../lib/jwt';
|
||||
|
||||
type EmailSignupFormProps = {
|
||||
isDisabled?: boolean;
|
||||
@ -9,6 +11,9 @@ type EmailSignupFormProps = {
|
||||
export function EmailSignupForm(props: EmailSignupFormProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const { rc: referralCode } = getUrlParams() as {
|
||||
rc?: string;
|
||||
};
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = 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 (
|
||||
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
|
||||
<label htmlFor="name" className="sr-only">
|
||||
@ -72,7 +87,7 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
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"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
|
@ -4,7 +4,7 @@ import type {
|
||||
ListLeaderboardStatsResponse,
|
||||
} from '../../api/leaderboard';
|
||||
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 { SecondPlaceMedalEmoji } from '../ReactIcons/SecondPlaceMedalEmoji';
|
||||
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
|
||||
title="Top Contributors"
|
||||
subtitle="Past 2 weeks"
|
||||
@ -97,15 +114,17 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
|
||||
return (
|
||||
<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">
|
||||
<h3 className="text-base font-medium">
|
||||
<h3 className="text-sm font-medium">
|
||||
{title}{' '}
|
||||
{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>
|
||||
|
||||
{tabs.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
|
||||
@ -114,10 +133,10 @@ function LeaderboardLane(props: LeaderboardLaneProps) {
|
||||
key={tab.title}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
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-gray-400 hover:text-gray-600': !isActive,
|
||||
'text-white bg-black': isActive,
|
||||
'hover:bg-gray-200': !isActive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
@ -6,6 +6,7 @@ export type StreakResponse = {
|
||||
previousCount?: number | null;
|
||||
firstVisitAt: Date;
|
||||
lastVisitAt: Date;
|
||||
refByUserCount: number;
|
||||
};
|
||||
|
||||
export const $accountStreak = atom<StreakResponse | undefined>();
|
||||
|
Loading…
x
Reference in New Issue
Block a user