coundown fixed
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,7 +20,6 @@ app.config.timestamp_*.js
|
|||||||
*.launch
|
*.launch
|
||||||
.settings/
|
.settings/
|
||||||
tasks
|
tasks
|
||||||
main.go
|
|
||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
gitignore
|
gitignore
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, createSignal, onMount, onCleanup } from "solid-js";
|
import { Component, createSignal, createEffect, onCleanup } from "solid-js";
|
||||||
|
|
||||||
interface CountdownCircleTimerProps {
|
interface CountdownCircleTimerProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -19,33 +19,65 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
|
|||||||
props.initialRemainingTime ?? props.duration
|
props.initialRemainingTime ?? props.duration
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = () => remainingTime() / props.duration;
|
const progress = () => {
|
||||||
const strokeDashoffset = () => circumference * (1 - progress());
|
const time = remainingTime();
|
||||||
|
if (isNaN(time) || !props.duration) return 0;
|
||||||
|
return Math.max(0, Math.min(1, time / props.duration));
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
const strokeDashoffset = () => {
|
||||||
const startTime = Date.now();
|
const prog = progress();
|
||||||
const initialTime = remainingTime();
|
if (isNaN(prog)) return 0;
|
||||||
let animationFrameId: number;
|
return circumference * (1 - prog);
|
||||||
|
};
|
||||||
|
|
||||||
const animate = () => {
|
// If isPlaying is set, manage countdown internally
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
createEffect(() => {
|
||||||
const newTime = Math.max(0, initialTime - elapsed);
|
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) {
|
const animate = () => {
|
||||||
props.onComplete?.();
|
const elapsed = (Date.now() - startTime) / 1000;
|
||||||
return;
|
const newTime = Math.max(0, initialTime - elapsed);
|
||||||
}
|
|
||||||
|
setRemainingTime(newTime);
|
||||||
|
|
||||||
|
if (newTime <= 0) {
|
||||||
|
props.onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
};
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
onCleanup(() => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
return;
|
||||||
cancelAnimationFrame(animationFrameId);
|
}
|
||||||
});
|
|
||||||
|
// 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 (
|
return (
|
||||||
|
|||||||
78
src/lib/useCountdown.ts
Normal file
78
src/lib/useCountdown.ts
Normal file
@@ -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<typeof setInterval> | 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, onMount, onCleanup, Show } from "solid-js";
|
import { createSignal, onMount, createEffect, Show } from "solid-js";
|
||||||
import {
|
import {
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
@@ -15,6 +15,7 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
|||||||
import RevealDropDown from "~/components/RevealDropDown";
|
import RevealDropDown from "~/components/RevealDropDown";
|
||||||
import Input from "~/components/ui/Input";
|
import Input from "~/components/ui/Input";
|
||||||
import { Button } from "~/components/ui/Button";
|
import { Button } from "~/components/ui/Button";
|
||||||
|
import { useCountdown } from "~/lib/useCountdown";
|
||||||
import type { UserProfile } from "~/types/user";
|
import type { UserProfile } from "~/types/user";
|
||||||
import { getCookie, setCookie } from "vinxi/http";
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -154,7 +155,6 @@ export default function ContactPage() {
|
|||||||
deferStream: true
|
deferStream: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const [countDown, setCountDown] = createSignal<number>(0);
|
|
||||||
const [emailSent, setEmailSent] = createSignal<boolean>(
|
const [emailSent, setEmailSent] = createSignal<boolean>(
|
||||||
searchParams.success === "true"
|
searchParams.success === "true"
|
||||||
);
|
);
|
||||||
@@ -165,36 +165,11 @@ export default function ContactPage() {
|
|||||||
const [user, setUser] = createSignal<UserProfile | null>(null);
|
const [user, setUser] = createSignal<UserProfile | null>(null);
|
||||||
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
||||||
|
|
||||||
let timerIdRef: ReturnType<typeof setInterval> | null = null;
|
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setJsEnabled(true);
|
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
|
api.user.getProfile
|
||||||
.query()
|
.query()
|
||||||
.then((userData) => {
|
.then((userData) => {
|
||||||
@@ -203,25 +178,28 @@ export default function ContactPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
if (searchParams.success || searchParams.error) {
|
createEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
// Try server data first (more accurate)
|
||||||
const newUrl =
|
const serverData = contactData();
|
||||||
location.pathname +
|
if (serverData?.remainingTime && serverData.remainingTime > 0) {
|
||||||
(viewer() !== "default" ? `?viewer=${viewer()}` : "");
|
const expirationTime = new Date(
|
||||||
navigate(newUrl, { replace: true });
|
Date.now() + serverData.remainingTime * 1000
|
||||||
setEmailSent(false);
|
);
|
||||||
setError("");
|
startCountdown(expirationTime);
|
||||||
}, 5000);
|
return;
|
||||||
|
|
||||||
onCleanup(() => clearTimeout(timer));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
// Fall back to client cookie if server data not available yet
|
||||||
if (timerIdRef !== null) {
|
const timer = getClientCookie("contactRequestSent");
|
||||||
clearInterval(timerIdRef);
|
if (timer) {
|
||||||
|
try {
|
||||||
|
startCountdown(timer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start countdown from cookie:", e);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sendEmailTrigger = async (e: Event) => {
|
const sendEmailTrigger = async (e: Event) => {
|
||||||
@@ -251,13 +229,11 @@ export default function ContactPage() {
|
|||||||
setError("");
|
setError("");
|
||||||
(e.target as HTMLFormElement).reset();
|
(e.target as HTMLFormElement).reset();
|
||||||
|
|
||||||
const timer = getClientCookie("contactRequestSent");
|
// Set countdown directly - cookie might not be readable immediately
|
||||||
if (timer) {
|
const expirationTime = new Date(
|
||||||
if (timerIdRef !== null) {
|
Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
|
||||||
clearInterval(timerIdRef);
|
);
|
||||||
}
|
startCountdown(expirationTime);
|
||||||
timerIdRef = setInterval(() => calcRemainder(timer), 1000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "An error occurred");
|
setError(err.message || "An error occurred");
|
||||||
@@ -351,9 +327,10 @@ export default function ContactPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderTime = ({ remainingTime }: { remainingTime: number }) => {
|
const renderTime = ({ remainingTime }: { remainingTime: number }) => {
|
||||||
|
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime);
|
||||||
return (
|
return (
|
||||||
<div class="timer">
|
<div class="timer">
|
||||||
<div class="value">{remainingTime.toFixed(0)}</div>
|
<div class="value">{time.toFixed(0)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -418,7 +395,8 @@ export default function ContactPage() {
|
|||||||
<div class="mx-auto flex w-full justify-end pt-4">
|
<div class="mx-auto flex w-full justify-end pt-4">
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
countDown() > 0 || (contactData()?.remainingTime ?? 0) > 0
|
remainingTime() > 0 ||
|
||||||
|
(contactData()?.remainingTime ?? 0) > 0
|
||||||
}
|
}
|
||||||
fallback={
|
fallback={
|
||||||
<Button type="submit" loading={loading()} class="w-36">
|
<Button type="submit" loading={loading()} class="w-36">
|
||||||
@@ -438,11 +416,11 @@ export default function ContactPage() {
|
|||||||
>
|
>
|
||||||
<CountdownCircleTimer
|
<CountdownCircleTimer
|
||||||
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
|
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
|
||||||
initialRemainingTime={countDown()}
|
initialRemainingTime={remainingTime()}
|
||||||
size={48}
|
size={48}
|
||||||
strokeWidth={6}
|
strokeWidth={6}
|
||||||
colors={"#60a5fa"}
|
colors={"#60a5fa"}
|
||||||
onComplete={() => setCountDown(0)}
|
onComplete={() => setRemainingTime(0)}
|
||||||
>
|
>
|
||||||
{renderTime}
|
{renderTime}
|
||||||
</CountdownCircleTimer>
|
</CountdownCircleTimer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
|
import { createSignal, createEffect, Show } from "solid-js";
|
||||||
import { A, useNavigate } from "@solidjs/router";
|
import { A, useNavigate } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||||
@@ -8,47 +8,24 @@ import { COUNTDOWN_CONFIG } from "~/config";
|
|||||||
import Input from "~/components/ui/Input";
|
import Input from "~/components/ui/Input";
|
||||||
import { Button } from "~/components/ui/Button";
|
import { Button } from "~/components/ui/Button";
|
||||||
import FormFeedback from "~/components/ui/FormFeedback";
|
import FormFeedback from "~/components/ui/FormFeedback";
|
||||||
|
import { useCountdown } from "~/lib/useCountdown";
|
||||||
|
|
||||||
export default function RequestPasswordResetPage() {
|
export default function RequestPasswordResetPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [countDown, setCountDown] = createSignal(0);
|
|
||||||
const [showSuccessMessage, setShowSuccessMessage] = createSignal(false);
|
const [showSuccessMessage, setShowSuccessMessage] = createSignal(false);
|
||||||
const [error, setError] = createSignal("");
|
const [error, setError] = createSignal("");
|
||||||
|
|
||||||
|
const { remainingTime, startCountdown } = useCountdown();
|
||||||
|
|
||||||
let emailRef: HTMLInputElement | undefined;
|
let emailRef: HTMLInputElement | undefined;
|
||||||
let timerInterval: number | undefined;
|
|
||||||
|
|
||||||
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 (timerInterval) {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCountDown(remainingInSeconds);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const timer = getClientCookie("passwordResetRequested");
|
const timer = getClientCookie("passwordResetRequested");
|
||||||
if (timer) {
|
if (timer) {
|
||||||
timerInterval = setInterval(
|
startCountdown(timer);
|
||||||
() => calcRemainder(timer),
|
|
||||||
1000
|
|
||||||
) as unknown as number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (timerInterval) {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestPasswordResetTrigger = async (e: Event) => {
|
const requestPasswordResetTrigger = async (e: Event) => {
|
||||||
@@ -85,12 +62,7 @@ export default function RequestPasswordResetPage() {
|
|||||||
|
|
||||||
const timer = getClientCookie("passwordResetRequested");
|
const timer = getClientCookie("passwordResetRequested");
|
||||||
if (timer) {
|
if (timer) {
|
||||||
if (timerInterval) {
|
startCountdown(timer);
|
||||||
clearInterval(timerInterval);
|
|
||||||
}
|
|
||||||
timerInterval = setInterval(() => {
|
|
||||||
calcRemainder(timer);
|
|
||||||
}, 1000) as unknown as number;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error?.message || "Failed to send reset email";
|
const errorMsg = result.error?.message || "Failed to send reset email";
|
||||||
@@ -151,7 +123,7 @@ export default function RequestPasswordResetPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={countDown() > 0}
|
when={remainingTime() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<Button type="submit" loading={loading()} class="my-6">
|
<Button type="submit" loading={loading()} class="my-6">
|
||||||
Request Password Reset
|
Request Password Reset
|
||||||
@@ -162,7 +134,7 @@ export default function RequestPasswordResetPage() {
|
|||||||
<CountdownCircleTimer
|
<CountdownCircleTimer
|
||||||
isPlaying={true}
|
isPlaying={true}
|
||||||
duration={COUNTDOWN_CONFIG.PASSWORD_RESET_DURATION_S}
|
duration={COUNTDOWN_CONFIG.PASSWORD_RESET_DURATION_S}
|
||||||
initialRemainingTime={countDown()}
|
initialRemainingTime={remainingTime()}
|
||||||
size={48}
|
size={48}
|
||||||
strokeWidth={6}
|
strokeWidth={6}
|
||||||
colors="#60a5fa"
|
colors="#60a5fa"
|
||||||
|
|||||||
Reference in New Issue
Block a user