parent
0fce5b89ab
commit
95a369849a
2 changed files with 194 additions and 5 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue