логика овертайм

This commit is contained in:
Nastya 2025-10-20 01:03:50 +03:00
parent 378eeb0068
commit 639929d825
8 changed files with 580 additions and 340 deletions

@ -1,4 +1,5 @@
1.0.13 _ 2025-10-18 _ новый визуал таймера с логикой + визуал овертайм 1.0.14 _ 2025-10-20 _ utm
1.0.13 _ 2025-10-18 _ Визуал utm + логика
1.0.12 _ 2025-10-12 _ ютм с дизайном и беком, но без логики 1.0.12 _ 2025-10-12 _ ютм с дизайном и беком, но без логики
1.0.11 _ 2025-10-06 _ Merge branch 'staging' 1.0.11 _ 2025-10-06 _ Merge branch 'staging'
1.0.10 _ 2025-10-05 _ utm 1.0.10 _ 2025-10-05 _ utm

BIN
src.zip Normal file

Binary file not shown.

@ -1,5 +1,5 @@
import { FC, useEffect, useMemo, useState } from "react"; import { FC, useEffect, useMemo, useState } from "react";
import { Box, IconButton, List, ListItem, Typography, useTheme, useMediaQuery, Pagination } from "@mui/material"; import { Box, IconButton, List, ListItem, Typography, useTheme, useMediaQuery } from "@mui/material";
import TrashIcon from "@/assets/icons/TrashIcon"; import TrashIcon from "@/assets/icons/TrashIcon";
import { type UtmRecord } from "@/api/utm"; import { type UtmRecord } from "@/api/utm";
import CopyIcon from "@/assets/icons/CopyIcon"; import CopyIcon from "@/assets/icons/CopyIcon";
@ -156,7 +156,7 @@ export const UtmList: FC<UtmListProps> = ({ items, loading, error, onDelete, qui
)} )}
{items.length > perPage && ( {items.length > perPage && (
<Box sx={{ display: "flex", justifyContent: "end", mt: 1 }}> <Box sx={{ display: "flex", justifyContent: "end", mt: 1 }}>
<CustomPagination countPagination={totalPages} page={page} onChange={(e, v) => setPage(v)} size="small" /> <CustomPagination countPagination={totalPages} page={page} onChange={(e, v) => setPage(v)} />
</Box> </Box>
)} )}
</Box> </Box>

@ -1,17 +1,156 @@
import { useCurrentQuiz } from "@/stores/quizes/hooks"; import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { updateQuiz } from "@/stores/quizes/actions";
import CustomTextField from "@/ui_kit/CustomTextField" import CustomTextField from "@/ui_kit/CustomTextField"
import { Box, Typography, useTheme } from "@mui/material" import { Box, Typography, useTheme } from "@mui/material"
import CustomizedSwitch from "@ui_kit/CustomSwitch"; import CustomizedSwitch from "@ui_kit/CustomSwitch";
import { useEffect, useMemo, useRef, useState } from "react";
const OverTime = () => { const OverTime = () => {
const theme = useTheme(); const theme = useTheme();
const quiz = useCurrentQuiz(); const quiz = useCurrentQuiz();
const initialOverTime = useMemo(() => {
const cfg = (quiz as any)?.config ?? {};
const ot = cfg.overTime ?? { enabled: false, endsAt: 0, description: "" };
return ot as { enabled: boolean; endsAt: number; description: string };
}, [(quiz as any)?.config?.overTime]);
const [enabled, setEnabled] = useState<boolean>(Boolean(initialOverTime.enabled));
const [description, setDescription] = useState<string>(initialOverTime.description || "");
const [days, setDays] = useState<string>("00");
const [hours, setHours] = useState<string>("00");
const [minutes, setMinutes] = useState<string>("00");
const [seconds, setSeconds] = useState<string>("00");
const [remainingMs, setRemainingMs] = useState<number>(
Math.max(0, (initialOverTime.endsAt || 0) - Date.now())
);
const daysRef = useRef<HTMLInputElement | null>(null);
const hoursRef = useRef<HTMLInputElement | null>(null);
const minutesRef = useRef<HTMLInputElement | null>(null);
const secondsRef = useRef<HTMLInputElement | null>(null);
const toDigits = (value: string): string => (value || "").replace(/\D+/g, "");
const clampTwoDigits = (value: string): string => {
const digits = toDigits(value).slice(0, 2);
if (digits.length === 0) return "";
const num = Number(digits);
if (isNaN(num)) return "";
if (num > 60) return "60";
return digits;
};
const clampTwoDigitsDays = (value: string): string => toDigits(value).slice(0, 2);
const allowControlKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
const allowed = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "Home", "End", "Enter"];
return allowed.includes(e.key);
};
const handleDigitKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (allowControlKey(e)) return;
if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
}
};
const handleTwoDigitKeyDown = (value: string) => (e: React.KeyboardEvent<HTMLInputElement>) => {
if (allowControlKey(e)) return;
const isDigit = /^[0-9]$/.test(e.key);
if (!isDigit) {
e.preventDefault();
return;
}
const target = e.currentTarget;
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
const selectionLength = Math.max(0, selectionEnd - selectionStart);
const currentLength = toDigits(value).length;
if (selectionLength === 0 && currentLength >= 2) {
e.preventDefault();
}
};
const persistEndsAt = (dStr: string, hStr: string, mStr: string, sStr: string) => {
const d = Number(toDigits(dStr) || 0);
const h = Number(toDigits(hStr) || 0);
const m = Number(toDigits(mStr) || 0);
const s = Number(toDigits(sStr) || 0);
const totalMs = (((d * 24 + h) * 60 + m) * 60 + s) * 1000;
const endsAt = Date.now() + Math.max(0, totalMs);
setRemainingMs(Math.max(0, endsAt - Date.now()));
updateQuiz(quiz!.id, (q) => {
const cfg: any = (q as any).config;
cfg.overTime = cfg.overTime || { enabled: false, endsAt: 0, description: "" };
cfg.overTime.endsAt = endsAt;
});
};
// Тикер обратного отсчёта
useEffect(() => {
if (!enabled) return;
let rafId: number | null = null;
let timerId: ReturnType<typeof setInterval> | null = null;
const tick = () => {
const cfgEndsAt = (quiz as any)?.config?.overTime?.endsAt ?? 0;
const ms = Math.max(0, cfgEndsAt - Date.now());
setRemainingMs(ms);
if (ms <= 0) {
// выключаем флаг
setEnabled(false);
updateQuiz(quiz!.id, (q) => {
const cfg: any = (q as any).config;
if (!cfg.overTime) cfg.overTime = { enabled: false, endsAt: 0, description: "" };
cfg.overTime.enabled = false;
cfg.overTime.endsAt = 0;
});
}
};
// первый расчёт по requestAnimationFrame для мгновенного обновления
rafId = window.requestAnimationFrame(() => tick());
timerId = setInterval(tick, 1000);
return () => {
if (rafId) cancelAnimationFrame(rafId);
if (timerId) clearInterval(timerId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, (quiz as any)?.config?.overTime?.endsAt]);
// Синхронизация, если config.overTime обновился извне
useEffect(() => {
const cfg = (quiz as any)?.config?.overTime;
if (!cfg) return;
setEnabled(Boolean(cfg.enabled));
setDescription(cfg.description || "");
setRemainingMs(Math.max(0, (cfg.endsAt || 0) - Date.now()));
}, [(quiz as any)?.config?.overTime]);
const fmt = (n: number) => (n < 10 ? `0${n}` : String(n));
const rem = useMemo(() => {
const totalSec = Math.floor(remainingMs / 1000);
const d = Math.floor(totalSec / (24 * 3600));
const h = Math.floor((totalSec % (24 * 3600)) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return { d, h, m, s };
}, [remainingMs]);
if (!quiz) return null; if (!quiz) return null;
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", mt: "20px", padding: "15px", backgroundColor: "#F2F3F7", width: "100%", maxWidth: "357px", height: "283px", borderRadius: "8px" }}> <Box sx={{ display: "flex", flexDirection: "column", mt: "20px", padding: "15px", backgroundColor: "#F2F3F7", width: "100%", maxWidth: "357px", height: "295px", borderRadius: "8px" }}>
<Box sx={{ display: "inline-flex", alignItems: "center", height: "31px", gap: "20px" }}> <Box sx={{ display: "inline-flex", alignItems: "center", height: "31px", gap: "20px" }}>
<CustomizedSwitch/> <CustomizedSwitch
checked={enabled}
onChange={(e) => {
const checked = e.target.checked;
setEnabled(checked);
updateQuiz(quiz!.id, (q) => {
const cfg: any = (q as any).config;
cfg.overTime = cfg.overTime || { enabled: false, endsAt: 0, description: "" };
cfg.overTime.enabled = checked;
});
}}
/>
<Typography <Typography
sx={{ sx={{
fontWeight: 500, fontWeight: 500,
@ -32,9 +171,20 @@ const OverTime = () => {
> >
Введите описание Введите описание
</Typography> </Typography>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "327px", mt: "9px" }} <CustomTextField
sx={{ height: "48px", width: "100%", maxWidth: "327px", mt: "9px", backgroundColor: "white" }}
placeholder="Квиз будет недоступен через:" placeholder="Квиз будет недоступен через:"
maxLength={40} maxLength={40}
value={description}
onChange={(e) => {
const next = e.target.value;
setDescription(next);
updateQuiz(quiz!.id, (q) => {
const cfg: any = (q as any).config;
cfg.overTime = cfg.overTime || { enabled: false, endsAt: 0, description: "" };
cfg.overTime.description = next;
});
}}
/> />
</Box> </Box>
@ -51,8 +201,20 @@ const OverTime = () => {
</Typography> </Typography>
<Box sx={{ display: "inline-flex", gap: "10px", pt: "8px" }}> <Box sx={{ display: "inline-flex", gap: "10px", pt: "8px" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }} <CustomTextField
placeholder="00" placeholder="00"
value={days}
onChange={(e) => {
const next = clampTwoDigitsDays(e.target.value);
setDays(next);
persistEndsAt(next, hours, minutes, seconds);
}}
onKeyDown={handleTwoDigitKeyDown(days) as any}
inputRef={daysRef}
onFocus={() => daysRef.current?.select()}
onClick={() => daysRef.current?.select()}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
/> />
<Typography <Typography
sx={{ sx={{
@ -66,8 +228,20 @@ const OverTime = () => {
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }} <CustomTextField
placeholder="00" placeholder="00"
value={hours}
onChange={(e) => {
const next = clampTwoDigits(e.target.value);
setHours(next);
persistEndsAt(days, next, minutes, seconds);
}}
onKeyDown={handleTwoDigitKeyDown(hours) as any}
inputRef={hoursRef}
onFocus={() => hoursRef.current?.select()}
onClick={() => hoursRef.current?.select()}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
/> />
<Typography <Typography
sx={{ sx={{
@ -81,8 +255,20 @@ const OverTime = () => {
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }} <CustomTextField
placeholder="00" placeholder="00"
value={minutes}
onChange={(e) => {
const next = clampTwoDigits(e.target.value);
setMinutes(next);
persistEndsAt(days, hours, next, seconds);
}}
onKeyDown={handleTwoDigitKeyDown(minutes) as any}
inputRef={minutesRef}
onFocus={() => minutesRef.current?.select()}
onClick={() => minutesRef.current?.select()}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
/> />
<Typography <Typography
sx={{ sx={{
@ -96,8 +282,20 @@ const OverTime = () => {
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }} <CustomTextField
placeholder="00" placeholder="00"
value={seconds}
onChange={(e) => {
const next = clampTwoDigits(e.target.value);
setSeconds(next);
persistEndsAt(days, hours, minutes, next);
}}
onKeyDown={handleTwoDigitKeyDown(seconds) as any}
inputRef={secondsRef}
onFocus={() => secondsRef.current?.select()}
onClick={() => secondsRef.current?.select()}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
/> />
<Typography <Typography
sx={{ sx={{
@ -111,6 +309,13 @@ const OverTime = () => {
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
{enabled && (
<Box sx={{ mt: "10px" }}>
<Typography sx={{ fontWeight: 500, color: theme.palette.grey3.main, fontSize: "16px" }}>
До конца: {rem.d} д {fmt(rem.h)}:{fmt(rem.m)}:{fmt(rem.s)}
</Typography>
</Box>
)}
</Box> </Box>
</Box> </Box>
) )

@ -175,7 +175,7 @@ const Timer = () => {
onKeyDown={handleMinutesKeyDown as any} onKeyDown={handleMinutesKeyDown as any}
onPaste={handleMinutesPaste as any} onPaste={handleMinutesPaste as any}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }} InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px" }} sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
inputRef={minutesRef} inputRef={minutesRef}
/> />
<Typography <Typography
@ -206,7 +206,7 @@ const Timer = () => {
onKeyDown={handleDigitKeyDown as any} onKeyDown={handleDigitKeyDown as any}
onPaste={handleSecondsPaste as any} onPaste={handleSecondsPaste as any}
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }} InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
sx={{ height: "48px", width: "100%", maxWidth: "51px" }} sx={{ height: "48px", width: "100%", maxWidth: "51px", backgroundColor: "white", p: "8px 6px" }}
inputRef={secondsRef} inputRef={secondsRef}
/> />
<Typography <Typography

@ -21,7 +21,16 @@ export default function BackBlockedWithTooltip() {
return ( return (
<ClickAwayListener onClickAway={handleTooltipClose}> <ClickAwayListener onClickAway={handleTooltipClose}>
<Box sx={{ position: "relative", display: "flex", gap: "20px", alignItems: "center", mt: "20px" }}> <Box sx={{
position: "relative",
display: "flex",
gap: "20px",
alignItems: "center",
mt: "20px",
backgroundColor: "#f2f3f7",
borderRadius: "8px",
p: "18px 15px"
}}>
{enabled && ( {enabled && (
<Box <Box
sx={{ sx={{

@ -859,25 +859,14 @@ export default function StartPageSettings() {
maxLength={1000} maxLength={1000}
/> />
<Extra /> <Extra />
<Box sx={{ display: "flex", gap: "20px", alignItems: "center" }}> <Box sx={{
<CustomizedSwitch display: "flex",
checked={quiz.config.antifraud} gap: "20px",
onChange={(e) => { alignItems: "center",
updateQuiz(quiz.id, (quiz) => { backgroundColor: "#f2f3f7",
quiz.config.antifraud = e.target.checked; borderRadius: "8px",
}); p: "18px 15px",
}} }}>
/>
<Typography
sx={{
fontWeight: 500,
color: theme.palette.grey3.main,
}}
>
Включить антифрод
</Typography>
</Box>
<Box sx={{ display: "flex", gap: "20px", alignItems: "center", mt: "20px" }}>
<CustomizedSwitch <CustomizedSwitch
checked={quiz.config?.isUnSc} checked={quiz.config?.isUnSc}
onChange={(e) => { onChange={(e) => {
@ -896,8 +885,44 @@ export default function StartPageSettings() {
</Typography> </Typography>
</Box> </Box>
<BackBlockedWithTooltip /> <BackBlockedWithTooltip />
<Timer/>
<OverTime/> <Box sx={{
display: "flex",
gap: "20px",
alignItems: "center",
backgroundColor: "#f2f3f7",
borderRadius: "8px",
p: "18px 15px",
mt: "20px",
}}>
<CustomizedSwitch
checked={quiz.config.antifraud}
onChange={(e) => {
updateQuiz(quiz.id, (quiz) => {
quiz.config.antifraud = e.target.checked;
});
}}
/>
<Typography
sx={{
fontWeight: 500,
color: theme.palette.grey3.main,
}}
>
Включить антифрод
</Typography>
</Box>
<Box
sx={{
display: "inline-flex",
gap: "12px",
flexDirection: isTablet ? "column" : "row"
}}
>
<Timer />
<OverTime />
</Box>
{!isSmallMonitor && <SwitchAI />} {!isSmallMonitor && <SwitchAI />}
</> </>
)} )}