Compare commits

...

22 Commits
est ... main

Author SHA1 Message Date
a532eecdca убираю спасибы 2025-08-18 16:12:35 +03:00
326e2c98b3 квизы умеют слушать запрет ..не пускать назад.. 2025-08-18 04:17:10 +03:00
b58042554f ci: production container deployment 2025-08-13 01:48:18 +03:00
147b776550 без логов 2025-08-13 00:56:46 +03:00
0c9b6e5b7a логи 2025-08-12 04:35:49 +03:00
785e56f9b0 fix шильдики отображают правильный цвет 2025-08-12 04:16:29 +03:00
f330c5c05a поле email при несуществовании не влияет на ФК 2025-08-11 17:29:00 +03:00
b1c3ab7314 fix inntr quiz id param 2025-08-09 23:57:33 +03:00
3baaef83fe deploy to docker 2025-08-09 16:36:54 +03:00
9e301e694f fix fields 2025-07-29 18:26:32 +03:00
f13df84fec ci: workaround branch error on registry deploy 2025-07-29 18:26:07 +03:00
4e2e591cd4 Merge branch 'staging' of gitea.pena:SQuiz/frontAnswerer into staging 2025-07-29 00:39:36 +03:00
670d2bcb3f fix lang in lib 2025-07-29 00:23:25 +03:00
c8ebf9cff0 Merge branch 'main' of gitea.pena:SQuiz/frontAnswerer 2025-07-29 00:18:46 +03:00
656ba9473c ФК кнопка отправки заблокирована пока не введут почту 2025-07-29 00:17:56 +03:00
17bd5cd53f ci: debug on pacjage deploy 2025-07-26 18:26:38 +03:00
b495974dba fix lang и номер телефона не даст отправить данные ФК если короче положенного 2025-07-26 01:48:51 +03:00
87f78ff8cf -- 2025-07-26 00:51:47 +03:00
c5e931702b ci: deploy on push to container registry 2025-07-25 15:12:05 +03:00
106d8b6ad7 fix config widget
Some checks failed
Deploy / DeployService (push) Successful in 24s
Deploy / CreateImage (push) Has been cancelled
2025-07-25 14:37:50 +03:00
32e5853ee8 передеплой
Some checks are pending
Deploy / CreateImage (push) Waiting to run
Deploy / DeployService (push) Successful in 24s
2025-07-25 13:55:22 +03:00
bcf2ca0842 fix lang in lib
All checks were successful
Deploy / CreateImage (push) Successful in 4m30s
Deploy / DeployService (push) Successful in 27s
2025-07-24 02:29:22 +03:00
36 changed files with 840 additions and 395 deletions

@ -2,23 +2,29 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry run-name: ${{ gitea.actor }} build image and push to container registry
on: on:
push: registry_package:
branches: types: [published]
- "main" #package_name: "gitea.pena/squiz/frontanswerer/main:latest"
jobs: jobs:
CreateImage: # CreateImage:
runs-on: [skeris] # runs-on: [skeris]
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p # uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with: # with:
runner: skeris # runner: skeris
secrets: # secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} # REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} # REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService: DeployService:
if: contains(github.event.package.name, 'main')
runs-on: [frontprod] runs-on: [frontprod]
needs: CreateImage container:
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7 image: gitea.pena/penadevops/container-images/node-compose:main
with: env:
runner: hubprod GITHUB_RUN_NUMBER: "${{ inputs.actionid }}"
actionid: ${{ gitea.run_id }} volumes:
- /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock
steps:
- name: Check out repository code
uses: http://gitea.pena/PenaDevops/actions.git/checkout@v1
- run: compose -f deployments/main/docker-compose.yaml up -d

@ -2,23 +2,28 @@ name: Deploy
run-name: ${{ gitea.actor }} build image and push to container registry run-name: ${{ gitea.actor }} build image and push to container registry
on: on:
push: registry_package:
branches: types: [published]
- "staging"
jobs: jobs:
CreateImage: # CreateImage:
runs-on: [skeris] # runs-on: [skeris]
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p # uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with: # with:
runner: hubstaging # runner: hubstaging
secrets: # secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} # REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} # REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService: DeployService:
if: contains(github.event.package.name, 'staging')
runs-on: [frontstaging] runs-on: [frontstaging]
needs: CreateImage container:
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7 image: gitea.pena:3000/penadevops/container-images/node-compose:main
with: env:
runner: frontstaging GITHUB_RUN_NUMBER: "${{ inputs.actionid }}"
actionid: ${{ gitea.run_id }} volumes:
- /run/user/1000/podman/podman.sock:/run/user/1000/podman/podman.sock
steps:
- name: Check out repository code
uses: http://gitea.pena:3000/PenaDevops/actions.git/checkout@v1
- run: compose -f deployments/staging/docker-compose.yaml up -d

@ -2,6 +2,7 @@ services:
respondent: respondent:
container_name: respondent container_name: respondent
restart: unless-stopped restart: unless-stopped
image: gitea.pena/squiz/frontanswerer/main:$GITHUB_RUN_NUMBER image: gitea.pena/squiz/frontanswerer/main:latest
hostname: respondent hostname: respondent
tty: true tty: true
pull_policy: always

@ -2,9 +2,7 @@ services:
respondent: respondent:
container_name: respondent container_name: respondent
restart: unless-stopped restart: unless-stopped
labels: image: gitea.pena/squiz/frontanswerer/staging:latest
com.pena.domains: s.hbpn.link
com.pena.front_headers: "Access-Control-Allow-Origin $$http_origin always"
image: gitea.pena:3000/squiz/frontanswerer/staging:$GITHUB_RUN_NUMBER
hostname: respondent hostname: respondent
tty: true tty: true
pull_policy: always

@ -32,19 +32,13 @@ export function useQuizData(quizId: string, preview: boolean = false) {
needConfig: true, needConfig: true,
}); });
//firstData.settings.status = "ai"; //firstData.settings.status = "ai";
console.log("useQuizData: firstData received:", firstData);
console.log("useQuizData: firstData.settings:", firstData.settings);
initDataManager({ initDataManager({
status: firstData.settings.status, status: firstData.settings.status,
haveRoot: firstData.settings.cfg.haveRoot, haveRoot: firstData.settings.cfg.haveRoot,
}); });
console.log("useQuizData: calling setQuizData with firstData");
setQuizData(firstData); setQuizData(firstData);
// Определяем нужно ли загружать все данные
console.log("Определяем нужно ли загружать все данные");
console.log(firstData.settings.status);
if (!["ai"].includes(firstData.settings.status)) { if (!["ai"].includes(firstData.settings.status)) {
setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа setNeedFullLoad(true); // Триггерит новый запрос через изменение ключа
return firstData; return firstData;
@ -62,7 +56,13 @@ export function useQuizData(quizId: string, preview: boolean = false) {
}); });
addQuestions(data.questions.slice(1)); addQuestions(data.questions.slice(1));
return data;
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions.slice(1)],
};
} }
if (currentPage >= questions.length) { if (currentPage >= questions.length) {
@ -74,15 +74,16 @@ export function useQuizData(quizId: string, preview: boolean = false) {
limit: 1, limit: 1,
needConfig: false, needConfig: false,
}); });
console.log(
"AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE AI RESPONSE "
);
console.log(data);
addQuestions(data.questions); addQuestions(data.questions);
changeNextLoading(false); changeNextLoading(false);
return data;
// Возвращаем полную структуру данных с настройками из store
const currentState = useQuizStore.getState();
return {
...currentState,
questions: [...currentState.questions, ...data.questions],
};
} catch (p) { } catch (p) {
console.log(p);
setPage(questions.length); setPage(questions.length);
changeNextLoading(false); changeNextLoading(false);
} }

@ -168,10 +168,7 @@ export async function getAndParceData(props: GetDataProps) {
} }
//Парсим строки в строках //Парсим строки в строках
console.log("до парса_______________________");
const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse)); const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse));
console.log("после парса_______________________");
console.log(quizSettings);
//Единоразово стрингифаим ВСЁ распаршенное и удаляем лишние пробелы //Единоразово стрингифаим ВСЁ распаршенное и удаляем лишние пробелы
const res = JSON.parse( const res = JSON.parse(
JSON.stringify({ data: quizSettings }) JSON.stringify({ data: quizSettings })

File diff suppressed because one or more lines are too long

@ -22,6 +22,7 @@ import { HelmetProvider } from "react-helmet-async";
import "moment/dist/locale/ru"; import "moment/dist/locale/ru";
import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore"; import { useQuizStore, setQuizData, addquizid } from "@/stores/useQuizStore";
import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl"; import { initDataManager, statusOfQuiz } from "@/utils/hooks/useQuestionFlowControl";
moment.locale("ru"); moment.locale("ru");
const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText; const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText;
@ -55,13 +56,6 @@ function QuizAnswererInner({
addquizid(quizId); addquizid(quizId);
}, []); }, []);
useEffect(() => {
console.log(settings);
console.log(questions);
console.log("r");
console.log(r);
}, [questions, settings]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
vkMetrics.quizOpened(); vkMetrics.quizOpened();
@ -72,7 +66,6 @@ function QuizAnswererInner({
useEffect(() => { useEffect(() => {
//Хук на случай если данные переданы нам сразу, а не "нам нужно их запросить" //Хук на случай если данные переданы нам сразу, а не "нам нужно их запросить"
if (quizSettings !== undefined) { if (quizSettings !== undefined) {
console.log("QuizAnswerer: calling setQuizData with quizSettings");
setQuizData(quizSettings); setQuizData(quizSettings);
initDataManager({ initDataManager({
status: quizSettings.settings.status, status: quizSettings.settings.status,
@ -98,19 +91,26 @@ function QuizAnswererInner({
}; };
}, []); }, []);
console.log("settings"); if (isLoading && !questions.length) {
console.log(settings); return <LoadingSkeleton />;
if (isLoading && !questions.length) return <LoadingSkeleton />; }
console.log("error"); if (error) {
console.log(error); return <ApologyPage error={error} />;
if (error) return <ApologyPage error={error} />; }
if (Object.keys(settings).length == 0) return <ApologyPage error={new Error("quiz data is null")} />; if (Object.keys(settings).length == 0) {
if (questions.length === 0) return <ApologyPage error={new Error("No questions found")} />; return <ApologyPage error={new Error("quiz data is null")} />;
}
if (questions.length === 0) {
return <ApologyPage error={new Error("No questions found")} />;
}
if (questions.length === 1 && settings.cfg.noStartPage && statusOfQuiz != "ai") if (questions.length === 1 && settings.cfg.noStartPage && statusOfQuiz != "ai") {
return <ApologyPage error={new Error("quiz is empty")} />; return <ApologyPage error={new Error("quiz is empty")} />;
if (!quizId) return <ApologyPage error={new Error("no quiz id")} />; }
if (!quizId) {
return <ApologyPage error={new Error("no quiz id")} />;
}
const quizContainer = ( const quizContainer = (
<Box <Box

@ -6,11 +6,7 @@ type Props = Partial<FallbackProps>;
export const ApologyPage = ({ error }: Props) => { export const ApologyPage = ({ error }: Props) => {
let message = error.message || error.response?.data || " "; let message = error.message || error.response?.data || " ";
console.log("message");
console.log(message.toLowerCase());
const { t } = useTranslation(); const { t } = useTranslation();
console.log("t");
console.log(t(message.toLowerCase()));
return ( return (
<Box <Box

@ -26,6 +26,7 @@ import type { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { isProduction } from "@/utils/defineDomain"; import { isProduction } from "@/utils/defineDomain";
import { useQuizStore } from "@/stores/useQuizStore"; import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NameplateLogoDark } from "@/assets/icons/NameplateLogoDark";
type Props = { type Props = {
currentQuestion: AnyTypedQuizQuestion; currentQuestion: AnyTypedQuizQuestion;
@ -45,6 +46,8 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [adress, setAdress] = useState(""); const [adress, setAdress] = useState("");
const [screenHeight, setScreenHeight] = useState<number>(window.innerHeight); const [screenHeight, setScreenHeight] = useState<number>(window.innerHeight);
const [emailError, setEmailError] = useState("");
const [phoneError, setPhoneError] = useState("");
const fireOnce = useRef(true); const fireOnce = useRef(true);
const [fire, setFire] = useState(false); const [fire, setFire] = useState(false);
@ -120,13 +123,23 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
async function handleShowResultsClick() { async function handleShowResultsClick() {
const FC = settings.cfg.formContact.fields; const FC = settings.cfg.formContact.fields;
if (!isDisableEmail && FC["email"].used !== EMAIL_REGEXP.test(email)) { // Проверяем email только если поле отображается
if (isEmailFieldVisible && !EMAIL_REGEXP.test(email)) {
return enqueueSnackbar("Incorrect email entered"); return enqueueSnackbar("Incorrect email entered");
} }
if (fireOnce.current) { if (fireOnce.current) {
if (name.length === 0 && email.length === 0 && phone.length === 0 && text.length === 0 && adress.length === 0) // Проверяем, что хотя бы одно видимое поле заполнено
const hasVisibleFieldsFilled =
(isNameFieldVisible() && name.length > 0) ||
(isEmailFieldVisible && email.length > 0) ||
(isPhoneFieldVisible() && phone.length > 0) ||
(isTextFieldVisible() && text.length > 0) ||
(isAddressFieldVisible() && adress.length > 0);
if (!hasVisibleFieldsFilled) {
return enqueueSnackbar(t("Please fill in the fields")); return enqueueSnackbar(t("Please fill in the fields"));
}
//почта валидна, хоть одно поле заполнено //почта валидна, хоть одно поле заполнено
setFire(true); setFire(true);
@ -177,6 +190,115 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Функция валидации телефона
const validatePhone = (phoneValue: string) => {
// Убираем все нецифровые символы и считаем только цифры
const digitsOnly = phoneValue.replace(/\D/g, "");
// Для российских номеров (начинающихся с +7) нужно 11 цифр
// Для остальных стран - минимум 10 цифр
const isRussianNumber = phoneValue.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
// Если есть какие-то символы в инпуте, но цифр меньше минимума - это ошибка
if (phoneValue.trim().length > 0 && digitsOnly.length < minDigits) {
return t("Please complete the phone number");
}
return "";
};
// Проверяем валидность телефона при каждом изменении
const digitsOnly = phone.replace(/\D/g, "");
const isRussianNumber = phone.startsWith("+7");
const minDigits = isRussianNumber ? 11 : 10;
const isPhoneValid = phone.trim().length === 0 || digitsOnly.length >= minDigits;
// Проверяем валидность email - должен быть заполнен и соответствовать формату
const validateEmail = (emailValue: string) => {
if (emailValue.trim().length === 0) return false;
// Проверяем наличие @ и .
const atIndex = emailValue.indexOf("@");
const dotIndex = emailValue.lastIndexOf(".");
if (atIndex === -1 || dotIndex === -1) return false;
// Точка должна быть после @
if (dotIndex <= atIndex) return false;
// Между @ и . должно быть минимум 3 символа
const domainPart = emailValue.substring(atIndex + 1, dotIndex);
if (domainPart.length < 3) return false;
// После точки должно быть минимум 2 символа
const tldPart = emailValue.substring(dotIndex + 1);
if (tldPart.length < 2) return false;
return true;
};
const isEmailValid = validateEmail(email);
// Определяем, отображается ли поле email
const isEmailFieldVisible = settings.cfg.formContact.fields?.email?.used && !isDisableEmail;
// Функции для определения видимости полей
const isNameFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["name"].used : true;
};
const isPhoneFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["phone"].used : true;
};
const isTextFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["text"].used : false;
};
const isAddressFieldVisible = () => {
const FC = settings.cfg.formContact.fields;
return Object.values(FC).some((data) => data.used) ? FC["address"].used : false;
};
// Обработчик изменения телефона
const handlePhoneChange = (newPhone: string) => {
setPhone(newPhone);
// Очищаем ошибку если поле стало пустым
if (newPhone.trim().length === 0) {
setPhoneError("");
}
};
// Обработчик изменения email
const handleEmailChange = (newEmail: string) => {
setEmail(newEmail);
// Очищаем ошибку если поле стало пустым
if (newEmail.trim().length === 0) {
setEmailError("");
}
};
// Обработчик потери фокуса для email
const handleEmailBlur = () => {
if (email.trim().length > 0 && !validateEmail(email)) {
setEmailError(t("Please enter a valid email"));
} else {
setEmailError("");
}
};
// Обработчик потери фокуса для телефона
const handlePhoneBlur = () => {
if (phone.trim().length > 0 && !isPhoneValid) {
setPhoneError(t("Please enter a valid phone number"));
} else {
setPhoneError("");
}
};
return ( return (
<Box <Box
sx={{ sx={{
@ -250,13 +372,17 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
name={name} name={name}
setName={setName} setName={setName}
email={email} email={email}
setEmail={setEmail} setEmail={handleEmailChange}
phone={phone} phone={phone}
setPhone={setPhone} setPhone={handlePhoneChange}
text={text} text={text}
setText={setText} setText={setText}
adress={adress} adress={adress}
setAdress={setAdress} setAdress={setAdress}
emailError={emailError}
phoneError={phoneError}
onEmailBlur={handleEmailBlur}
onPhoneBlur={handlePhoneBlur}
crutch={{ crutch={{
disableEmail: isDisableEmail, disableEmail: isDisableEmail,
}} }}
@ -304,7 +430,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
</Box> </Box>
<Button <Button
disabled={!(ready && !fire)} disabled={!(ready && !fire && isPhoneValid && (isEmailFieldVisible ? isEmailValid : true))}
variant="contained" variant="contained"
onClick={handleShowResultsClick} onClick={handleShowResultsClick}
sx={{ sx={{
@ -336,12 +462,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
margitTop: "auto", margitTop: "auto",
}} }}
> >
<NameplateLogo {quizThemes[settings.cfg.theme].isLight ? <NameplateLogoDark /> : <NameplateLogo />}
style={{
fontSize: "20px",
color: quizThemes[settings.cfg.theme].isLight ? "#151515" : "#FFFFFF",
}}
/>
</Box> </Box>
)} )}
</Box> </Box>

@ -17,6 +17,8 @@ type InputProps = {
isPhone?: boolean; isPhone?: boolean;
type?: HTMLInputTypeAttribute; type?: HTMLInputTypeAttribute;
value?: string; value?: string;
onBlur?: () => void;
error?: string;
}; };
const TextField = MuiTextField as unknown as FC<TextFieldProps>; const TextField = MuiTextField as unknown as FC<TextFieldProps>;
@ -34,7 +36,18 @@ function phoneChange(e: ChangeEvent<HTMLInputElement>, mask: string) {
return a || ""; return a || "";
} }
export const CustomInput = ({ title, desc, Icon, onChange, onChangePhone, isPhone, type, value }: InputProps) => { export const CustomInput = ({
title,
desc,
Icon,
onChange,
onChangePhone,
isPhone,
type,
value,
onBlur,
error,
}: InputProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useRootContainerSize() < 600; const isMobile = useRootContainerSize() < 600;
const { settings } = useQuizStore(); const { settings } = useQuizStore();
@ -57,8 +70,11 @@ export const CustomInput = ({ title, desc, Icon, onChange, onChangePhone, isPhon
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange={(e: ChangeEvent<HTMLInputElement>) =>
isPhone ? onChangePhone?.(phoneChange(e, mask)) : onChange?.(e) isPhone ? onChangePhone?.(phoneChange(e, mask)) : onChange?.(e)
} }
onBlur={onBlur}
type={isPhone ? "tel" : type} type={isPhone ? "tel" : type}
value={value} value={value}
error={!!error}
helperText={error}
sx={{ sx={{
width: isMobile ? "100%" : "390px", width: isMobile ? "100%" : "390px",
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,

@ -13,13 +13,17 @@ type InputsProps = {
name: string; name: string;
setName: Dispatch<SetStateAction<string>>; setName: Dispatch<SetStateAction<string>>;
email: string; email: string;
setEmail: Dispatch<SetStateAction<string>>; setEmail: (email: string) => void;
phone: string; phone: string;
setPhone: Dispatch<SetStateAction<string>>; setPhone: (phone: string) => void;
text: string; text: string;
setText: Dispatch<SetStateAction<string>>; setText: Dispatch<SetStateAction<string>>;
adress: string; adress: string;
setAdress: Dispatch<SetStateAction<string>>; setAdress: Dispatch<SetStateAction<string>>;
emailError?: string;
phoneError?: string;
onEmailBlur?: () => void;
onPhoneBlur?: () => void;
crutch: { crutch: {
disableEmail: boolean; disableEmail: boolean;
}; };
@ -39,6 +43,10 @@ export const Inputs = ({
setText, setText,
adress, adress,
setAdress, setAdress,
emailError,
phoneError,
onEmailBlur,
onPhoneBlur,
crutch, crutch,
}: InputsProps) => { }: InputsProps) => {
const { settings } = useQuizStore(); const { settings } = useQuizStore();
@ -64,11 +72,13 @@ export const Inputs = ({
onChange={({ target }) => { onChange={({ target }) => {
setEmail(target.value.replaceAll(/\s/g, "")); setEmail(target.value.replaceAll(/\s/g, ""));
}} }}
onBlur={onEmailBlur}
id={email} id={email}
title={FC["email"].innerText || `${t("Enter")} Email`} title={FC["email"].innerText || `${t("Enter")} Email`}
desc={FC["email"].text || "Email"} desc={FC["email"].text || "Email"}
Icon={EmailIcon} Icon={EmailIcon}
type="email" type="email"
error={emailError}
/> />
); );
const Phone = ( const Phone = (
@ -77,12 +87,14 @@ export const Inputs = ({
onChangePhone={(phone: string) => { onChangePhone={(phone: string) => {
setPhone(phone); setPhone(phone);
}} }}
onBlur={onPhoneBlur}
value={phone} value={phone}
id={phone} id={phone}
title={FC["phone"].innerText || `${t("Enter")} ${t("Phone number").toLowerCase()}`} title={FC["phone"].innerText || `${t("Enter")} ${t("Phone number").toLowerCase()}`}
desc={FC["phone"].text || t("Phone number")} desc={FC["phone"].text || t("Phone number")}
Icon={PhoneIcon} Icon={PhoneIcon}
isPhone={true} isPhone={true}
error={phoneError}
/> />
); );
const Text = ( const Text = (

@ -15,7 +15,7 @@ export const Footer = ({ stepNumber, nextButton, prevButton }: FooterProps) => {
const theme = useTheme(); const theme = useTheme();
const { questions, settings } = useQuizStore(); const { questions, settings } = useQuizStore();
const questionsAmount = questions.filter(({ type }) => type !== "result").length; const questionsAmount = questions.filter(({ type }) => type !== "result").length;
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return ( return (
<Box <Box

@ -6,6 +6,23 @@ import { AnyTypedQuizQuestion, QuizQuestionVariant } from "@/index";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuizStore } from "@/stores/useQuizStore"; import { useQuizStore } from "@/stores/useQuizStore";
const dinocrutch = window.location.pathname === "/413b9e24-996a-400e-9076-c158f64b9bd7";
// Функция для определения вопроса "спасибо"
const isThankYouQuestion = (question: QuizQuestionVariant): boolean => {
// Проверяем что у вопроса только один вариант ответа
if (question.content.variants.length !== 1) {
return false;
}
// Проверяем что текст варианта полностью состоит из слова "спасибо"
const variant = question.content.variants[0];
const answerText = variant.answer.toLowerCase().trim();
// Проверяем точное совпадение со словом "спасибо"
return answerText === "спасибо";
};
export const PointSystemResultList = () => { export const PointSystemResultList = () => {
const theme = useTheme(); const theme = useTheme();
const { questions } = useQuizStore(); const { questions } = useQuizStore();
@ -16,7 +33,12 @@ export const PointSystemResultList = () => {
(q: AnyTypedQuizQuestion): q is QuizQuestionVariant => q.type === "variant" (q: AnyTypedQuizQuestion): q is QuizQuestionVariant => q.type === "variant"
); );
return questionsWothoutResult.map((currentQuestion) => { // Фильтруем вопросы "спасибо" только для указанного квиза
const filteredQuestions = dinocrutch
? questionsWothoutResult.filter((q) => !isThankYouQuestion(q))
: questionsWothoutResult;
return filteredQuestions.map((currentQuestion, index) => {
let answerIndex = 0; let answerIndex = 0;
let currentVariants = currentQuestion.content.variants; let currentVariants = currentQuestion.content.variants;
@ -53,7 +75,7 @@ export const PointSystemResultList = () => {
color: theme.palette.grey[500], color: theme.palette.grey[500],
}} }}
> >
{currentQuestion.page + 1}. {index + 1}.
</Typography> </Typography>
<Typography <Typography
sx={{ sx={{

@ -12,6 +12,7 @@ import { quizThemes } from "@utils/themes/Publication/themePublication";
import { NameplateLogo } from "@icons/NameplateLogo"; import { NameplateLogo } from "@icons/NameplateLogo";
import type { QuizQuestionResult } from "@/model/questionTypes/result"; import type { QuizQuestionResult } from "@/model/questionTypes/result";
import type { QuizQuestionVariant } from "@/model/questionTypes/variant";
import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe"; import QuizVideo from "@/ui_kit/VideoIframe/VideoIframe";
import { TextAccordion } from "./tools/TextAccordion"; import { TextAccordion } from "./tools/TextAccordion";
import { PointSystemResultList } from "./PointSystemResultList"; import { PointSystemResultList } from "./PointSystemResultList";
@ -20,11 +21,27 @@ import { sendFC, sendResult } from "@/api/quizRelase";
import { isProduction } from "@/utils/defineDomain"; import { isProduction } from "@/utils/defineDomain";
import { useQuizStore } from "@/stores/useQuizStore"; import { useQuizStore } from "@/stores/useQuizStore";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NameplateLogoDark } from "@/assets/icons/NameplateLogoDark";
type ResultFormProps = { type ResultFormProps = {
resultQuestion: QuizQuestionResult; resultQuestion: QuizQuestionResult;
}; };
// Функция для определения вопроса "спасибо"
const isThankYouQuestion = (question: QuizQuestionVariant): boolean => {
// Проверяем что у вопроса только один вариант ответа
if (question.content.variants.length !== 1) {
return false;
}
// Проверяем что текст варианта полностью состоит из слова "спасибо"
const variant = question.content.variants[0];
const answerText = variant.answer.toLowerCase().trim();
// Проверяем точное совпадение со словом "спасибо"
return answerText === "спасибо";
};
export const ResultForm = ({ resultQuestion }: ResultFormProps) => { export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useRootContainerSize() < 650; const isMobile = useRootContainerSize() < 650;
@ -38,6 +55,22 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber); const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
const { t } = useTranslation(); const { t } = useTranslation();
// Проверяем, является ли это квизом с костылем
const dinocrutch = window.location.pathname === "/413b9e24-996a-400e-9076-c158f64b9bd7";
// Вычисляем общее количество вопросов с учетом фильтрации
const totalQuestions = useMemo(() => {
if (dinocrutch) {
// Для квиза с костылем: исключаем вопросы "спасибо" и вопросы типа "result"
const variantQuestions = questions.filter((e) => e.type === "variant") as QuizQuestionVariant[];
const filteredQuestions = variantQuestions.filter((q) => !isThankYouQuestion(q));
return filteredQuestions.length;
}
// Для обычных квизов: исключаем только вопросы типа "result"
return questions.filter((e) => e.type !== "result").length;
}, [questions, dinocrutch]);
useEffect(() => { useEffect(() => {
vkMetrics.resultIdShown(resultQuestion.id); vkMetrics.resultIdShown(resultQuestion.id);
yandexMetrics.resultIdShown(resultQuestion.id); yandexMetrics.resultIdShown(resultQuestion.id);
@ -259,7 +292,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
fontWeight: 600, fontWeight: 600,
}} }}
> >
{pointsSum} {t("of")} {questions.filter((e) => e.type != "result").length} {pointsSum} {t("of")} {totalQuestions}
</Typography> </Typography>
<TextAccordion <TextAccordion
headerText={ headerText={
@ -308,12 +341,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
bottom: "90px", bottom: "90px",
}} }}
> >
<NameplateLogo {quizThemes[settings.cfg.theme].isLight ? <NameplateLogoDark /> : <NameplateLogo />}
style={{
fontSize: "23px",
color: quizThemes[settings.cfg.theme].isLight ? "#000000" : "#F5F7FF",
}}
/>
</Box> </Box>
)} )}
<Box <Box

@ -9,6 +9,7 @@ import { useUADevice } from "@utils/hooks/useUADevice";
import { quizThemes } from "@utils/themes/Publication/themePublication"; import { quizThemes } from "@utils/themes/Publication/themePublication";
import { NameplateLogo } from "@icons/NameplateLogo"; import { NameplateLogo } from "@icons/NameplateLogo";
import { NameplateLogoDark } from "@icons/NameplateLogoDark";
import { useQuizViewStore } from "@/stores/quizView"; import { useQuizViewStore } from "@/stores/quizView";
import { DESIGN_LIST } from "@/utils/designList"; import { DESIGN_LIST } from "@/utils/designList";
@ -153,17 +154,13 @@ export const StartPageViewPublication = () => {
: undefined, : undefined,
}} }}
> >
<NameplateLogo {settings.cfg.startpageType === "expanded" ? (
style={{ <NameplateLogo />
fontSize: "23px", ) : quizThemes[settings.cfg.theme].isLight ? (
color: <NameplateLogoDark />
settings.cfg.startpageType === "expanded" ) : (
? "#FFFFFF" <NameplateLogo />
: quizThemes[settings.cfg.theme].isLight )}
? "#151515"
: "#FFFFFF",
}}
/>
</Box> </Box>
); );

@ -70,7 +70,7 @@ export default function ViewPublicationPage() {
if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed"); if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed");
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question"; if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question";
if (!currentQuestion) if (!currentQuestion) {
return ( return (
<ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}> <ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}>
<Typography <Typography
@ -81,6 +81,7 @@ export default function ViewPublicationPage() {
</Typography> </Typography>
</ThemeProvider> </ThemeProvider>
); );
}
const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id); const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);

@ -36,10 +36,6 @@ export const Number = ({ currentQuestion }: NumberProps) => {
answer || answer ||
(reversed ? max + min - currentQuestion.content.start + "—" + max : currentQuestion.content.start + "—" + max); (reversed ? max + min - currentQuestion.content.start + "—" + max : currentQuestion.content.start + "—" + max);
useEffect(() => {
console.log("reversed:", reversed);
}, [reversed]);
const sendAnswerToBackend = async (value: string, noUpdate = false) => { const sendAnswerToBackend = async (value: string, noUpdate = false) => {
if (!noUpdate) { if (!noUpdate) {
updateAnswer(currentQuestion.id, value, 0); updateAnswer(currentQuestion.id, value, 0);

@ -10,7 +10,7 @@ interface Props {
export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) { export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }: Props) {
const { settings, nextLoading } = useQuizStore(); const { settings, nextLoading } = useQuizStore();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return nextLoading ? ( return nextLoading ? (
<Skeleton <Skeleton

@ -13,7 +13,8 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
const theme = useTheme(); const theme = useTheme();
const { settings } = useQuizStore(); const { settings } = useQuizStore();
const isMobileMini = useRootContainerSize() < 382; const isMobileMini = useRootContainerSize() < 382;
const { t } = useTranslation(); const { t, i18n } = useTranslation();
return ( return (
<Button <Button
disabled={!isPreviousButtonEnabled} disabled={!isPreviousButtonEnabled}

@ -27,7 +27,6 @@ export interface GetQuizDataResponse {
} }
export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizSettings, "recentlyCompleted"> { export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizSettings, "recentlyCompleted"> {
console.log(quizDataResponse);
const readyData = { const readyData = {
cnt: quizDataResponse.cnt, cnt: quizDataResponse.cnt,
show_badge: quizDataResponse.show_badge, show_badge: quizDataResponse.show_badge,
@ -51,7 +50,8 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
answerType: "single", answerType: "single",
onlyNumbers: false, onlyNumbers: false,
}; };
if (item.c) content.id = Math.floor(Math.random() * 9999999999) + 1; // Убираю замену ID - оставляю оригинальный с бэкенда
// if (item.c) content.id = Math.floor(Math.random() * 9999999999) + 1;
return { return {
description: item.desc, description: item.desc,
id: item.id, id: item.id,
@ -66,7 +66,6 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
readyData.questions = items; readyData.questions = items;
if (quizDataResponse?.settings !== undefined) { if (quizDataResponse?.settings !== undefined) {
console.log("попытка парсануть сеттингс", quizDataResponse.settings);
readyData.settings = { readyData.settings = {
fp: quizDataResponse.settings.fp, fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep, rep: quizDataResponse.settings.rep,

@ -119,6 +119,7 @@ export interface QuizConfig {
showfc?: boolean; showfc?: boolean;
yandexMetricsNumber?: number; yandexMetricsNumber?: number;
vkMetricsNumber?: number; vkMetricsNumber?: number;
backBlocked?: boolean;
} }
export type FormContactFieldName = "name" | "email" | "phone" | "text" | "address"; export type FormContactFieldName = "name" | "email" | "phone" | "text" | "address";

@ -25,22 +25,14 @@ export const useQuizStore = create<QuizStore>(() => ({
})); }));
export const setQuizData = (data: QuizSettings) => { export const setQuizData = (data: QuizSettings) => {
console.log("setQuizData called with:");
console.log("data:", data);
console.log("data.settings:", data.settings);
console.log("data.questions:", data.questions);
const currentState = useQuizStore.getState(); const currentState = useQuizStore.getState();
console.log("Current state before update:", currentState);
useQuizStore.setState((state: QuizStore) => { useQuizStore.setState((state: QuizStore) => {
const newState = { ...state, ...data }; const newState = { ...state, ...data };
console.log("New state after update:", newState);
return newState; return newState;
}); });
const updatedState = useQuizStore.getState(); const updatedState = useQuizStore.getState();
console.log("State after setState:", updatedState);
}; };
export const addQuestions = (newQuestions: AnyTypedQuizQuestion[]) => export const addQuestions = (newQuestions: AnyTypedQuizQuestion[]) =>

@ -13,6 +13,6 @@ const isProduction = !(
//туризм больше не в исключениях //туризм больше не в исключениях
if (!isProduction) domain = "https://s.hbpn.link"; if (!isProduction) domain = "https://s.hbpn.link";
domain = "https://hbpn.link"; // domain = "https://hbpn.link";
export { domain, isProduction }; export { domain, isProduction };

@ -37,6 +37,6 @@ async function sendErrorsToServer() {
// body: errorsQueue, // body: errorsQueue,
// useToken: true, // useToken: true,
// }); // });
console.log(`Fake-sending ${errorsQueue.length} errors to server`, errorsQueue); console.info(`Fake-sending ${errorsQueue.length} errors to server`, errorsQueue);
errorsQueue = []; errorsQueue = [];
} }

@ -14,11 +14,6 @@ export function useAIQuiz() {
//Получаем инфо о квизе и список вопросов. //Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt, quizStep } = useQuizStore(); const { settings, questions, quizId, cnt, quizStep } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах //Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
const answers = useQuizViewStore((state) => state.answers); const answers = useQuizViewStore((state) => state.answers);
@ -29,9 +24,6 @@ export function useAIQuiz() {
const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber); const yandexMetrics = useYandexMetricsGoals(settings.cfg.yandexMetricsNumber);
const currentQuestion = useMemo(() => { const currentQuestion = useMemo(() => {
console.log("выбор currentQuestion");
console.log("quizStep ", quizStep);
console.log("questions[quizStep] ", questions[quizStep]);
const calcQuestion = questions[quizStep]; const calcQuestion = questions[quizStep];
if (calcQuestion) { if (calcQuestion) {
vkMetrics.questionPassed(calcQuestion.id); vkMetrics.questionPassed(calcQuestion.id);
@ -44,8 +36,6 @@ export function useAIQuiz() {
useEffect(() => { useEffect(() => {
if (currentQuestion.type === "result") showResult(); if (currentQuestion.type === "result") showResult();
if (currentQuestion) changeNextLoading(false); if (currentQuestion) changeNextLoading(false);
console.log("questions");
console.log(questions);
}, [currentQuestion, questions]); }, [currentQuestion, questions]);
//Показать визуалом юзеру результат //Показать визуалом юзеру результат
@ -86,7 +76,7 @@ export function useAIQuiz() {
const setQuestion = useCallback((_: string) => {}, []); const setQuestion = useCallback((_: string) => {}, []);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = quizStep > 0; const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : quizStep > 0;
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {

@ -14,12 +14,6 @@ export function useBranchingQuiz() {
//Получаем инфо о квизе и список вопросов. //Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt } = useQuizStore(); const { settings, questions, quizId, cnt } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
console.log(questions);
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page. //Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page //За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
const sortedQuestions = useMemo(() => { const sortedQuestions = useMemo(() => {
@ -227,7 +221,7 @@ export function useBranchingQuiz() {
); );
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion); const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {
@ -237,9 +231,6 @@ export function useBranchingQuiz() {
return hasAnswer; return hasAnswer;
} }
console.log(linearQuestionIndex);
console.log(questions.length);
console.log(cnt);
if (linearQuestionIndex !== null && questions.length < cnt) return true; if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion); return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]); }, [answers, currentQuestion, nextQuestion]);

@ -14,12 +14,6 @@ export function useLinearQuiz() {
//Получаем инфо о квизе и список вопросов. //Получаем инфо о квизе и список вопросов.
const { settings, questions, quizId, cnt } = useQuizStore(); const { settings, questions, quizId, cnt } = useQuizStore();
useEffect(() => {
console.log("useQuestionFlowControl useEffect");
console.log(questions);
}, [questions]);
console.log(questions);
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page. //Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page //За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
const sortedQuestions = useMemo(() => { const sortedQuestions = useMemo(() => {
@ -227,7 +221,7 @@ export function useLinearQuiz() {
); );
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isPreviousButtonEnabled = Boolean(prevQuestion); const isPreviousButtonEnabled = settings.cfg?.backBlocked ? false : Boolean(prevQuestion);
//Анализ дисаблить ли кнопки навигации //Анализ дисаблить ли кнопки навигации
const isNextButtonEnabled = useMemo(() => { const isNextButtonEnabled = useMemo(() => {
@ -237,9 +231,6 @@ export function useLinearQuiz() {
return hasAnswer; return hasAnswer;
} }
console.log(linearQuestionIndex);
console.log(questions.length);
console.log(cnt);
if (linearQuestionIndex !== null && questions.length < cnt) return true; if (linearQuestionIndex !== null && questions.length < cnt) return true;
return Boolean(nextQuestion); return Boolean(nextQuestion);
}, [answers, currentQuestion, nextQuestion]); }, [answers, currentQuestion, nextQuestion]);

@ -26,6 +26,7 @@
"preview": "vite preview", "preview": "vite preview",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"prepublishOnly": "npm run build:package", "prepublishOnly": "npm run build:package",
"deploy": "docker login gitea.pena && docker build -t gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest . && docker push gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {

@ -56,5 +56,8 @@
"Step": "Шаг", "Step": "Шаг",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите", "questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите",
"Add your image": "Добавьте своё изображение", "Add your image": "Добавьте своё изображение",
"select emoji": "выберите смайлик" "select emoji": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, завершите номер телефона",
"Please enter a valid email": "Пожалуйста, введите корректную почту",
"Please enter a valid phone number": "Пожалуйста, введите корректный номер телефона"
} }

@ -10,11 +10,10 @@ const getLanguageFromURL = (): string => {
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i); const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
if (langMatch) { if (langMatch) {
//console.log("Язык из URL:", langMatch[1]); const detectedLang = langMatch[1].toLowerCase();
return langMatch[1].toLowerCase(); return detectedLang;
} }
//console.log('Язык не указан в URL, используем "ru"');
return "ru"; // Жёсткий фолбэк return "ru"; // Жёсткий фолбэк
}; };
@ -33,6 +32,9 @@ i18n
backend: { backend: {
loadPath: "/locales/{{lng}}.json", loadPath: "/locales/{{lng}}.json",
allowMultiLoading: false, allowMultiLoading: false,
requestOptions: {
cache: "no-store",
},
}, },
react: { react: {
useSuspense: false, // Отключаем для совместимости с React 18 useSuspense: false, // Отключаем для совместимости с React 18
@ -43,11 +45,11 @@ i18n
caches: [], // Не использовать localStorage caches: [], // Не использовать localStorage
}, },
parseMissingKeyHandler: (key) => { parseMissingKeyHandler: (key) => {
console.warn("Missing translation:", key); console.warn("⚠️ Main i18n: Missing translation:", key);
return key; // Вернёт ключ вместо ошибки return key; // Вернёт ключ вместо ошибки
}, },
missingKeyHandler: (lngs, ns, key) => { missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Missing i18n key:", { console.error("🚨 Main i18n: Missing i18n key:", {
key, key,
languages: lngs, languages: lngs,
namespace: ns, namespace: ns,
@ -55,21 +57,35 @@ i18n
}); });
}, },
}) })
.then(() => {
//console.log("i18n инициализирован! Текущий язык:", i18n.language);
//console.log("Загруженные переводы:", i18n.store.data);
})
.catch((err) => { .catch((err) => {
console.error("Ошибка i18n:", err); console.error("❌ Main i18n: Initialization failed:", err);
}); });
// 3. Логирование всех событий // 3. Логирование всех событий
i18n.on("languageChanged", (lng) => { i18n.on("languageChanged", (lng) => {
console.log("Язык изменён на:", lng); console.log("🔄 Main i18n: Language changed to:", lng);
});
i18n.on("initialized", (options) => {
console.log("🎯 Main i18n: Initialized event fired with options:", options);
}); });
i18n.on("failedLoading", (lng, ns, msg) => { i18n.on("failedLoading", (lng, ns, msg) => {
console.error(`Ошибка загрузки ${lng}.json:`, msg); console.error(`💥 Main i18n: Failed loading ${lng}.json:`, msg);
// Если не удалось загрузить русский, пробуем английский
if (lng === "ru") {
console.log("🔄 Main i18n: Trying to load English as fallback");
i18n.changeLanguage("en");
}
});
i18n.on("loaded", (loaded) => {
console.log("📦 Main i18n: Translations loaded:", loaded);
});
i18n.on("missingKey", (lngs, namespace, key, res) => {
console.warn("⚠️ Main i18n: Missing key event:", { lngs, namespace, key, res });
}); });
export default i18n; export default i18n;

@ -5,241 +5,283 @@ import { initReactI18next } from "react-i18next";
const getLanguageFromURL = (): string => { const getLanguageFromURL = (): string => {
const path = window.location.pathname; const path = window.location.pathname;
const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i); const langMatch = path.match(/^\/(en|ru|uz)(\/|$)/i);
return langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru' const detectedLang = langMatch ? langMatch[1].toLowerCase() : "ru"; // Фолбэк на 'ru'
return detectedLang;
}; };
// 2. Локали, встроенные прямо в конфиг // 2. Локали, встроенные прямо в конфиг
const r = { const r = {
ru: { ru: {
"quiz is inactive": "Квиз не активирован", translation: {
"no questions found": "Нет созданных вопросов", "quiz is inactive": "Квиз не активирован",
"quiz is empty": "Квиз пуст", "no questions found": "Нет созданных вопросов",
"quiz already completed": "Вы уже прошли этот опрос", "quiz is empty": "Квиз пуст",
"no quiz id": "Отсутствует id квиза", "quiz already completed": "Вы уже прошли этот опрос",
"quiz data is null": "Не были переданы параметры квиза", "no quiz id": "Отсутствует id квиза",
"invalid request data": "Такого квиза не существует", "quiz data is null": "Не были переданы параметры квиза",
"default message": "Что-то пошло не так", "invalid request data": "Такого квиза не существует",
"The request could not be sent": "Заявка не может быть отправлена", "default message": "Что-то пошло не так",
"The number of points could not be sent": "Количество баллов не может быть отправлено", "The request could not be sent": "Заявка не может быть отправлена",
"Your result": "Ваш результат", "The number of points could not be sent": "Количество баллов не может быть отправлено",
"Your points": "Ваши баллы", "Your result": "Ваш результат",
of: "из", "Your points": "Ваши баллы",
"View answers": "Посмотреть ответы", of: "из",
"Find out more": "Узнать подробнее", "View answers": "Посмотреть ответы",
"Go to website": "Перейти на сайт", "Find out more": "Узнать подробнее",
"Question title": "Заголовок вопроса", "Go to website": "Перейти на сайт",
"Question without a title": "Вопрос без названия", "Question title": "Заголовок вопроса",
"Your answer": "Ваш ответ", "Question without a title": "Вопрос без названия",
"Add image": "Добавить изображение", "Your answer": "Ваш ответ",
"Accepts images": "Принимает изображения", "Add image": "Добавить изображение",
"Add video": "Добавить видео", "Accepts images": "Принимает изображения",
"Accepts .mp4 and .mov format - maximum 50mb": "Принимает .mp4 и .mov формат — максимум 50мб", "Add video": "Добавить видео",
"Add audio file": "Добавить аудиофайл", "Accepts .mp4 and .mov format - maximum 50mb": "Принимает .mp4 и .mov формат — максимум 50мб",
"Accepts audio files": "Принимает аудиофайлы", "Add audio file": "Добавить аудиофайл",
"Add document": "Добавить документ", "Accepts audio files": "Принимает аудиофайлы",
"Accepts documents": "Принимает документы", "Add document": "Добавить документ",
Next: "Далее", "Accepts documents": "Принимает документы",
Prev: "Назад", Next: "Далее",
From: "От", Prev: "Назад",
До: "До", From: "От",
"Enter your answer": "Введите свой ответ", До: "До",
"Incorrect file type selected": "Выбран некорректный тип файла", "Enter your answer": "Введите свой ответ",
"File is too big. Maximum size is 50 MB": "Файл слишком большой. Максимальный размер 50 МБ", "Incorrect file type selected": "Выбран некорректный тип файла",
"Acceptable file extensions": "Допустимые расширения файлов", "File is too big. Maximum size is 50 MB": "Файл слишком большой. Максимальный размер 50 МБ",
"You have uploaded": "Вы загрузили", "Acceptable file extensions": "Допустимые расширения файлов",
"The answer was not counted": "Ответ не был засчитан", "You have uploaded": "Вы загрузили",
"Select an answer option below": "Выберите вариант ответа ниже", "The answer was not counted": "Ответ не был засчитан",
"Select an answer option on the left": "Выберите вариант ответа слева", "Select an answer option below": "Выберите вариант ответа ниже",
"Fill out the form to receive your test results": "Заполните форму, чтобы получить результаты теста", "Select an answer option on the left": "Выберите вариант ответа слева",
Enter: "Введите", "Fill out the form to receive your test results": "Заполните форму, чтобы получить результаты теста",
Name: "Имя", Enter: "Введите",
"Phone number": "Номер телефона", Name: "Имя",
"Last name": "Фамилия", "Phone number": "Номер телефона",
Address: "Адрес", "Last name": "Фамилия",
"Incorrect email entered": "Введена некорректная почта", Address: "Адрес",
"Please fill in the fields": "Пожалуйста, заполните поля", "Incorrect email entered": "Введена некорректная почта",
"Please try again later": "повторите попытку позже", "Please fill in the fields": "Пожалуйста, заполните поля",
"Regulation on the processing of personal data": "Положением об обработке персональных данных", "Please try again later": "повторите попытку позже",
"Privacy Policy": "Политикой конфиденциальности", "Regulation on the processing of personal data": "Положением об обработке персональных данных",
familiarized: "ознакомлен", "Privacy Policy": "Политикой конфиденциальности",
and: "и", familiarized: "ознакомлен",
"Get results": "Получить результаты", and: "и",
"Data sent successfully": "Данные успешно отправлены", "Get results": "Получить результаты",
Step: "Шаг", "Data sent successfully": "Данные успешно отправлены",
"questions are not ready yet": "Вопросы для аудитории ещё не созданы. Пожалуйста, подождите", Step: "Шаг",
"Add your image": "Добавьте своё изображение", "questions are not ready yet": "Вопросы для аудитории пока не готовы. Подождите",
"select emoji": "выберите смайлик", "Add your image": "Добавьте своё изображение",
"": "", // Пустой ключ для fallback "select emoji": "выберите смайлик",
"Please complete the phone number": "Пожалуйста, заполните номер телефона до конца",
"": "", // Пустой ключ для fallback
},
}, },
en: { en: {
"quiz is inactive": "Quiz is inactive", translation: {
"no questions found": "No questions found", "quiz is inactive": "Quiz is inactive",
"quiz is empty": "Quiz is empty", "no questions found": "No questions found",
"quiz already completed": "You've already completed this quiz", "quiz is empty": "Quiz is empty",
"no quiz id": "Missing quiz ID", "quiz already completed": "You've already completed this quiz",
"quiz data is null": "No quiz parameters were provided", "no quiz id": "Missing quiz ID",
"invalid request data": "This quiz doesn't exist", "quiz data is null": "No quiz parameters were provided",
"default message": "Something went wrong", "invalid request data": "This quiz doesn't exist",
"The request could not be sent": "Request could not be sent", "default message": "Something went wrong",
"The number of points could not be sent": "Points could not be submitted", "The request could not be sent": "Request could not be sent",
"Your result": "Your result", "The number of points could not be sent": "Points could not be submitted",
"Your points": "Your points", "Your result": "Your result",
of: "of", "Your points": "Your points",
"View answers": "View answers", of: "of",
"Find out more": "Learn more", "View answers": "View answers",
"Go to website": "Go to website", "Find out more": "Learn more",
"Question title": "Question title", "Go to website": "Go to website",
"Question without a title": "Untitled question", "Question title": "Question title",
"Your answer": "Your answer", "Question without a title": "Untitled question",
"Add image": "Add image", "Your answer": "Your answer",
"Accepts images": "Accepts images", "Add image": "Add image",
"Add video": "Add video", "Accepts images": "Accepts images",
"Accepts .mp4 and .mov format - maximum 50mb": "Accepts .mp4 and .mov format - maximum 50MB", "Add video": "Add video",
"Add audio file": "Add audio file", "Accepts .mp4 and .mov format - maximum 50mb": "Accepts .mp4 and .mov format - maximum 50MB",
"Accepts audio files": "Accepts audio files", "Add audio file": "Add audio file",
"Add document": "Add document", "Accepts audio files": "Accepts audio files",
"Accepts documents": "Accepts documents", "Add document": "Add document",
Next: "Next", "Accepts documents": "Accepts documents",
Prev: "Previous", Next: "Next",
From: "From", Prev: "Previous",
До: "To", From: "From",
"Enter your answer": "Enter your answer", До: "To",
"Incorrect file type selected": "Invalid file type selected", "Enter your answer": "Enter your answer",
"File is too big. Maximum size is 50 MB": "File is too large. Maximum size is 50 MB", "Incorrect file type selected": "Invalid file type selected",
"Acceptable file extensions": "Allowed file extensions", "File is too big. Maximum size is 50 MB": "File is too large. Maximum size is 50 MB",
"You have uploaded": "You've uploaded", "Acceptable file extensions": "Allowed file extensions",
"The answer was not counted": "Answer wasn't counted", "You have uploaded": "You've uploaded",
"Select an answer option below": "Select an answer option below", "The answer was not counted": "Answer wasn't counted",
"Select an answer option on the left": "Select an answer option on the left", "Select an answer option below": "Select an answer option below",
"Fill out the form to receive your test results": "Fill out the form to receive your test results", "Select an answer option on the left": "Select an answer option on the left",
Enter: "Enter", "Fill out the form to receive your test results": "Fill out the form to receive your test results",
Name: "Name", Enter: "Enter",
"Phone number": "Phone number", Name: "Name",
"Last name": "Last name", "Phone number": "Phone number",
Address: "Address", "Last name": "Last name",
"Incorrect email entered": "Invalid email entered", Address: "Address",
"Please fill in the fields": "Please fill in the fields", "Incorrect email entered": "Invalid email entered",
"Please try again later": "Please try again later", "Please fill in the fields": "Please fill in the fields",
"Regulation on the processing of personal data": "Personal Data Processing Regulation", "Please try again later": "Please try again later",
"Privacy Policy": "Privacy Policy", "Regulation on the processing of personal data": "Personal Data Processing Regulation",
familiarized: "acknowledged", "Privacy Policy": "Privacy Policy",
and: "and", familiarized: "acknowledged",
"Get results": "Get results", and: "and",
"Data sent successfully": "Data sent successfully", "Get results": "Get results",
Step: "Step", "Data sent successfully": "Data sent successfully",
"questions are not ready yet": "There are no questions for the audience yet. Please wait", Step: "Step",
"Add your image": "Add your image", "questions are not ready yet": "There are no questions for the audience yet. Please wait",
"select emoji": "select emoji", "Add your image": "Add your image",
"": "", // Пустой ключ для fallback "select emoji": "select emoji",
"Please complete the phone number": "Please complete the phone number",
"": "", // Пустой ключ для fallback
},
}, },
uz: { uz: {
"quiz is inactive": "Test faol emas", translation: {
"no questions found": "Savollar topilmadi", "quiz is inactive": "Test faol emas",
"quiz is empty": "Test boʻsh", "no questions found": "Savollar topilmadi",
"quiz already completed": "Siz bu testni allaqachon topshirgansiz", "quiz is empty": "Test boʻsh",
"no quiz id": "Test IDsi yoʻq", "quiz already completed": "Siz bu testni allaqachon topshirgansiz",
"quiz data is null": "Test parametrlari yuborilmagan", "no quiz id": "Test IDsi yoʻq",
"invalid request data": "Bunday test mavjud emas", "quiz data is null": "Test parametrlari yuborilmagan",
"default message": "Xatolik yuz berdi", "invalid request data": "Bunday test mavjud emas",
"The request could not be sent": "Soʻrov yuborib boʻlmadi", "default message": "Xatolik yuz berdi",
"The number of points could not be sent": "Ballar yuborib boʻlmadi", "The request could not be sent": "Soʻrov yuborib boʻlmadi",
"Your result": "Sizning natijangiz", "The number of points could not be sent": "Ballar yuborib boʻlmadi",
"Your points": "Sizning ballaringiz", "Your result": "Sizning natijangiz",
of: "/", "Your points": "Sizning ballaringiz",
"View answers": "Javoblarni koʻrish", of: "/",
"Find out more": "Batafsil maʼlumot", "View answers": "Javoblarni koʻrish",
"Go to website": "Veb-saytga oʻtish", "Find out more": "Batafsil maʼlumot",
"Question title": "Savol sarlavhasi", "Go to website": "Veb-saytga oʻtish",
"Question without a title": "Sarlavhasiz savol", "Question title": "Savol sarlavhasi",
"Your answer": "Sizning javobingiz", "Question without a title": "Sarlavhasiz savol",
"Add image": "Rasm qoʻshish", "Your answer": "Sizning javobingiz",
"Accepts images": "Rasmlarni qabul qiladi", "Add image": "Rasm qoʻshish",
"Add video": "Video qoʻshish", "Accepts images": "Rasmlarni qabul qiladi",
"Accepts .mp4 and .mov format - maximum 50mb": ".mp4 va .mov formatlarini qabul qiladi - maksimal 50MB", "Add video": "Video qoʻshish",
"Add audio file": "Audio fayl qoʻshish", "Accepts .mp4 and .mov format - maximum 50mb": ".mp4 va .mov formatlarini qabul qiladi - maksimal 50MB",
"Accepts audio files": "Audio fayllarni qabul qiladi", "Add audio file": "Audio fayl qoʻshish",
"Add document": "Hujjat qoʻshish", "Accepts audio files": "Audio fayllarni qabul qiladi",
"Accepts documents": "Hujjatlarni qabul qiladi", "Add document": "Hujjat qoʻshish",
Next: "Keyingi", "Accepts documents": "Hujjatlarni qabul qiladi",
Prev: "Oldingi", Next: "Keyingi",
From: "Dan", Prev: "Oldingi",
До: "Gacha", From: "Dan",
"Enter your answer": "Javobingizni kiriting", До: "Gacha",
"Incorrect file type selected": "Notoʻgʻri fayl turi tanlandi", "Enter your answer": "Javobingizni kiriting",
"File is too big. Maximum size is 50 MB": "Fayl juda katta. Maksimal hajmi 50 MB", "Incorrect file type selected": "Notoʻgʻri fayl turi tanlandi",
"Acceptable file extensions": "Qabul qilinadigan fayl kengaytmalari", "File is too big. Maximum size is 50 MB": "Fayl juda katta. Maksimal hajmi 50 MB",
"You have uploaded": "Siz yuklagansiz", "Acceptable file extensions": "Qabul qilinadigan fayl kengaytmalari",
"The answer was not counted": "Javob hisobga olinmadi", "You have uploaded": "Siz yuklagansiz",
"Select an answer option below": "Quyidagi javob variantlaridan birini tanlang", "The answer was not counted": "Javob hisobga olinmadi",
"Select an answer option on the left": "Chapdagi javob variantlaridan birini tanlang", "Select an answer option below": "Quyidagi javob variantlaridan birini tanlang",
"Fill out the form to receive your test results": "Test natijalaringizni olish uchun shaklni toʻldiring", "Select an answer option on the left": "Chapdagi javob variantlaridan birini tanlang",
Enter: "Kiriting", "Fill out the form to receive your test results": "Test natijalaringizni olish uchun shaklni toʻldiring",
Name: "Ism", Enter: "Kiriting",
"Phone number": "Telefon raqami", Name: "Ism",
"Last name": "Familiya", "Phone number": "Telefon raqami",
Address: "Manzil", "Last name": "Familiya",
"Incorrect email entered": "Notoʻgʻri elektron pochta kiritildi", Address: "Manzil",
"Please fill in the fields": "Iltimos, maydonlarni toʻldiring", "Incorrect email entered": "Notoʻgʻri elektron pochta kiritildi",
"Please try again later": "Iltimos, keyinroq urinib koʻring", "Please fill in the fields": "Iltimos, maydonlarni toʻldiring",
"Regulation on the processing of personal data": "Shaxsiy maʼlumotlarni qayta ishlash qoidalari", "Please try again later": "Iltimos, keyinroq urinib koʻring",
"Privacy Policy": "Maxfiylik siyosati", "Regulation on the processing of personal data": "Shaxsiy maʼlumotlarni qayta ishlash qoidalari",
familiarized: "tanishdim", "Privacy Policy": "Maxfiylik siyosati",
and: "va", familiarized: "tanishdim",
"Get results": "Natijalarni olish", and: "va",
"Data sent successfully": "Ma'lumotlar muvaffaqiyatli yuborildi", "Get results": "Natijalarni olish",
Step: "Qadam", "Data sent successfully": "Ma'lumotlar muvaffaqiyatli yuborildi",
"questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting", Step: "Qadam",
"Add your image": "Rasmingizni qo'shing", "questions are not ready yet": "Tomoshabinlar uchun hozircha savollar yo'q. Iltimos kuting",
"select emoji": "emoji tanlang", "Add your image": "Rasmingizni qo'shing",
"": "", // Пустой ключ для fallback "select emoji": "emoji tanlang",
"Please complete the phone number": "Iltimos, telefon raqamini to'liq kiriting",
"": "", // Пустой ключ для fallback
},
}, },
}; };
// 3. Конфигурация i18n без Backend // Проверяем, не инициализирован ли уже i18n
i18n if (i18n.isInitialized) {
.use(initReactI18next) // Добавляем ресурсы к существующему экземпляру
.init({ (Object.keys(r) as Array<"ru" | "en" | "uz">).forEach((lng) => {
resources: r, // Используем встроенные переводы if (i18n.store.data[lng] && i18n.store.data[lng].translation) {
lng: getLanguageFromURL(), // Объединяем с существующими переводами
fallbackLng: "ru", i18n.store.data[lng].translation = {
supportedLngs: ["en", "ru", "uz"], ...(i18n.store.data[lng].translation as Record<string, string>),
debug: true, ...r[lng].translation,
interpolation: { };
escapeValue: false, } else {
}, // Добавляем новые переводы
react: { i18n.store.data[lng] = {
useSuspense: false, ...(i18n.store.data[lng] as Record<string, any>),
}, translation: r[lng].translation,
detection: { };
order: ["path"], }
lookupFromPathIndex: 0,
caches: [],
},
parseMissingKeyHandler: (key) => {
console.warn("Missing translation:", key);
return key;
},
missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Missing i18n key:", {
key,
languages: lngs,
namespace: ns,
stack: new Error().stack,
});
},
})
.then(() => {
console.log("i18n initialized. Current language:", i18n.language);
console.log("Available languages:", i18n.languages);
console.log("Available keys for ru:", Object.keys(r.ru));
console.log("Available keys for en:", Object.keys(r.en));
console.log("Available keys for uz:", Object.keys(r.uz));
}); });
} else {
// 3. Конфигурация i18n без Backend
i18n
.use(initReactI18next)
.init({
resources: r, // Используем встроенные переводы
lng: getLanguageFromURL(),
fallbackLng: "ru",
supportedLngs: ["en", "ru", "uz"],
debug: true,
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
detection: {
order: ["path"],
lookupFromPathIndex: 0,
caches: [],
},
parseMissingKeyHandler: (key) => {
console.warn("⚠️ Widget i18n: Missing translation key:", key);
return key;
},
missingKeyHandler: (lngs, ns, key) => {
console.error("🚨 Widget i18n: Missing i18n key:", {
key,
languages: lngs,
namespace: ns,
stack: new Error().stack,
});
},
})
.catch((error) => {
console.error("❌ Widget i18n: Initialization failed:", error);
});
}
// 4. Логирование событий // 4. Логирование событий
i18n.on("languageChanged", (lng) => { i18n.on("languageChanged", (lng) => {
console.log("Language changed to:", lng); console.log("🔄 Widget i18n: Language changed to:", lng);
});
i18n.on("initialized", (options) => {
console.log("🎯 Widget i18n: Initialized event fired with options:", options);
});
i18n.on("loaded", (loaded) => {
console.log("📦 Widget i18n: Loaded event fired:", loaded);
});
i18n.on("failedLoading", (lng, ns, msg) => {
console.error("💥 Widget i18n: Failed loading:", { lng, ns, msg });
});
i18n.on("missingKey", (lngs, namespace, key, res) => {
console.warn("⚠️ Widget i18n: Missing key event:", { lngs, namespace, key, res });
}); });
export default i18n; export default i18n;

@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom"; import { RouteObject, RouterProvider, createBrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { StrictMode, lazy } from "react"; import { StrictMode, lazy } from "react";
import "./i18n/i18n"; import "./i18n/i18n";
const routes: RouteObject[] = [ const routes: RouteObject[] = [

@ -2,6 +2,7 @@ import QuizAnswerer from "@/components/QuizAnswerer";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export * from "./widgets"; export * from "./widgets";
import "./i18n/i18nWidget"; import "./i18n/i18nWidget";
// old widget // old widget
@ -16,7 +17,10 @@ const widget = {
changeFaviconAndTitle: boolean; changeFaviconAndTitle: boolean;
}) { }) {
const element = document.getElementById(selector); const element = document.getElementById(selector);
if (!element) throw new Error("Element for widget doesn't exist"); if (!element) {
console.error("❌ Widget: Element for widget doesn't exist:", selector);
throw new Error("Element for widget doesn't exist");
}
const root = createRoot(element); const root = createRoot(element);

@ -56,7 +56,7 @@ export default function QuizPopup({
if (!quizData) return null; if (!quizData) return null;
const isQuizCompleted = quizData.settings.cfg.antifraud ? quizData.recentlyCompleted : false; const isQuizCompleted = quizData.settings?.cfg?.antifraud ? quizData.recentlyCompleted : false;
if (isQuizCompleted) return null; if (isQuizCompleted) return null;
if (hideOnMobile && isMobile) return null; if (hideOnMobile && isMobile) return null;

@ -56,7 +56,7 @@ export default function QuizSideButton({
if (hideOnMobile && isMobile) return null; if (hideOnMobile && isMobile) return null;
if (!quizData) return null; if (!quizData) return null;
const isQuizCompleted = quizData.settings.cfg.antifraud ? quizData.recentlyCompleted : false; const isQuizCompleted = quizData.settings?.cfg?.antifraud ? quizData.recentlyCompleted : false;
const showButtonFlash = !isQuizCompleted && isFlashEnabled; const showButtonFlash = !isQuizCompleted && isFlashEnabled;
return createPortal( return createPortal(