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

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.11 _ 2025-10-06 _ Merge branch 'staging'
1.0.10 _ 2025-10-05 _ utm

BIN
src.zip Normal file

Binary file not shown.

@ -1,304 +1,304 @@
import {
Box,
Button,
FormControl,
IconButton,
MenuItem,
TextField as MuiTextField,
Paper,
Select,
SelectChangeEvent,
TextFieldProps,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { FC, useEffect, useMemo, useState } from "react";
import ArrowDown from "@/assets/icons/ArrowDownIcon";
import CopyIcon from "@/assets/icons/CopyIcon";
import LinkIcon from "@/assets/icons/LinkIcon";
import { InfoPopover } from "@/ui_kit/InfoPopover";
import { utmApi, type UtmRecord } from "@/api/utm";
import { UtmList } from "./UtmList";
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
export default function QuizMarkCreate() {
const theme = useTheme();
const quiz = useCurrentQuiz();
const [category, setCategory] = useState<string>("google");
const [name, setName] = useState<string>("");
const [utm, setUtm] = useState<string>("");
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [items, setItems] = useState<UtmRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
if (!quiz?.backendId) return;
setLoading(true);
setError(null);
const [data, err] = await utmApi.getAll(quiz.backendId);
if (cancelled) return;
if (err) {
setError(err);
setItems([]);
} else {
setItems((data ?? []).filter((i) => !i.deleted));
}
setLoading(false);
}
load();
return () => { cancelled = true; };
}, [quiz?.backendId]);
const CopyLink = () => {
let one = (document.getElementById("inputMarkLinkone") as HTMLInputElement)
?.value;
let text = (document.getElementById("inputMarkLink") as HTMLInputElement)
?.value;
navigator.clipboard.writeText(one + text);
};
const handleChange = (event: SelectChangeEvent<string>) => {
setCategory(event.target.value as string);
};
const filteredItems = useMemo(() => {
return items.filter((i) => (i.category ? i.category === category : true));
}, [items, category]);
const utmHasSpaces = /\s/.test(utm);
const canSave = Boolean(name.trim() && category && utm.trim() && !utmHasSpaces && quiz?.backendId);
const handleSave = async () => {
if (!canSave || !quiz?.backendId) return;
const body = { quiz_id: quiz.backendId, utm, name, category };
const [created, err] = await utmApi.create(body);
if (err || !created) return;
setItems((prev) => [created, ...prev]);
// Очистим только utm, оставим категорию; имя можно тоже очистить
setUtm("");
setName("");
};
if (!quiz) return null;
return (
<Paper
sx={{
boxSizing: "border-box",
maxWidth: "580px",
width: "100%",
padding: "22px 18px 20px 20px",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
boxShadow:
"0px 100px 309px rgba(210, 208, 225, 0.24), 0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525), 0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066), 0px 12.5216px 38.6916px rgba(210, 208, 225, 0.12), 0px 6.6501px 20.5488px rgba(210, 208, 225, 0.0969343), 0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)",
}}
>
<Box
sx={{
display: "flex",
alignItems: isMobile ? "flex-start" : "center",
gap: "10px",
flexDirection: isMobile ? "column" : "row",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<LinkIcon
color={theme.palette.brightPurple.main}
bgcolor={"#EEE4FC"}
/>
<Typography color={theme.palette.grey3.main}>Создание/выбор utm меток</Typography>
</Box>
<Box>
<InfoPopover />
<FormControl
fullWidth
size="small"
sx={{
width: "100%",
maxWidth: "118px",
height: "24px",
}}
>
<Select
id="display-select"
variant="outlined"
value={category}
displayEmpty
onChange={handleChange}
sx={{
height: "24px",
borderRadius: "8px",
"& .MuiOutlinedInput-notchedOutline": {
border: "none !important",
},
"& .MuiSelect-icon": {
position: 'relative',
marginBottom: '13px',
}
}}
MenuProps={{
PaperProps: {
sx: {
mt: "8px",
p: "4px",
borderRadius: "8px",
width: "300px",
maxWidth: "300px",
},
},
MenuListProps: {
sx: {
py: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
"& .Mui-selected": {
backgroundColor: theme.palette.background.default,
color: theme.palette.brightPurple.main,
},
"& .MuiMenuItem-root": {
whiteSpace: "normal",
wordBreak: "break-word",
lineHeight: "20px",
maxWidth: "300px",
},
},
},
}}
inputProps={{
sx: {
color: theme.palette.brightPurple.main,
display: "flex",
alignItems: "center",
px: "20px",
gap: "20px"
},
}}
IconComponent={(props) => <ArrowDown {...props} sx={{
position: 'relative',
top: '-5px', // Поднимаем вверх
right: '5px'
}} />}
>
<MenuItem
value={"google"}
sx={{
display: "flex",
gap: "20px",
p: "4px",
borderRadius: "5px",
color: theme.palette.grey2.main,
}}
>
Google
</MenuItem>
<MenuItem value={"vk"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>VK</MenuItem>
<MenuItem value={"facebook"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>Facebook* является экстремисткой организацией запрещённой на территории РФ</MenuItem>
<MenuItem value={"yandex"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>Яндекс</MenuItem>
<MenuItem value={"tiktok"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>TikTok</MenuItem>
<MenuItem value={"other"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>другое</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
<Box sx={{ display: "inline-flex", alignItems: "center", justifyContent: "space-between", margin: "30px 0px 0px 0px" }}>
<Box sx={{ display: "inline-flex" }}>
<FormControl variant="standard" sx={{ p: 0 }}>
<TextField
id="inputMarkLinkone"
placeholder="Название"
value={name}
onChange={(e) => setName(e.target.value)}
sx={{
width: isTablet ? "100%" : "182px",
maxWidth: "182px",
"& .MuiInputBase-root": {
height: "48px",
color: "#525253",
borderRadius: "8px 0 0 8px",
backgroundColor: "#F2F3F7",
},
}}
/>
</FormControl>
<FormControl variant="standard" sx={{ p: 0 }}>
<TextField
placeholder="пример-ссылки-с-utm-метками"
id="inputMarkLink"
value={utm}
onChange={(e) => setUtm(e.target.value)}
sx={{
"& .MuiInputBase-root": {
color: "#525253",
width: isTablet ? "100%" : "317px",
maxWidth: "317px",
height: "48px",
borderRadius: "0 8px 8px 0",
backgroundColor: "#F2F3F7"
},
}}
inputProps={{
sx: {
borderRadius: "0 10px 10px 0",
fontSize: "18px",
lineHeight: "21px",
py: 0,
},
}}
/>
</FormControl>
<IconButton
onClick={CopyLink}
id={"copyLink"}
sx={{ borderRadius: "6px" }}
>
<CopyIcon
color={theme.palette.brightPurple.main}
bgcolor={"#EEE4FC"}
/>
</IconButton>
</Box>
</Box>
<Box sx={{ display: "inline-flex", margin: "11px 0 0 0", alignItems: "flex-end" }}>
<Tooltip title={!canSave ? (utmHasSpaces ? "уберите пробелы" : "заполните поля") : ""} disableHoverListener={canSave} arrow>
<span style={{ marginLeft: "auto" }}>
<Button variant="contained" disabled={!canSave} onClick={handleSave} sx={{ width: "133px", height: "44px" }}>Сохранить</Button>
</span>
</Tooltip>
</Box>
{quiz?.qid && (
<UtmList
items={filteredItems}
loading={loading}
error={error}
quizQid={quiz.qid}
onDelete={async (id) => {
const prev = items;
setItems(prev.filter((it) => it.id !== id));
const [, err] = await utmApi.softDelete(id);
if (err) setItems(prev);
}}
/>)}
</Paper>
);
import {
Box,
Button,
FormControl,
IconButton,
MenuItem,
TextField as MuiTextField,
Paper,
Select,
SelectChangeEvent,
TextFieldProps,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { FC, useEffect, useMemo, useState } from "react";
import ArrowDown from "@/assets/icons/ArrowDownIcon";
import CopyIcon from "@/assets/icons/CopyIcon";
import LinkIcon from "@/assets/icons/LinkIcon";
import { InfoPopover } from "@/ui_kit/InfoPopover";
import { utmApi, type UtmRecord } from "@/api/utm";
import { UtmList } from "./UtmList";
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
export default function QuizMarkCreate() {
const theme = useTheme();
const quiz = useCurrentQuiz();
const [category, setCategory] = useState<string>("google");
const [name, setName] = useState<string>("");
const [utm, setUtm] = useState<string>("");
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [items, setItems] = useState<UtmRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
if (!quiz?.backendId) return;
setLoading(true);
setError(null);
const [data, err] = await utmApi.getAll(quiz.backendId);
if (cancelled) return;
if (err) {
setError(err);
setItems([]);
} else {
setItems((data ?? []).filter((i) => !i.deleted));
}
setLoading(false);
}
load();
return () => { cancelled = true; };
}, [quiz?.backendId]);
const CopyLink = () => {
let one = (document.getElementById("inputMarkLinkone") as HTMLInputElement)
?.value;
let text = (document.getElementById("inputMarkLink") as HTMLInputElement)
?.value;
navigator.clipboard.writeText(one + text);
};
const handleChange = (event: SelectChangeEvent<string>) => {
setCategory(event.target.value as string);
};
const filteredItems = useMemo(() => {
return items.filter((i) => (i.category ? i.category === category : true));
}, [items, category]);
const utmHasSpaces = /\s/.test(utm);
const canSave = Boolean(name.trim() && category && utm.trim() && !utmHasSpaces && quiz?.backendId);
const handleSave = async () => {
if (!canSave || !quiz?.backendId) return;
const body = { quiz_id: quiz.backendId, utm, name, category };
const [created, err] = await utmApi.create(body);
if (err || !created) return;
setItems((prev) => [created, ...prev]);
// Очистим только utm, оставим категорию; имя можно тоже очистить
setUtm("");
setName("");
};
if (!quiz) return null;
return (
<Paper
sx={{
boxSizing: "border-box",
maxWidth: "580px",
width: "100%",
padding: "22px 18px 20px 20px",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
boxShadow:
"0px 100px 309px rgba(210, 208, 225, 0.24), 0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525), 0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066), 0px 12.5216px 38.6916px rgba(210, 208, 225, 0.12), 0px 6.6501px 20.5488px rgba(210, 208, 225, 0.0969343), 0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)",
}}
>
<Box
sx={{
display: "flex",
alignItems: isMobile ? "flex-start" : "center",
gap: "10px",
flexDirection: isMobile ? "column" : "row",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<LinkIcon
color={theme.palette.brightPurple.main}
bgcolor={"#EEE4FC"}
/>
<Typography color={theme.palette.grey3.main}>Создание/выбор utm меток</Typography>
</Box>
<Box>
<InfoPopover />
<FormControl
fullWidth
size="small"
sx={{
width: "100%",
maxWidth: "118px",
height: "24px",
}}
>
<Select
id="display-select"
variant="outlined"
value={category}
displayEmpty
onChange={handleChange}
sx={{
height: "24px",
borderRadius: "8px",
"& .MuiOutlinedInput-notchedOutline": {
border: "none !important",
},
"& .MuiSelect-icon": {
position: 'relative',
marginBottom: '13px',
}
}}
MenuProps={{
PaperProps: {
sx: {
mt: "8px",
p: "4px",
borderRadius: "8px",
width: "300px",
maxWidth: "300px",
},
},
MenuListProps: {
sx: {
py: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
"& .Mui-selected": {
backgroundColor: theme.palette.background.default,
color: theme.palette.brightPurple.main,
},
"& .MuiMenuItem-root": {
whiteSpace: "normal",
wordBreak: "break-word",
lineHeight: "20px",
maxWidth: "300px",
},
},
},
}}
inputProps={{
sx: {
color: theme.palette.brightPurple.main,
display: "flex",
alignItems: "center",
px: "20px",
gap: "20px"
},
}}
IconComponent={(props) => <ArrowDown {...props} sx={{
position: 'relative',
top: '-5px', // Поднимаем вверх
right: '5px'
}} />}
>
<MenuItem
value={"google"}
sx={{
display: "flex",
gap: "20px",
p: "4px",
borderRadius: "5px",
color: theme.palette.grey2.main,
}}
>
Google
</MenuItem>
<MenuItem value={"vk"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>VK</MenuItem>
<MenuItem value={"facebook"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>Facebook* является экстремисткой организацией запрещённой на территории РФ</MenuItem>
<MenuItem value={"yandex"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>Яндекс</MenuItem>
<MenuItem value={"tiktok"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>TikTok</MenuItem>
<MenuItem value={"other"} sx={{ display: "flex", gap: "20px", p: "4px", borderRadius: "5px", color: theme.palette.grey2.main }}>другое</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
<Box sx={{ display: "inline-flex", alignItems: "center", justifyContent: "space-between", margin: "30px 0px 0px 0px" }}>
<Box sx={{ display: "inline-flex" }}>
<FormControl variant="standard" sx={{ p: 0 }}>
<TextField
id="inputMarkLinkone"
placeholder="Название"
value={name}
onChange={(e) => setName(e.target.value)}
sx={{
width: isTablet ? "100%" : "182px",
maxWidth: "182px",
"& .MuiInputBase-root": {
height: "48px",
color: "#525253",
borderRadius: "8px 0 0 8px",
backgroundColor: "#F2F3F7",
},
}}
/>
</FormControl>
<FormControl variant="standard" sx={{ p: 0 }}>
<TextField
placeholder="пример-ссылки-с-utm-метками"
id="inputMarkLink"
value={utm}
onChange={(e) => setUtm(e.target.value)}
sx={{
"& .MuiInputBase-root": {
color: "#525253",
width: isTablet ? "100%" : "317px",
maxWidth: "317px",
height: "48px",
borderRadius: "0 8px 8px 0",
backgroundColor: "#F2F3F7"
},
}}
inputProps={{
sx: {
borderRadius: "0 10px 10px 0",
fontSize: "18px",
lineHeight: "21px",
py: 0,
},
}}
/>
</FormControl>
<IconButton
onClick={CopyLink}
id={"copyLink"}
sx={{ borderRadius: "6px" }}
>
<CopyIcon
color={theme.palette.brightPurple.main}
bgcolor={"#EEE4FC"}
/>
</IconButton>
</Box>
</Box>
<Box sx={{ display: "inline-flex", margin: "11px 0 0 0", alignItems: "flex-end" }}>
<Tooltip title={!canSave ? (utmHasSpaces ? "уберите пробелы" : "заполните поля") : ""} disableHoverListener={canSave} arrow>
<span style={{ marginLeft: "auto" }}>
<Button variant="contained" disabled={!canSave} onClick={handleSave} sx={{ width: "133px", height: "44px" }}>Сохранить</Button>
</span>
</Tooltip>
</Box>
{quiz?.qid && (
<UtmList
items={filteredItems}
loading={loading}
error={error}
quizQid={quiz.qid}
onDelete={async (id) => {
const prev = items;
setItems(prev.filter((it) => it.id !== id));
const [, err] = await utmApi.softDelete(id);
if (err) setItems(prev);
}}
/>)}
</Paper>
);
}

@ -1,5 +1,5 @@
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 { type UtmRecord } from "@/api/utm";
import CopyIcon from "@/assets/icons/CopyIcon";
@ -156,7 +156,7 @@ export const UtmList: FC<UtmListProps> = ({ items, loading, error, onDelete, qui
)}
{items.length > perPage && (
<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>

@ -1,17 +1,156 @@
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { updateQuiz } from "@/stores/quizes/actions";
import CustomTextField from "@/ui_kit/CustomTextField"
import { Box, Typography, useTheme } from "@mui/material"
import CustomizedSwitch from "@ui_kit/CustomSwitch";
import { useEffect, useMemo, useRef, useState } from "react";
const OverTime = () => {
const theme = useTheme();
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;
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" }}>
<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
sx={{
fontWeight: 500,
@ -32,9 +171,20 @@ const OverTime = () => {
>
Введите описание
</Typography>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "327px", mt: "9px" }}
<CustomTextField
sx={{ height: "48px", width: "100%", maxWidth: "327px", mt: "9px", backgroundColor: "white" }}
placeholder="Квиз будет недоступен через:"
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>
@ -51,8 +201,20 @@ const OverTime = () => {
</Typography>
<Box sx={{ display: "inline-flex", gap: "10px", pt: "8px" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
<CustomTextField
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
sx={{
@ -66,8 +228,20 @@ const OverTime = () => {
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
<CustomTextField
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
sx={{
@ -81,8 +255,20 @@ const OverTime = () => {
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
<CustomTextField
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
sx={{
@ -96,8 +282,20 @@ const OverTime = () => {
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
<CustomTextField
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
sx={{
@ -111,6 +309,13 @@ const OverTime = () => {
</Typography>
</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>
)

@ -175,7 +175,7 @@ const Timer = () => {
onKeyDown={handleMinutesKeyDown as any}
onPaste={handleMinutesPaste as any}
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}
/>
<Typography
@ -206,7 +206,7 @@ const Timer = () => {
onKeyDown={handleDigitKeyDown as any}
onPaste={handleSecondsPaste as any}
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}
/>
<Typography

@ -21,7 +21,16 @@ export default function BackBlockedWithTooltip() {
return (
<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 && (
<Box
sx={{

@ -393,7 +393,7 @@ export default function StartPageSettings() {
setBackgroundUploading(false);
}}
onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.background.desktop = null;
@ -602,7 +602,7 @@ export default function StartPageSettings() {
setLogoUploading(false);
}}
onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.logo = null;
@ -675,7 +675,7 @@ export default function StartPageSettings() {
setLogoUploading(false);
}}
onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.logo = null;
@ -859,25 +859,14 @@ export default function StartPageSettings() {
maxLength={1000}
/>
<Extra />
<Box sx={{ display: "flex", gap: "20px", alignItems: "center" }}>
<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: "flex", gap: "20px", alignItems: "center", mt: "20px" }}>
<Box sx={{
display: "flex",
gap: "20px",
alignItems: "center",
backgroundColor: "#f2f3f7",
borderRadius: "8px",
p: "18px 15px",
}}>
<CustomizedSwitch
checked={quiz.config?.isUnSc}
onChange={(e) => {
@ -896,8 +885,44 @@ export default function StartPageSettings() {
</Typography>
</Box>
<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 />}
</>
)}