новый визуал таймера с логикой + визуал овертайм
This commit is contained in:
parent
9c61a9e2b8
commit
e68447dc6e
@ -1,3 +1,4 @@
|
|||||||
|
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
|
||||||
1.0.9 _ 2025-10-05 _ utm
|
1.0.9 _ 2025-10-05 _ utm
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import { useAuthRedirect } from "../../utils/hooks/useAuthRedirect";
|
|
||||||
|
|
||||||
export default function Payment() {
|
|
||||||
// Используем хук авторизации
|
|
||||||
const { isProcessing } = useAuthRedirect();
|
|
||||||
|
|
||||||
// Если идет обработка авторизации, показываем загрузку
|
|
||||||
if (isProcessing) {
|
|
||||||
return <div>Идёт загрузка...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... existing component code ...
|
|
||||||
129
src/pages/startPage/Components/OverTime.tsx
Normal file
129
src/pages/startPage/Components/OverTime.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useCurrentQuiz } from "@/stores/quizes/hooks";
|
||||||
|
import CustomTextField from "@/ui_kit/CustomTextField"
|
||||||
|
import { Box, Typography, useTheme } from "@mui/material"
|
||||||
|
import CustomizedSwitch from "@ui_kit/CustomSwitch";
|
||||||
|
|
||||||
|
const OverTime = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const quiz = useCurrentQuiz();
|
||||||
|
|
||||||
|
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: "inline-flex", alignItems: "center", height: "31px", gap: "20px" }}>
|
||||||
|
<CustomizedSwitch/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Включить счётчик
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mt: "25px" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Введите описание
|
||||||
|
</Typography>
|
||||||
|
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "327px", mt: "9px" }}
|
||||||
|
placeholder="Квиз будет недоступен через:"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "row-reverse", height: "16px" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "12px",
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
0/40
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
<Box sx={{ mt: "2px" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Введите время
|
||||||
|
</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" }}
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "3px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
дней
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
|
||||||
|
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "3px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
часов
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
|
||||||
|
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "3px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
мин.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "3px" }}>
|
||||||
|
<CustomTextField sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
|
||||||
|
placeholder="00"
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "3px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
сек.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default OverTime;
|
||||||
228
src/pages/startPage/Components/Timer.tsx
Normal file
228
src/pages/startPage/Components/Timer.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
|
||||||
|
import { updateQuiz } from "@/stores/quizes/actions";
|
||||||
|
import { useCurrentQuiz } from "@/stores/quizes/hooks";
|
||||||
|
import CustomTextField from "@/ui_kit/CustomTextField"
|
||||||
|
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"
|
||||||
|
import moment from "moment";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import CustomizedSwitch from "@ui_kit/CustomSwitch";
|
||||||
|
|
||||||
|
const Timer = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const quiz = useCurrentQuiz();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down(650));
|
||||||
|
const minutesRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const secondsRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const enabled = Boolean((quiz as any)?.config?.questionTimerEnabled);
|
||||||
|
|
||||||
|
const initialSeconds = useMemo(() => {
|
||||||
|
// Читаем из корректного поля, с fallback на возможное старое имя
|
||||||
|
const raw = (quiz as any)?.config?.time_of_passing ?? 0;
|
||||||
|
const sec = Number(raw) || 0;
|
||||||
|
return Math.max(0, sec);
|
||||||
|
}, [(quiz as any)?.config?.time_of_passing]);
|
||||||
|
|
||||||
|
const initial = useMemo(() => {
|
||||||
|
const d = moment.duration(initialSeconds, "seconds");
|
||||||
|
const m = Math.min(60, Math.max(0, d.minutes() + d.hours() * 60));
|
||||||
|
const s = Math.min(60, Math.max(0, d.seconds()));
|
||||||
|
return {
|
||||||
|
minutes: String(m),
|
||||||
|
seconds: String(s),
|
||||||
|
};
|
||||||
|
}, [initialSeconds]);
|
||||||
|
|
||||||
|
const [minutes, setMinutes] = useState<string>(initial.minutes);
|
||||||
|
const [seconds, setSeconds] = useState<string>(initial.seconds);
|
||||||
|
|
||||||
|
// Не синхронизируем обратно каждое изменение, чтобы не ломать ввод
|
||||||
|
|
||||||
|
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 persist = (mStr: string, sStr: string) => {
|
||||||
|
const m = Number(toDigits(mStr) || 0);
|
||||||
|
const s = Number(toDigits(sStr) || 0);
|
||||||
|
const total = Math.min(3660, Math.max(0, (isNaN(m) ? 0 : m) * 60 + (isNaN(s) ? 0 : s)));
|
||||||
|
updateQuiz(quiz!.id, (q) => {
|
||||||
|
// Пишем только в корректное поле модели
|
||||||
|
(q as any).config.time_of_passing = total;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 handleMinutesKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (allowControlKey(e)) return;
|
||||||
|
const isDigit = /^[0-9]$/.test(e.key);
|
||||||
|
if (!isDigit) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.currentTarget;
|
||||||
|
console.log("target")
|
||||||
|
console.log(target)
|
||||||
|
const selectionStart = target.selectionStart ?? 0;
|
||||||
|
const selectionEnd = target.selectionEnd ?? 0;
|
||||||
|
const selectionLength = Math.max(0, selectionEnd - selectionStart);
|
||||||
|
const currentLength = toDigits(minutes).length;
|
||||||
|
if (selectionLength === 0 && currentLength >= 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinutesPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData("text");
|
||||||
|
const next = clampTwoDigits(text);
|
||||||
|
setMinutes(next);
|
||||||
|
if (toDigits(next).length >= 2 && secondsRef.current) {
|
||||||
|
secondsRef.current.focus();
|
||||||
|
secondsRef.current.select();
|
||||||
|
}
|
||||||
|
persist(next, seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondsPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if ((e.target as HTMLInputElement).value.length >= 2) return;
|
||||||
|
const text = e.clipboardData.getData("text");
|
||||||
|
const next = clampTwoDigits(text);
|
||||||
|
setSeconds(next);
|
||||||
|
persist(minutes, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!quiz) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: "25px", mt: "20px", padding: "15px", backgroundColor: "#F2F3F7", width: "100%", maxWidth: "386px", height: "187px", borderRadius: "8px" }}>
|
||||||
|
<Box sx={{ display: "inline-flex", alignItems: "center", height: "31px", gap: "20px" }}>
|
||||||
|
<CustomizedSwitch
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateQuiz(quiz!.id, (q) => {
|
||||||
|
(q as any).config.questionTimerEnabled = e.target.checked;
|
||||||
|
(q as any).config.backBlocked = true;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Включить таймер вопросов
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Введите время
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "inline-flex", gap: "10px", pt: "8px" }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<CustomTextField
|
||||||
|
id="question-timer-minutes"
|
||||||
|
placeholder="00"
|
||||||
|
type="tel"
|
||||||
|
value={minutes}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = clampTwoDigits(e.target.value).slice(0, 2);
|
||||||
|
setMinutes(next);
|
||||||
|
if (toDigits(next).length === 2 && secondsRef.current) {
|
||||||
|
secondsRef.current.focus();
|
||||||
|
secondsRef.current.select();
|
||||||
|
}
|
||||||
|
persist(next, seconds);
|
||||||
|
}}
|
||||||
|
onFocus={() => minutesRef.current?.select()}
|
||||||
|
onClick={() => minutesRef.current?.select()}
|
||||||
|
onKeyDown={handleMinutesKeyDown as any}
|
||||||
|
onPaste={handleMinutesPaste as any}
|
||||||
|
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
|
||||||
|
sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
|
||||||
|
inputRef={minutesRef}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "4px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
мин.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||||
|
<CustomTextField
|
||||||
|
id="question-timer-seconds"
|
||||||
|
placeholder="00"
|
||||||
|
type="tel"
|
||||||
|
value={seconds}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = clampTwoDigits(e.target.value);
|
||||||
|
setSeconds(next);
|
||||||
|
persist(minutes, next);
|
||||||
|
}}
|
||||||
|
onFocus={() => secondsRef.current?.select()}
|
||||||
|
onClick={() => secondsRef.current?.select()}
|
||||||
|
onKeyDown={handleDigitKeyDown as any}
|
||||||
|
onPaste={handleSecondsPaste as any}
|
||||||
|
InputProps={{ inputProps: { pattern: "\\d*", inputMode: "numeric", maxLength: 2 } }}
|
||||||
|
sx={{ height: "48px", width: "100%", maxWidth: "51px" }}
|
||||||
|
inputRef={secondsRef}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 400,
|
||||||
|
color: theme.palette.grey3.main,
|
||||||
|
fontSize: "16px",
|
||||||
|
mt: "4px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
сек.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default Timer;
|
||||||
@ -48,6 +48,8 @@ import { VideoElement } from "../VideoElement";
|
|||||||
import UploadVideoModal from "../../Questions/UploadVideoModal";
|
import UploadVideoModal from "../../Questions/UploadVideoModal";
|
||||||
import { SwitchAI } from "@/ui_kit/crutchFunctionAI";
|
import { SwitchAI } from "@/ui_kit/crutchFunctionAI";
|
||||||
import BackBlockedWithTooltip from "./BackBlockedWithTooltip";
|
import BackBlockedWithTooltip from "./BackBlockedWithTooltip";
|
||||||
|
import OverTime from "../Components/OverTime";
|
||||||
|
import Timer from "../Components/Timer";
|
||||||
|
|
||||||
const designTypes = [
|
const designTypes = [
|
||||||
["standard", (color: string) => <LayoutStandartIcon color={color} />, "Standard"],
|
["standard", (color: string) => <LayoutStandartIcon color={color} />, "Standard"],
|
||||||
@ -894,7 +896,8 @@ export default function StartPageSettings() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<BackBlockedWithTooltip />
|
<BackBlockedWithTooltip />
|
||||||
<QuestionTimerSettings />
|
<Timer/>
|
||||||
|
<OverTime/>
|
||||||
{!isSmallMonitor && <SwitchAI />}
|
{!isSmallMonitor && <SwitchAI />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user