diff --git a/.gitignore b/.gitignore index 6013878..7db022d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ app.config.timestamp_*.js *.launch .settings/ tasks -main.go # Temp gitignore diff --git a/src/components/CountdownCircleTimer.tsx b/src/components/CountdownCircleTimer.tsx index d4c2858..f0a0050 100644 --- a/src/components/CountdownCircleTimer.tsx +++ b/src/components/CountdownCircleTimer.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, onMount, onCleanup } from "solid-js"; +import { Component, createSignal, createEffect, onCleanup } from "solid-js"; interface CountdownCircleTimerProps { duration: number; @@ -19,33 +19,65 @@ const CountdownCircleTimer: Component = (props) => { props.initialRemainingTime ?? props.duration ); - const progress = () => remainingTime() / props.duration; - const strokeDashoffset = () => circumference * (1 - progress()); + const progress = () => { + const time = remainingTime(); + if (isNaN(time) || !props.duration) return 0; + return Math.max(0, Math.min(1, time / props.duration)); + }; - onMount(() => { - const startTime = Date.now(); - const initialTime = remainingTime(); - let animationFrameId: number; + const strokeDashoffset = () => { + const prog = progress(); + if (isNaN(prog)) return 0; + return circumference * (1 - prog); + }; - const animate = () => { - const elapsed = (Date.now() - startTime) / 1000; - const newTime = Math.max(0, initialTime - elapsed); + // If isPlaying is set, manage countdown internally + createEffect(() => { + if (props.isPlaying !== undefined && props.isPlaying) { + const startTime = Date.now(); + const initialTime = props.initialRemainingTime ?? props.duration; + setRemainingTime(initialTime); - setRemainingTime(newTime); + let animationFrameId: number; - if (newTime <= 0) { - props.onComplete?.(); - return; - } + const animate = () => { + const elapsed = (Date.now() - startTime) / 1000; + const newTime = Math.max(0, initialTime - elapsed); + + setRemainingTime(newTime); + + if (newTime <= 0) { + props.onComplete?.(); + return; + } + + animationFrameId = requestAnimationFrame(animate); + }; animationFrameId = requestAnimationFrame(animate); - }; - animationFrameId = requestAnimationFrame(animate); + onCleanup(() => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }); - onCleanup(() => { - cancelAnimationFrame(animationFrameId); - }); + return; + } + + // Otherwise, just sync with the prop value - parent controls the countdown + const newTime = props.initialRemainingTime ?? props.duration; + + if (isNaN(newTime)) { + setRemainingTime(0); + return; + } + + setRemainingTime(newTime); + + if (newTime <= 0) { + props.onComplete?.(); + } }); return ( diff --git a/src/lib/useCountdown.ts b/src/lib/useCountdown.ts new file mode 100644 index 0000000..28c1f82 --- /dev/null +++ b/src/lib/useCountdown.ts @@ -0,0 +1,78 @@ +import { createSignal, onCleanup } from "solid-js"; + +export interface UseCountdownOptions { + /** + * Initial remaining time in seconds + */ + initialTime?: number; + /** + * Cookie name to read expiration time from + */ + cookieName?: string; + /** + * Callback when countdown reaches zero + */ + onComplete?: () => void; +} + +/** + * Hook for managing countdown timers from expiration timestamps + * @param options Configuration options + * @returns [remainingTime, startCountdown] + */ +export function useCountdown(options: UseCountdownOptions = {}) { + const [remainingTime, setRemainingTime] = createSignal( + options.initialTime ?? 0 + ); + let intervalId: ReturnType | null = null; + + const calculateRemaining = (expiresAt: string | Date) => { + const expires = + typeof expiresAt === "string" ? new Date(expiresAt) : expiresAt; + const remaining = expires.getTime() - Date.now(); + const remainingInSeconds = remaining / 1000; + + if (remainingInSeconds <= 0) { + setRemainingTime(0); + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + options.onComplete?.(); + } else { + setRemainingTime(remainingInSeconds); + } + }; + + const startCountdown = (expiresAt: string | Date) => { + // Clear any existing interval + if (intervalId !== null) { + clearInterval(intervalId); + } + + // Calculate immediately + calculateRemaining(expiresAt); + + // Then update every second + intervalId = setInterval(() => calculateRemaining(expiresAt), 1000); + }; + + const stopCountdown = () => { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }; + + // Cleanup on unmount + onCleanup(() => { + stopCountdown(); + }); + + return { + remainingTime, + startCountdown, + stopCountdown, + setRemainingTime + }; +} diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index 0fe3a8d..35e4ab3 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -1,4 +1,4 @@ -import { createSignal, onMount, onCleanup, Show } from "solid-js"; +import { createSignal, onMount, createEffect, Show } from "solid-js"; import { useSearchParams, useNavigate, @@ -15,6 +15,7 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import RevealDropDown from "~/components/RevealDropDown"; import Input from "~/components/ui/Input"; import { Button } from "~/components/ui/Button"; +import { useCountdown } from "~/lib/useCountdown"; import type { UserProfile } from "~/types/user"; import { getCookie, setCookie } from "vinxi/http"; import { z } from "zod"; @@ -154,7 +155,6 @@ export default function ContactPage() { deferStream: true }); - const [countDown, setCountDown] = createSignal(0); const [emailSent, setEmailSent] = createSignal( searchParams.success === "true" ); @@ -165,36 +165,11 @@ export default function ContactPage() { const [user, setUser] = createSignal(null); const [jsEnabled, setJsEnabled] = createSignal(false); - let timerIdRef: ReturnType | null = null; - - const calcRemainder = (timer: string) => { - const expires = new Date(timer); - const remaining = expires.getTime() - Date.now(); - const remainingInSeconds = remaining / 1000; - - if (remainingInSeconds <= 0) { - setCountDown(0); - if (timerIdRef !== null) { - clearInterval(timerIdRef); - } - } else { - setCountDown(remainingInSeconds); - } - }; + const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); onMount(() => { setJsEnabled(true); - const serverData = contactData(); - if (serverData?.remainingTime) { - setCountDown(serverData.remainingTime); - } - - const timer = getClientCookie("contactRequestSent"); - if (timer) { - timerIdRef = setInterval(() => calcRemainder(timer), 1000); - } - api.user.getProfile .query() .then((userData) => { @@ -203,25 +178,28 @@ export default function ContactPage() { } }) .catch(() => {}); + }); - if (searchParams.success || searchParams.error) { - const timer = setTimeout(() => { - const newUrl = - location.pathname + - (viewer() !== "default" ? `?viewer=${viewer()}` : ""); - navigate(newUrl, { replace: true }); - setEmailSent(false); - setError(""); - }, 5000); - - onCleanup(() => clearTimeout(timer)); + createEffect(() => { + // Try server data first (more accurate) + const serverData = contactData(); + if (serverData?.remainingTime && serverData.remainingTime > 0) { + const expirationTime = new Date( + Date.now() + serverData.remainingTime * 1000 + ); + startCountdown(expirationTime); + return; } - onCleanup(() => { - if (timerIdRef !== null) { - clearInterval(timerIdRef); + // Fall back to client cookie if server data not available yet + const timer = getClientCookie("contactRequestSent"); + if (timer) { + try { + startCountdown(timer); + } catch (e) { + console.error("Failed to start countdown from cookie:", e); } - }); + } }); const sendEmailTrigger = async (e: Event) => { @@ -251,13 +229,11 @@ export default function ContactPage() { setError(""); (e.target as HTMLFormElement).reset(); - const timer = getClientCookie("contactRequestSent"); - if (timer) { - if (timerIdRef !== null) { - clearInterval(timerIdRef); - } - timerIdRef = setInterval(() => calcRemainder(timer), 1000); - } + // Set countdown directly - cookie might not be readable immediately + const expirationTime = new Date( + Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS + ); + startCountdown(expirationTime); } } catch (err: any) { setError(err.message || "An error occurred"); @@ -351,9 +327,10 @@ export default function ContactPage() { }; const renderTime = ({ remainingTime }: { remainingTime: number }) => { + const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime); return (
-
{remainingTime.toFixed(0)}
+
{time.toFixed(0)}
); }; @@ -418,7 +395,8 @@ export default function ContactPage() {
0 || (contactData()?.remainingTime ?? 0) > 0 + remainingTime() > 0 || + (contactData()?.remainingTime ?? 0) > 0 } fallback={