feat: implement user streak

feat/streak
Arik Chakma 4 months ago
parent 0fce5b89ab
commit 95a369849a
  1. 190
      src/components/AccountStreak/AccountStreak.tsx
  2. 9
      src/components/Navigation/Navigation.astro

@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { ChevronDown, Flame, X } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
type StreakResponse = {
count: number;
longestCount: number;
previousCount?: number | null;
firstVisitAt: Date;
lastVisitAt: Date;
};
type AccountStreakProps = {};
export function AccountStreak(props: AccountStreakProps) {
const toast = useToast();
const dropdownRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [accountStreak, setAccountStreak] = useState<StreakResponse>({
count: 0,
longestCount: 0,
firstVisitAt: new Date(),
lastVisitAt: new Date(),
});
const [showDropdown, setShowDropdown] = useState(false);
const loadAccountStreak = async () => {
if (!isLoggedIn()) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<StreakResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-streak`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to load account streak');
setIsLoading(false);
return;
}
setAccountStreak(response);
setIsLoading(false);
};
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
});
useEffect(() => {
loadAccountStreak().finally(() => {});
}, []);
if (!isLoggedIn() || isLoading) {
return null;
}
let { count: currentCount } = accountStreak;
const previousCount =
accountStreak?.previousCount || accountStreak?.count || 0;
// Adding one to show the current day
const currentCircleCount = Math.min(currentCount, 5) + 1;
// Adding one day to show the streak they broke
const leftCircleCount = Math.min(5 - currentCircleCount, previousCount) + 1;
const remainingCount = Math.max(0, 10 - leftCircleCount - currentCircleCount);
return (
<div className="relative z-[90] animate-fade-in">
<button
className="flex items-center justify-center rounded-lg bg-purple-100/10 p-1.5 px-2 hover:bg-purple-100/20 focus:outline-none"
onClick={() => setShowDropdown(true)}
>
<Flame className="size-5" />
<span className="ml-2 text-sm font-semibold">
{accountStreak?.count}
</span>
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 top-full z-50 w-[280px] translate-y-1 rounded-lg border border-gray-200 bg-white text-black shadow-lg"
>
<div className="p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-gray-500">
Current Streak
<span className="ml-2 font-medium text-black">
{accountStreak?.count || 0}
</span>
</p>
<p className="text-sm text-gray-500">
Longest Streak
<span className="ml-2 font-medium text-black">
{accountStreak?.longestCount || 0}
</span>
</p>
</div>
<div className="mt-6">
<div className="grid grid-cols-10 gap-2">
{[...Array(leftCircleCount)].map((_, index) => {
const isLast = index === leftCircleCount - 1;
const dayCount =
previousCount - leftCircleCount + index + 1 + 1;
return (
<div
className="flex flex-col items-center justify-center gap-1.5"
key={`left-${index}`}
>
<div
key={index}
className="flex size-5 items-center justify-center rounded-full bg-red-200"
>
{isLast ? (
<X className="size-3 stroke-[2.5px] text-red-600" />
) : (
<Flame className="size-3 text-red-600" />
)}
</div>
<span className="text-xs text-red-600">{dayCount}</span>
</div>
);
})}
{[...Array(currentCircleCount)].map((_, index) => {
const dayCount =
currentCount - currentCircleCount + index + 1 + 1;
const isLast = index === currentCircleCount - 1;
return (
<div
className="relative flex flex-col items-center justify-center gap-1.5"
key={`current-${index}`}
>
<div
key={index}
className={cn(
'flex size-5 items-center justify-center rounded-full',
isLast
? 'border-2 border-dashed border-gray-400'
: 'bg-purple-200',
)}
>
{!isLast && (
<Flame className="size-3 text-purple-500" />
)}
</div>
<span className="text-xs text-gray-600">{dayCount}</span>
{isLast && (
<ChevronDown className="absolute bottom-full left-1/2 h-4 w-4 -translate-x-1/2 transform stroke-[2.5px] text-gray-400" />
)}
</div>
);
})}
{[...Array(remainingCount)].map((_, index) => {
const dayCount = currentCount + index + 1 + 1;
return (
<div
className="flex flex-col items-center justify-center gap-1.5"
key={`remaining-${index}`}
>
<div
key={index}
className={cn(
'flex size-5 items-center justify-center rounded-full',
'bg-gray-200',
)}
></div>
<span className="text-xs text-gray-400">{dayCount}</span>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
);
}

@ -4,6 +4,7 @@ import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
import { AccountDropdown } from './AccountDropdown';
import NewIndicator from './NewIndicator.astro';
import { AccountStreak } from '../AccountStreak/AccountStreak';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@ -40,10 +41,7 @@ import NewIndicator from './NewIndicator.astro';
<a href='/get-started' class='text-gray-400 hover:text-white'>
Start Here
</a>
<a
href='/teams'
class='group relative text-gray-400 hover:text-white'
>
<a href='/teams' class='group relative text-gray-400 hover:text-white'>
Teams
</a>
<a href='/ai' class='text-gray-400 hover:text-white'> AI</a>
@ -68,7 +66,8 @@ import NewIndicator from './NewIndicator.astro';
<li data-guest-required class='hidden'>
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<li class='flex items-center gap-2'>
<AccountStreak client:only='react' />
<AccountDropdown client:only='react' />
<a

Loading…
Cancel
Save