diff --git a/CHANGELOG.md b/CHANGELOG.md index 632c7c67..07c7f880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src.zip b/src.zip new file mode 100644 index 00000000..2e85d329 Binary files /dev/null and b/src.zip differ diff --git a/src/pages/InstallQuiz/QuizInstallationCard/QuizMarkCreate.tsx b/src/pages/InstallQuiz/QuizInstallationCard/QuizMarkCreate.tsx index 8962e692..254d3a61 100644 --- a/src/pages/InstallQuiz/QuizInstallationCard/QuizMarkCreate.tsx +++ b/src/pages/InstallQuiz/QuizInstallationCard/QuizMarkCreate.tsx @@ -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; - - -export default function QuizMarkCreate() { - const theme = useTheme(); - const quiz = useCurrentQuiz(); - const [category, setCategory] = useState("google"); - const [name, setName] = useState(""); - const [utm, setUtm] = useState(""); - const isMobile = useMediaQuery(theme.breakpoints.down(600)); - const isTablet = useMediaQuery(theme.breakpoints.down(1000)); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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) => { - 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 ( - - - - - Создание/выбор utm меток - - - - - - - - - - - - - setName(e.target.value)} - sx={{ - width: isTablet ? "100%" : "182px", - maxWidth: "182px", - "& .MuiInputBase-root": { - - height: "48px", - color: "#525253", - borderRadius: "8px 0 0 8px", - backgroundColor: "#F2F3F7", - }, - }} - /> - - - 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, - }, - }} - /> - - - - - - - - - - - - - - - - - {quiz?.qid && ( - { - const prev = items; - setItems(prev.filter((it) => it.id !== id)); - const [, err] = await utmApi.softDelete(id); - if (err) setItems(prev); - }} - />)} - - ); - +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; + + +export default function QuizMarkCreate() { + const theme = useTheme(); + const quiz = useCurrentQuiz(); + const [category, setCategory] = useState("google"); + const [name, setName] = useState(""); + const [utm, setUtm] = useState(""); + const isMobile = useMediaQuery(theme.breakpoints.down(600)); + const isTablet = useMediaQuery(theme.breakpoints.down(1000)); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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) => { + 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 ( + + + + + Создание/выбор utm меток + + + + + + + + + + + + + setName(e.target.value)} + sx={{ + width: isTablet ? "100%" : "182px", + maxWidth: "182px", + "& .MuiInputBase-root": { + + height: "48px", + color: "#525253", + borderRadius: "8px 0 0 8px", + backgroundColor: "#F2F3F7", + }, + }} + /> + + + 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, + }, + }} + /> + + + + + + + + + + + + + + + + + {quiz?.qid && ( + { + const prev = items; + setItems(prev.filter((it) => it.id !== id)); + const [, err] = await utmApi.softDelete(id); + if (err) setItems(prev); + }} + />)} + + ); + } \ No newline at end of file diff --git a/src/pages/InstallQuiz/QuizInstallationCard/UtmList.tsx b/src/pages/InstallQuiz/QuizInstallationCard/UtmList.tsx index fabb306f..2dfbf5ad 100644 --- a/src/pages/InstallQuiz/QuizInstallationCard/UtmList.tsx +++ b/src/pages/InstallQuiz/QuizInstallationCard/UtmList.tsx @@ -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 = ({ items, loading, error, onDelete, qui )} {items.length > perPage && ( - setPage(v)} size="small" /> + setPage(v)} /> )} diff --git a/src/pages/startPage/Components/OverTime.tsx b/src/pages/startPage/Components/OverTime.tsx index 64365384..38a1c896 100644 --- a/src/pages/startPage/Components/OverTime.tsx +++ b/src/pages/startPage/Components/OverTime.tsx @@ -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(initialOverTime.enabled)); + const [description, setDescription] = useState(initialOverTime.description || ""); + const [days, setDays] = useState("00"); + const [hours, setHours] = useState("00"); + const [minutes, setMinutes] = useState("00"); + const [seconds, setSeconds] = useState("00"); + + const [remainingMs, setRemainingMs] = useState( + Math.max(0, (initialOverTime.endsAt || 0) - Date.now()) + ); + + const daysRef = useRef(null); + const hoursRef = useRef(null); + const minutesRef = useRef(null); + const secondsRef = useRef(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) => { + const allowed = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab", "Home", "End", "Enter"]; + return allowed.includes(e.key); + }; + + const handleDigitKeyDown = (e: React.KeyboardEvent) => { + if (allowControlKey(e)) return; + if (!/^[0-9]$/.test(e.key)) { + e.preventDefault(); + } + }; + + const handleTwoDigitKeyDown = (value: string) => (e: React.KeyboardEvent) => { + 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 | 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 ( - + - + { + 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; + }); + }} + /> { > Введите описание - { + 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; + }); + }} /> @@ -51,8 +201,20 @@ const OverTime = () => { - { + 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" }} /> { - { + 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" }} /> { - { + 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" }} /> { - { + 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" }} /> { + {enabled && ( + + + До конца: {rem.d} д {fmt(rem.h)}:{fmt(rem.m)}:{fmt(rem.s)} + + + )} ) diff --git a/src/pages/startPage/Components/Timer.tsx b/src/pages/startPage/Components/Timer.tsx index 09fcd577..dee15c40 100644 --- a/src/pages/startPage/Components/Timer.tsx +++ b/src/pages/startPage/Components/Timer.tsx @@ -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} /> { 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} /> - + {enabled && ( { 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} /> - - { - updateQuiz(quiz.id, (quiz) => { - quiz.config.antifraud = e.target.checked; - }); - }} - /> - - Включить антифрод - - - + { @@ -896,8 +885,44 @@ export default function StartPageSettings() { - - + + + { + updateQuiz(quiz.id, (quiz) => { + quiz.config.antifraud = e.target.checked; + }); + }} + /> + + Включить антифрод + + + + + + + {!isSmallMonitor && } )}