mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-01-18 13:54:24 +01:00
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { MdLocalFireDepartment } from 'react-icons/md'
|
|
import Button from 'src/Components/Button/Button'
|
|
import { useAppSelector, usePressHolder, useResizeListener } from 'src/utils/hooks'
|
|
import { ComponentProps, SyntheticEvent, useRef, useState } from 'react'
|
|
import styles from './styles.module.scss'
|
|
import { random, randomItem, numberFormatter } from 'src/utils/helperFunctions'
|
|
import { useDebouncedCallback, useMountEffect, useThrottledCallback } from '@react-hookz/web'
|
|
import { UnionToObjectKeys } from 'src/utils/types/utils'
|
|
import { Portal } from '../Portal/Portal'
|
|
import { ThreeDots } from 'react-loader-spinner'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
|
|
|
|
|
|
interface Particle {
|
|
id: string,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
color: string
|
|
animation: 'fly-spark-1' | 'fly-spark-2',
|
|
animationSpeed: 1 | 2 | 3,
|
|
scale: number
|
|
}
|
|
|
|
type Props = {
|
|
votes: number,
|
|
onVote?: (amount: number, config: Partial<{
|
|
onSetteled: () => void;
|
|
onError: () => void;
|
|
onSuccess: () => void;
|
|
}>) => void,
|
|
fillType?: 'leftRight' | 'upDown' | "background" | 'radial',
|
|
direction?: 'horizontal' | 'vertical'
|
|
disableCounter?: boolean
|
|
disableShake?: boolean
|
|
hideVotesCoun?: boolean
|
|
dense?: boolean
|
|
size?: 'sm' | 'md'
|
|
resetCounterOnRelease?: boolean
|
|
} & Omit<ComponentProps<typeof Button>, 'children'>
|
|
|
|
|
|
const btnPadding: UnionToObjectKeys<Props, 'direction', any> = {
|
|
horizontal: {
|
|
sm: '',
|
|
md: '',
|
|
} as UnionToObjectKeys<Props, 'size'>,
|
|
vertical: {
|
|
sm: 'p-8',
|
|
md: '',
|
|
} as UnionToObjectKeys<Props, 'size'>
|
|
}
|
|
|
|
type BtnState = 'ready' | 'voting' | 'loading' | "success" | "fail";
|
|
|
|
export default function VoteButton({
|
|
votes,
|
|
onVote = () => { },
|
|
fillType = 'leftRight',
|
|
direction = 'horizontal',
|
|
disableCounter = false,
|
|
disableShake = true,
|
|
hideVotesCoun = false,
|
|
dense = false,
|
|
resetCounterOnRelease = true,
|
|
...props }: Props) {
|
|
const [voteCnt, setVoteCnt] = useState(0)
|
|
const voteCntRef = useRef(0);
|
|
const btnContainerRef = useRef<HTMLDivElement>(null!!)
|
|
const [btnShakeClass, setBtnShakeClass] = useState('')
|
|
const [sparks, setSparks] = useState<Particle[]>([]);
|
|
const [incrementsCount, setIncrementsCount] = useState(0);
|
|
const totalIncrementsCountRef = useRef(0)
|
|
const currentIncrementsCountRef = useRef(0);
|
|
const [increments, setIncrements] = useState<Array<{ id: string, value: number }>>([]);
|
|
const [btnPosition, setBtnPosition] = useState<{ top: number, left: number, width: number, height: number }>();
|
|
const [btnState, setBtnState] = useState<BtnState>('ready');
|
|
|
|
|
|
const doVote = useDebouncedCallback(() => {
|
|
setBtnState('loading');
|
|
const amount = voteCntRef.current;
|
|
onVote(amount, {
|
|
onSuccess: () => {
|
|
setBtnState("success");
|
|
spawnSparks(10);
|
|
},
|
|
onError: () => setBtnState('fail'),
|
|
onSetteled: () => {
|
|
setVoteCnt(v => v - amount);
|
|
setTimeout(() => {
|
|
setBtnState("ready")
|
|
if (resetCounterOnRelease) {
|
|
setIncrementsCount(0);
|
|
totalIncrementsCountRef.current = 0;
|
|
currentIncrementsCountRef.current = 0;
|
|
}
|
|
voteCntRef.current = 0;
|
|
}, 2000);
|
|
}
|
|
});
|
|
|
|
}, [], 1500);
|
|
|
|
const spawnSparks = (cnt = 5) => {
|
|
const newSparks = Array(cnt).fill(0).map((_, idx) => ({
|
|
id: (Math.random() + 1).toString(),
|
|
offsetX: random(-10, 99),
|
|
offsetY: random(10, 90),
|
|
animation: randomItem(styles.fly_spark_1, styles.fly_spark_1),
|
|
animationSpeed: randomItem(1, 1.5, 2),
|
|
color: `hsl(0deg 86% ${random(50, 63)}%)`,
|
|
scale: random(1, 1.5)
|
|
}))
|
|
|
|
// if on mobile screen, reduce number of sparks particles to 60%
|
|
setSparks(oldSparks => [...oldSparks, ...newSparks])
|
|
setTimeout(() => {
|
|
setSparks(s => {
|
|
return s.filter(spark => !newSparks.some(newSpark => newSpark.id === spark.id))
|
|
})
|
|
|
|
}, 2 * 1000)
|
|
}
|
|
|
|
const clickIncrement = () => {
|
|
if (!disableShake)
|
|
setBtnShakeClass(s => s === styles.clicked_2 ? styles.clicked_1 : styles.clicked_2)
|
|
|
|
|
|
const _incStep = Math.ceil((currentIncrementsCountRef.current + 1) / 5);
|
|
|
|
currentIncrementsCountRef.current += 1;
|
|
totalIncrementsCountRef.current += 1;
|
|
|
|
setIncrementsCount(v => totalIncrementsCountRef.current);
|
|
|
|
if (!disableCounter)
|
|
setIncrements(v => {
|
|
const genId = Math.random().toString();
|
|
setTimeout(() => {
|
|
setIncrements(v => v.filter(e => e.id !== genId))
|
|
}, 500)
|
|
return [...v, { id: genId, value: _incStep }]
|
|
});
|
|
|
|
setVoteCnt(s => {
|
|
const newValue = s + _incStep;
|
|
voteCntRef.current = newValue;
|
|
return newValue;
|
|
})
|
|
|
|
// Each time the button make 5 increments, spawn some flames
|
|
if (totalIncrementsCountRef.current && totalIncrementsCountRef.current % 5 === 0)
|
|
spawnSparks(5);
|
|
|
|
|
|
doVote();
|
|
}
|
|
|
|
const onHold = useThrottledCallback(clickIncrement, [], 150)
|
|
|
|
const { onPressDown, onPressUp } = usePressHolder(onHold, 200);
|
|
|
|
const handlePressDown = () => {
|
|
if (btnState !== 'ready' && btnState !== 'voting') return;
|
|
|
|
setBtnState('voting');
|
|
onPressDown();
|
|
}
|
|
|
|
const handlePressUp = (event?: SyntheticEvent) => {
|
|
if (btnState !== 'voting') return;
|
|
|
|
if (event?.preventDefault) event.preventDefault();
|
|
|
|
onPressUp();
|
|
onHold();
|
|
}
|
|
|
|
useMountEffect(() => {
|
|
const bodyRect = document.body.getBoundingClientRect();
|
|
const btnRect = btnContainerRef.current.getBoundingClientRect()
|
|
setBtnPosition({
|
|
top: btnRect.top - bodyRect.top,
|
|
left: btnRect.left - bodyRect.left,
|
|
width: btnRect.width,
|
|
height: btnRect.height
|
|
});
|
|
|
|
})
|
|
|
|
useResizeListener(() => {
|
|
const bodyRect = document.body.getBoundingClientRect();
|
|
const btnRect = btnContainerRef.current.getBoundingClientRect()
|
|
setBtnPosition({
|
|
top: btnRect.top - bodyRect.top,
|
|
left: btnRect.left - bodyRect.left,
|
|
width: btnRect.width,
|
|
height: btnRect.height
|
|
});
|
|
}, { debounce: 300 })
|
|
|
|
return (
|
|
<button
|
|
onMouseDown={handlePressDown}
|
|
onMouseUp={handlePressUp}
|
|
onMouseLeave={() => onPressUp()}
|
|
onTouchStart={handlePressDown}
|
|
onTouchEnd={handlePressUp}
|
|
|
|
className={`${styles.vote_button} relative noselect border-0`}
|
|
style={{
|
|
"--increments": incrementsCount,
|
|
"--offset": `${(incrementsCount ? (incrementsCount % 5 === 0 ? 5 : incrementsCount % 5) : 0) * 20}%`,
|
|
"--bg-color": fillType !== 'background' ?
|
|
'hsl(0deg 86% max(calc((93 - var(--increments) / 3) * 1%), 68%))'
|
|
:
|
|
"hsl(0deg 86% max(calc((100 - var(--increments) / 2) * 1%), 68%))",
|
|
} as any}
|
|
{...props}
|
|
>
|
|
|
|
<div
|
|
ref={btnContainerRef}
|
|
className={`
|
|
${styles.btn_content}
|
|
relative rounded-lg text-gray-600 ${!incrementsCount && 'bg-gray-50 hover:bg-gray-100'}
|
|
${direction === 'vertical' ?
|
|
dense ? "py-4 px-12" : "py-8 px-20"
|
|
:
|
|
dense ? "py-4 px-8" : "p-8 min-w-[80px]"}
|
|
${voteCntRef.current > 0 && "outline"} active:outline outline-1 outline-red-500
|
|
${btnShakeClass}
|
|
`}
|
|
|
|
>
|
|
<div
|
|
className={`
|
|
${styles.color_overlay}
|
|
${fillType === 'upDown' && styles.color_overlay__upDown}
|
|
${fillType === 'leftRight' && styles.color_overlay__leftRight}
|
|
${fillType === 'background' && styles.color_overlay__background}
|
|
${fillType === 'radial' && styles.color_overlay__radial}
|
|
`}
|
|
>
|
|
<div></div>
|
|
</div>
|
|
<div className={`
|
|
relative z-10
|
|
${incrementsCount ? "text-red-800" : "text-gray-500"}
|
|
flex justify-center items-center gap-8 text-center ${direction === 'vertical' && "flex-col !text-center"}
|
|
`}>
|
|
<MdLocalFireDepartment
|
|
className={`text-body2 ${incrementsCount ? "text-red-600" : "text-gray-400"}`}
|
|
|
|
/>{!hideVotesCoun && <span className="align-middle w-[4ch]"> {numberFormatter(votes + voteCnt)}</span>}
|
|
</div>
|
|
<AnimatePresence>
|
|
{(btnState === 'loading' || btnState === 'fail') &&
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className={styles.loading}>
|
|
<ThreeDots width={20} color="#dc2626" />
|
|
</motion.div>
|
|
}
|
|
{btnState === 'success' &&
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className={styles.success}>
|
|
+{numberFormatter(voteCntRef.current)}
|
|
</motion.div>
|
|
}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<Portal id='effects-container'>
|
|
<div
|
|
className='absolute pointer-events-none'
|
|
style={btnPosition && {
|
|
position: 'absolute',
|
|
top: btnPosition.top,
|
|
left: btnPosition.left,
|
|
width: btnPosition.width,
|
|
height: btnPosition.height,
|
|
pointerEvents: 'none'
|
|
}}
|
|
>
|
|
|
|
{increments.map(increment => <span
|
|
key={increment.id}
|
|
className={styles.vote_counter}
|
|
>+{increment.value}</span>)}
|
|
{sparks.map(spark =>
|
|
<div
|
|
key={spark.id}
|
|
className={styles.spark}
|
|
style={{
|
|
"--offsetX": spark.offsetX,
|
|
"--offsetY": spark.offsetY,
|
|
"--animationSpeed": spark.animationSpeed,
|
|
"--scale": spark.scale,
|
|
"animationName": spark.animation,
|
|
"color": spark.color
|
|
} as any}
|
|
><MdLocalFireDepartment className='' /></div>)
|
|
}
|
|
</div>
|
|
</Portal>
|
|
<div
|
|
className={styles.spark}
|
|
|
|
><MdLocalFireDepartment className='' /></div>
|
|
</button>
|
|
|
|
)
|
|
}
|