Merge branch 'widget-params' into dev
This commit is contained in:
commit
8276355ed5
@ -19,6 +19,7 @@ export const ApologyPage = ({ error }: Props) => {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
backgroundColor: "#F2F3F7",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
|
71
src/WidgetDev.tsx
Normal file
71
src/WidgetDev.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import lightTheme from "@/utils/themes/light";
|
||||||
|
import { Box, ThemeProvider, Typography } from "@mui/material";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { ContainerWidget as Widget } from "./widgets";
|
||||||
|
|
||||||
|
|
||||||
|
const widgetProps: ConstructorParameters<typeof Widget>[0] = {
|
||||||
|
quizId: "3c49550d-8c77-4788-bc2d-42586a261514",
|
||||||
|
selector: "#widget-container",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WidgetDev() {
|
||||||
|
const widgetRef = useRef<Widget | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!widgetRef.current) {
|
||||||
|
widgetRef.current = new Widget(widgetProps);
|
||||||
|
} else {
|
||||||
|
widgetRef.current.render(widgetProps);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={lightTheme}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100dvh",
|
||||||
|
p: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lorem />
|
||||||
|
<Box id="widget-button"></Box>
|
||||||
|
<Box
|
||||||
|
id="widget-container"
|
||||||
|
sx={{
|
||||||
|
height: "500px",
|
||||||
|
}}
|
||||||
|
></Box>
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
<Lorem />
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qu";
|
||||||
|
|
||||||
|
function Lorem() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography fontSize={"24px"}>{lorem}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
25
src/main.tsx
25
src/main.tsx
@ -1,10 +1,10 @@
|
|||||||
// import "https://markknol.github.io/console-log-viewer/console-log-viewer.js";
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { 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";
|
||||||
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const routes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
children: [
|
children: [
|
||||||
@ -18,8 +18,23 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const WidgetDev = lazy(() => import("./WidgetDev"));
|
||||||
|
|
||||||
|
routes[0].children?.push({
|
||||||
|
path: "widgetdev",
|
||||||
|
element: <WidgetDev />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createBrowserRouter(routes);
|
||||||
|
|
||||||
const root = createRoot(document.getElementById("root")!);
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
|
||||||
root.render(<RouterProvider router={router} />);
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import QuizAnswerer from "@/components/QuizAnswerer";
|
|
||||||
import { Dialog } from "@mui/material";
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
open?: boolean;
|
|
||||||
quizId: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QuizDialog({ open = true, quizId, onClose }: Props) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
keepMounted
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
width: "calc(min(100%, max(70%, 700px)))",
|
|
||||||
height: "80%",
|
|
||||||
maxWidth: "100%",
|
|
||||||
m: "16px",
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QuizAnswerer
|
|
||||||
quizId={quizId}
|
|
||||||
changeFaviconAndTitle={false}
|
|
||||||
disableGlobalCss
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
@ -3,21 +3,26 @@ import QuizBanner from "./QuizBanner";
|
|||||||
import { ComponentPropsWithoutRef } from "react";
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = Omit<ComponentPropsWithoutRef<typeof QuizBanner>, "onClose">;
|
||||||
|
|
||||||
export class BannerWidget {
|
export class BannerWidget {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
element = document.createElement("div");
|
element = document.createElement("div");
|
||||||
|
|
||||||
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizBanner>) {
|
constructor(props: Props) {
|
||||||
this.element.style.setProperty("display", "none");
|
this.element.style.setProperty("display", "none");
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
|
|
||||||
this.root = createRoot(this.element);
|
this.root = createRoot(this.element);
|
||||||
|
|
||||||
this.root.render(
|
this.render(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props: Props) {
|
||||||
|
this.root?.render(
|
||||||
<QuizBanner
|
<QuizBanner
|
||||||
quizId={quizId}
|
{...props}
|
||||||
position={position}
|
onWidgetClose={() => this.destroy()}
|
||||||
onClose={() => this.destroy()}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,80 +1,210 @@
|
|||||||
import lightTheme from "@/utils/themes/light";
|
import lightTheme from "@/utils/themes/light";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import { Box, Button, Fade, IconButton, ThemeProvider, Typography, useMediaQuery } from "@mui/material";
|
||||||
import { Box, Button, IconButton, ThemeProvider } from "@mui/material";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import QuizDialog from "../QuizDialog";
|
import BannerIcon from "../shared/BannerIcon";
|
||||||
|
import QuizDialog from "../shared/QuizDialog";
|
||||||
|
import RunningStripe from "../shared/RunningStripe";
|
||||||
|
import { useQuizCompletionStatus } from "../shared/useQuizCompletionStatus";
|
||||||
|
|
||||||
|
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
position: "topleft" | "topright" | "bottomleft" | "bottomright";
|
|
||||||
quizId: string;
|
quizId: string;
|
||||||
onClose: () => void;
|
position: "topleft" | "topright" | "bottomleft" | "bottomright";
|
||||||
|
onWidgetClose?: () => void;
|
||||||
|
appealText?: string;
|
||||||
|
quizHeaderText?: string;
|
||||||
|
buttonTextColor?: string;
|
||||||
|
buttonBackgroundColor?: string;
|
||||||
|
/**
|
||||||
|
* Открыть квиз через X секунд, 0 - сразу
|
||||||
|
*/
|
||||||
|
autoShowQuizTime?: number | null;
|
||||||
|
openOnLeaveAttempt?: boolean;
|
||||||
|
buttonFlash?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
withShadow?: boolean;
|
||||||
|
rounded?: boolean;
|
||||||
|
bannerFullWidth?: boolean;
|
||||||
|
pulsation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuizBanner({ quizId, position, onClose }: Props) {
|
export default function QuizBanner({
|
||||||
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<boolean>(false);
|
quizId,
|
||||||
|
position,
|
||||||
|
onWidgetClose,
|
||||||
|
appealText = "Пройти тест",
|
||||||
|
quizHeaderText = "Заголовок квиза",
|
||||||
|
buttonTextColor,
|
||||||
|
buttonBackgroundColor,
|
||||||
|
autoShowQuizTime = null,
|
||||||
|
openOnLeaveAttempt,
|
||||||
|
buttonFlash = false,
|
||||||
|
hideOnMobile,
|
||||||
|
withShadow = false,
|
||||||
|
rounded = false,
|
||||||
|
bannerFullWidth = false,
|
||||||
|
pulsation = false,
|
||||||
|
}: Props) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 600px)");
|
||||||
|
const [isQuizShown, setIsQuizShown] = useState<boolean>(false);
|
||||||
|
const [isFlashEnabled, setIsFlashEnabled] = useState<boolean>(buttonFlash);
|
||||||
|
const isQuizCompleted = useQuizCompletionStatus(quizId);
|
||||||
|
const preventQuizAutoShowRef = useRef<boolean>(false);
|
||||||
|
const preventOpenOnLeaveAttemptRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(function setAutoShowQuizTimer() {
|
||||||
|
if (autoShowQuizTime === null || openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}, autoShowQuizTime * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [autoShowQuizTime, openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
useEffect(function attachLeaveListener() {
|
||||||
|
if (!openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!preventOpenOnLeaveAttemptRef.current) {
|
||||||
|
preventOpenOnLeaveAttemptRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
};
|
||||||
|
}, [openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
function openQuiz() {
|
||||||
|
preventQuizAutoShowRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
setIsFlashEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOnMobile && isMobile) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<Box
|
<Fade in={!isQuizShown}>
|
||||||
className="pena-quiz-widget-banner"
|
<Box
|
||||||
sx={[
|
className="pena-quiz-widget-banner"
|
||||||
{
|
sx={[
|
||||||
position: "fixed",
|
{
|
||||||
height: "70px",
|
position: "fixed",
|
||||||
width: `calc(min(calc(100% - ${PADDING * 2}px), max(500px, 70%)))`,
|
height: "120px",
|
||||||
},
|
width: bannerFullWidth ? "100%" : "800px",
|
||||||
position === "topleft" && {
|
maxWidth: `calc(100% - ${PADDING * 2}px)`,
|
||||||
top: PADDING,
|
},
|
||||||
left: PADDING,
|
position === "topleft" && {
|
||||||
},
|
top: bannerFullWidth ? 0 : PADDING,
|
||||||
position === "topright" && {
|
left: bannerFullWidth ? 0 : PADDING,
|
||||||
top: PADDING,
|
},
|
||||||
right: PADDING,
|
position === "topright" && {
|
||||||
},
|
top: bannerFullWidth ? 0 : PADDING,
|
||||||
position === "bottomleft" && {
|
right: bannerFullWidth ? 0 : PADDING,
|
||||||
bottom: PADDING,
|
},
|
||||||
left: PADDING,
|
position === "bottomleft" && {
|
||||||
},
|
bottom: bannerFullWidth ? 0 : PADDING,
|
||||||
position === "bottomright" && {
|
left: bannerFullWidth ? 0 : PADDING,
|
||||||
bottom: PADDING,
|
},
|
||||||
right: PADDING,
|
position === "bottomright" && {
|
||||||
},
|
bottom: bannerFullWidth ? 0 : PADDING,
|
||||||
]}
|
right: bannerFullWidth ? 0 : PADDING,
|
||||||
>
|
},
|
||||||
<Button
|
pulsation && {
|
||||||
onClick={() => setIsQuizDialogOpen(p => !p)}
|
":before": {
|
||||||
variant="contained"
|
content: "''",
|
||||||
sx={{
|
position: "absolute",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
pointerEvents: "none",
|
||||||
|
willChange: "box-shadow",
|
||||||
|
borderRadius: rounded ? "8px" : 0,
|
||||||
|
animation: "pena-pulsation linear 5s infinite",
|
||||||
|
"@keyframes pena-pulsation": {
|
||||||
|
"0%": {
|
||||||
|
boxShadow: "0 0 0 0 rgba(126, 42, 234, 0.5)",
|
||||||
|
},
|
||||||
|
"30%": {
|
||||||
|
boxShadow: "0 0 0 15px rgba(0, 0, 0, 0)",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
boxShadow: "0 0 0 0 rgba(0, 0, 0, 0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
Пройти квиз
|
<Button
|
||||||
</Button>
|
onClick={openQuiz}
|
||||||
<IconButton
|
variant="contained"
|
||||||
onClick={onClose}
|
sx={[
|
||||||
sx={{
|
{
|
||||||
position: "absolute",
|
display: "flex",
|
||||||
top: 0,
|
gap: "20px",
|
||||||
right: 0,
|
overflow: "hidden",
|
||||||
p: 0,
|
height: "100%",
|
||||||
width: "34px",
|
width: "100%",
|
||||||
height: "34px",
|
px: "28px",
|
||||||
borderRadius: "4px",
|
color: buttonTextColor,
|
||||||
backgroundColor: "#333647",
|
backgroundColor: buttonBackgroundColor,
|
||||||
}}
|
borderRadius: rounded ? "8px" : 0,
|
||||||
>
|
justifyContent: "start",
|
||||||
<CloseIcon sx={{ color: "#FFFFFF" }} />
|
},
|
||||||
</IconButton>
|
withShadow && {
|
||||||
</Box>
|
boxShadow: "0px 0px 12px 0px rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BannerIcon />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography fontSize="24px" lineHeight="120%">{appealText}</Typography>
|
||||||
|
<Typography fontSize="44px" lineHeight="120%">{quizHeaderText}</Typography>
|
||||||
|
</Box>
|
||||||
|
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
onClick={onWidgetClose}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
p: "8px",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 7 7" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.00391 0.757812L6.67266 6.42656M1.00391 6.42656L6.67266 0.757812" stroke="white" strokeWidth="0.5" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
<QuizDialog
|
<QuizDialog
|
||||||
open={isQuizDialogOpen}
|
open={isQuizShown}
|
||||||
quizId={quizId}
|
quizId={quizId}
|
||||||
onClose={() => setIsQuizDialogOpen(false)}
|
onClose={() => setIsQuizShown(false)}
|
||||||
|
disableScrollLock
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
document.body
|
document.body
|
||||||
|
@ -1,25 +1,28 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
import { Root, createRoot } from "react-dom/client";
|
import { Root, createRoot } from "react-dom/client";
|
||||||
|
import { pollForSelector } from "../shared/pollForSelector";
|
||||||
import OpenQuizButton from "./OpenQuizButton";
|
import OpenQuizButton from "./OpenQuizButton";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { pollForSelector } from "../pollForSelector";
|
|
||||||
|
|
||||||
|
|
||||||
|
type ButtonWidgetProps = Omit<ComponentPropsWithoutRef<typeof OpenQuizButton>, "fixedSide">;
|
||||||
|
|
||||||
export class ButtonWidget {
|
export class ButtonWidget {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
element = document.createElement("div");
|
|
||||||
|
|
||||||
constructor({ quizId, selector, selectorPollingTimeLimit = 60 }: {
|
constructor(props: ButtonWidgetProps & {
|
||||||
quizId: string;
|
|
||||||
selector: string;
|
selector: string;
|
||||||
/**
|
/**
|
||||||
* In seconds, null - polling disabled
|
* In seconds, null - polling disabled
|
||||||
*/
|
*/
|
||||||
selectorPollingTimeLimit?: number | null;
|
selectorPollingTimeLimit?: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { selector, selectorPollingTimeLimit = 60 } = props;
|
||||||
|
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
if (element) {
|
if (element) {
|
||||||
this.root = createRoot(element);
|
this.root = createRoot(element);
|
||||||
this.root.render(<OpenQuizButton quizId={quizId} />);
|
this.render(props);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -31,36 +34,38 @@ export class ButtonWidget {
|
|||||||
|
|
||||||
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
|
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
|
||||||
this.root = createRoot(element);
|
this.root = createRoot(element);
|
||||||
this.root.render(<OpenQuizButton quizId={quizId} />);
|
this.render(props);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render(props: ButtonWidgetProps) {
|
||||||
|
this.root?.render(<OpenQuizButton {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.root) this.root.unmount();
|
if (this.root) this.root.unmount();
|
||||||
this.element.remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ButtonWidgetFixedProps = Omit<ComponentPropsWithoutRef<typeof OpenQuizButton>, "selector"> & {
|
||||||
|
fixedSide: "left" | "right";
|
||||||
|
};
|
||||||
|
|
||||||
export class ButtonWidgetFixed {
|
export class ButtonWidgetFixed {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
element = document.createElement("div");
|
element = document.createElement("div");
|
||||||
|
|
||||||
constructor({ quizId, side }: {
|
constructor(props: ButtonWidgetFixedProps) {
|
||||||
quizId: string;
|
|
||||||
side: "left" | "right";
|
|
||||||
}) {
|
|
||||||
this.element.style.setProperty("display", "none");
|
this.element.style.setProperty("display", "none");
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
|
|
||||||
this.root = createRoot(this.element);
|
this.root = createRoot(this.element);
|
||||||
|
|
||||||
this.root.render(createPortal(
|
this.render(props);
|
||||||
<OpenQuizButton
|
}
|
||||||
fixedSide={side}
|
|
||||||
quizId={quizId}
|
render(props: ButtonWidgetFixedProps) {
|
||||||
/>,
|
this.root?.render(createPortal(<OpenQuizButton {...props} />, document.body));
|
||||||
document.body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
@ -1,26 +1,108 @@
|
|||||||
import lightTheme from "@/utils/themes/light";
|
import lightTheme from "@/utils/themes/light";
|
||||||
import { Button, ThemeProvider } from "@mui/material";
|
import { Button, ThemeProvider, useMediaQuery } from "@mui/material";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import QuizDialog from "../QuizDialog";
|
import QuizDialog from "../shared/QuizDialog";
|
||||||
|
import RunningStripe from "../shared/RunningStripe";
|
||||||
|
import { useQuizCompletionStatus } from "../shared/useQuizCompletionStatus";
|
||||||
|
|
||||||
|
|
||||||
|
const WIDGET_DEFAULT_WIDTH = "600px";
|
||||||
|
const WIDGET_DEFAULT_HEIGHT = "80%";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fixedSide?: "left" | "right";
|
|
||||||
quizId: string;
|
quizId: string;
|
||||||
|
fixedSide?: "left" | "right";
|
||||||
|
dialogDimensions?: { width: string; height: string; };
|
||||||
|
/**
|
||||||
|
* Открыть квиз через X секунд, 0 - сразу
|
||||||
|
*/
|
||||||
|
autoShowQuizTime?: number | null;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
openOnLeaveAttempt?: boolean;
|
||||||
|
buttonFlash?: boolean;
|
||||||
|
withShadow?: boolean;
|
||||||
|
rounded?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
buttonTextColor?: string;
|
||||||
|
buttonBackgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OpenQuizButton({ quizId, fixedSide }: Props) {
|
export default function OpenQuizButton({
|
||||||
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<boolean>(false);
|
quizId,
|
||||||
|
fixedSide,
|
||||||
|
autoShowQuizTime = null,
|
||||||
|
dialogDimensions,
|
||||||
|
hideOnMobile,
|
||||||
|
openOnLeaveAttempt,
|
||||||
|
buttonFlash = false,
|
||||||
|
withShadow = false,
|
||||||
|
rounded = false,
|
||||||
|
buttonText = "Пройти квиз",
|
||||||
|
buttonTextColor,
|
||||||
|
buttonBackgroundColor,
|
||||||
|
}: Props) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 600px)");
|
||||||
|
const [isQuizShown, setIsQuizShown] = useState<boolean>(false);
|
||||||
|
const isQuizCompleted = useQuizCompletionStatus(quizId);
|
||||||
|
const [isFlashEnabled, setIsFlashEnabled] = useState<boolean>(buttonFlash);
|
||||||
|
const preventQuizAutoShowRef = useRef<boolean>(false);
|
||||||
|
const preventOpenOnLeaveAttemptRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(function setAutoShowQuizTimer() {
|
||||||
|
if (autoShowQuizTime === null || openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}, autoShowQuizTime * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [autoShowQuizTime, openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
useEffect(function attachLeaveListener() {
|
||||||
|
if (!openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!preventOpenOnLeaveAttemptRef.current) {
|
||||||
|
preventOpenOnLeaveAttemptRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
};
|
||||||
|
}, [openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
function openQuiz() {
|
||||||
|
preventQuizAutoShowRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
setIsFlashEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOnMobile && isMobile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<Button
|
<Button
|
||||||
className="pena-quiz-widget-button"
|
className="pena-quiz-widget-button"
|
||||||
onClick={() => setIsQuizDialogOpen(p => !p)}
|
onClick={openQuiz}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
disableFocusRipple
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
// normal styles
|
overflow: "hidden",
|
||||||
|
color: buttonTextColor,
|
||||||
|
backgroundColor: buttonBackgroundColor,
|
||||||
|
},
|
||||||
|
withShadow && {
|
||||||
|
boxShadow: "0px 0px 8px 0px rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
!rounded && {
|
||||||
|
borderRadius: 0,
|
||||||
},
|
},
|
||||||
Boolean(fixedSide) && {
|
Boolean(fixedSide) && {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@ -38,12 +120,17 @@ export default function OpenQuizButton({ quizId, fixedSide }: Props) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Пройти квиз
|
{buttonText}
|
||||||
|
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
|
||||||
</Button>
|
</Button>
|
||||||
<QuizDialog
|
<QuizDialog
|
||||||
open={isQuizDialogOpen}
|
open={isQuizShown}
|
||||||
quizId={quizId}
|
quizId={quizId}
|
||||||
onClose={() => setIsQuizDialogOpen(false)}
|
onClose={() => setIsQuizShown(false)}
|
||||||
|
paperSx={{
|
||||||
|
width: dialogDimensions?.width ?? WIDGET_DEFAULT_WIDTH,
|
||||||
|
height: dialogDimensions?.height ?? WIDGET_DEFAULT_HEIGHT,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
@ -1,29 +1,27 @@
|
|||||||
import QuizAnswerer from "@/components/QuizAnswerer";
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
import { Root, createRoot } from "react-dom/client";
|
import { Root, createRoot } from "react-dom/client";
|
||||||
import { pollForSelector } from "../pollForSelector";
|
import { pollForSelector } from "../shared/pollForSelector";
|
||||||
|
import QuizContainer from "./QuizContainer";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<typeof QuizContainer>;
|
||||||
|
|
||||||
export class ContainerWidget {
|
export class ContainerWidget {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
|
|
||||||
constructor({ selector, quizId, selectorPollingTimeLimit = 60 }: {
|
constructor(props: Props & {
|
||||||
quizId: string;
|
|
||||||
selector: string;
|
selector: string;
|
||||||
/**
|
/**
|
||||||
* In seconds, null - polling disabled
|
* In seconds, null - polling disabled
|
||||||
*/
|
*/
|
||||||
selectorPollingTimeLimit?: number | null;
|
selectorPollingTimeLimit?: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { selector, selectorPollingTimeLimit = 60 } = props;
|
||||||
|
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
if (element) {
|
if (element) {
|
||||||
this.root = createRoot(element);
|
this.root = createRoot(element);
|
||||||
this.root.render(
|
this.render(props);
|
||||||
<QuizAnswerer
|
|
||||||
quizId={quizId}
|
|
||||||
changeFaviconAndTitle={false}
|
|
||||||
disableGlobalCss
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -35,16 +33,14 @@ export class ContainerWidget {
|
|||||||
|
|
||||||
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
|
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
|
||||||
this.root = createRoot(element);
|
this.root = createRoot(element);
|
||||||
this.root.render(
|
this.render(props);
|
||||||
<QuizAnswerer
|
|
||||||
quizId={quizId}
|
|
||||||
changeFaviconAndTitle={false}
|
|
||||||
disableGlobalCss
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render(props: Props) {
|
||||||
|
this.root?.render(<QuizContainer {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.root) this.root.unmount();
|
if (this.root) this.root.unmount();
|
||||||
}
|
}
|
||||||
|
35
src/widgets/container/QuizContainer.tsx
Normal file
35
src/widgets/container/QuizContainer.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import QuizAnswerer from "@/components/QuizAnswerer";
|
||||||
|
import { Box, useMediaQuery } from "@mui/material";
|
||||||
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
|
import OpenQuizButton from "../button/OpenQuizButton";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<typeof OpenQuizButton> & {
|
||||||
|
quizId: string;
|
||||||
|
showButtonOnMobile?: boolean;
|
||||||
|
dimensions?: { width: string; height: string; };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QuizContainer(props: Props) {
|
||||||
|
const { quizId, dimensions, showButtonOnMobile = false } = props;
|
||||||
|
const isMobile = useMediaQuery("(max-width: 600px)");
|
||||||
|
|
||||||
|
return showButtonOnMobile && isMobile ? (
|
||||||
|
<OpenQuizButton {...props} />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: dimensions?.width ?? "100%",
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: dimensions?.height ?? "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QuizAnswerer
|
||||||
|
quizId={quizId}
|
||||||
|
changeFaviconAndTitle={false}
|
||||||
|
disableGlobalCss
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
@ -1,26 +1,25 @@
|
|||||||
import { Root, createRoot } from "react-dom/client";
|
import { Root, createRoot } from "react-dom/client";
|
||||||
import QuizDialog from "../QuizDialog";
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
|
import QuizPopup from "./QuizPopup";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<typeof QuizPopup>;
|
||||||
|
|
||||||
export class PopupWidget {
|
export class PopupWidget {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
element: HTMLDivElement;
|
element = document.createElement("div");
|
||||||
|
|
||||||
constructor({ quizId }: {
|
constructor(props: Props) {
|
||||||
quizId: string;
|
|
||||||
}) {
|
|
||||||
this.element = document.createElement("div");
|
|
||||||
this.element.style.setProperty("display", "none");
|
this.element.style.setProperty("display", "none");
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
|
|
||||||
this.root = createRoot(this.element);
|
this.root = createRoot(this.element);
|
||||||
|
|
||||||
this.root.render(
|
this.render(props);
|
||||||
<QuizDialog
|
}
|
||||||
quizId={quizId}
|
|
||||||
onClose={() => this.destroy()}
|
render(props: Props) {
|
||||||
/>
|
this.root?.render(<QuizPopup {...props} />);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
78
src/widgets/popup/QuizPopup.tsx
Normal file
78
src/widgets/popup/QuizPopup.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import QuizDialog from "../shared/QuizDialog";
|
||||||
|
import { useQuizCompletionStatus } from "../shared/useQuizCompletionStatus";
|
||||||
|
import { useMediaQuery } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
|
const WIDGET_DEFAULT_WIDTH = "600px";
|
||||||
|
const WIDGET_DEFAULT_HEIGHT = "80%";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quizId: string;
|
||||||
|
dialogDimensions?: { width: string; height: string; };
|
||||||
|
/**
|
||||||
|
* Открыть квиз через X секунд, 0 - сразу
|
||||||
|
*/
|
||||||
|
autoShowQuizTime?: number | null;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
openOnLeaveAttempt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuizPopup({
|
||||||
|
quizId,
|
||||||
|
dialogDimensions,
|
||||||
|
autoShowQuizTime = null,
|
||||||
|
hideOnMobile = false,
|
||||||
|
openOnLeaveAttempt = false,
|
||||||
|
}: Props) {
|
||||||
|
const initialIsQuizShown = (autoShowQuizTime !== null || openOnLeaveAttempt) ? false : true;
|
||||||
|
|
||||||
|
const [isQuizShown, setIsQuizShown] = useState<boolean>(initialIsQuizShown);
|
||||||
|
const isQuizCompleted = useQuizCompletionStatus(quizId);
|
||||||
|
const isMobile = useMediaQuery("(max-width: 600px)");
|
||||||
|
const preventOpenOnLeaveAttemptRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(function setAutoShowQuizTimer() {
|
||||||
|
if (autoShowQuizTime === null || openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}, autoShowQuizTime * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [autoShowQuizTime, openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
useEffect(function attachLeaveListener() {
|
||||||
|
if (!openOnLeaveAttempt) return;
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!preventOpenOnLeaveAttemptRef.current) {
|
||||||
|
preventOpenOnLeaveAttemptRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
};
|
||||||
|
}, [openOnLeaveAttempt]);
|
||||||
|
|
||||||
|
if (isQuizCompleted) return null;
|
||||||
|
if (hideOnMobile && isMobile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuizDialog
|
||||||
|
open={isQuizShown}
|
||||||
|
quizId={quizId}
|
||||||
|
onClose={() => setIsQuizShown(false)}
|
||||||
|
paperSx={{
|
||||||
|
width: dialogDimensions?.width ?? WIDGET_DEFAULT_WIDTH,
|
||||||
|
height: dialogDimensions?.height ?? WIDGET_DEFAULT_HEIGHT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
25
src/widgets/shared/BannerIcon.tsx
Normal file
25
src/widgets/shared/BannerIcon.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
|
export default function BannerIcon() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "80px",
|
||||||
|
height: "76px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="auto" height="auto" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.2979 2.94922H15.4949C15.6488 2.94922 15.7964 3.01036 15.9052 3.11919C16.0141 3.22802 16.0752 3.37563 16.0752 3.52954V6.77848M7.21163 2.94922H5.04907C4.89516 2.94922 4.74755 3.01036 4.63872 3.11919C4.52989 3.22802 4.46875 3.37563 4.46875 3.52954V15.7163C4.46875 15.8702 4.52989 16.0178 4.63872 16.1267C4.74755 16.2355 4.89516 16.2966 5.04907 16.2966H8.53802M7.95068 16.2966H15.4949C15.6488 16.2966 15.7964 16.2355 15.9052 16.1267C16.0141 16.0178 16.0752 15.8702 16.0752 15.7163V11.9923" stroke="white" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M9.40182 13.7891H7.65735C7.58039 13.7891 7.50659 13.762 7.45217 13.7139C7.39776 13.6659 7.36719 13.6006 7.36719 13.5326V8.14708C7.36719 8.07906 7.39776 8.01383 7.45217 7.96574C7.50659 7.91764 7.58039 7.89062 7.65735 7.89062H9.10815H12.8802C12.9572 7.89062 13.031 7.91764 13.0854 7.96574C13.1398 8.01383 13.1704 8.07906 13.1704 8.14708V9.58283" stroke="white" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M7.36719 1.8125H13.1704V3.39705C13.1704 3.71756 12.9106 3.97737 12.5901 3.97737H7.94751C7.62701 3.97737 7.36719 3.71756 7.36719 3.39705V1.8125Z" stroke="white" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M17.0844 8.36719L11.8615 13.5901L9.25 10.9786" stroke="white" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
79
src/widgets/shared/QuizDialog.tsx
Normal file
79
src/widgets/shared/QuizDialog.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import QuizAnswerer from "@/components/QuizAnswerer";
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { Dialog, IconButton, Slide, SlideProps, SxProps, Theme } from "@mui/material";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
const SlideTransition = forwardRef<unknown, SlideProps>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<Slide direction="up" ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
quizId: string;
|
||||||
|
paperSx?: SxProps<Theme>;
|
||||||
|
hideBackdrop?: boolean;
|
||||||
|
disableScrollLock?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuizDialog({
|
||||||
|
open = true,
|
||||||
|
quizId,
|
||||||
|
paperSx = [],
|
||||||
|
hideBackdrop,
|
||||||
|
disableScrollLock,
|
||||||
|
onClose
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
keepMounted
|
||||||
|
hideBackdrop={hideBackdrop}
|
||||||
|
disableScrollLock={disableScrollLock}
|
||||||
|
TransitionComponent={SlideTransition}
|
||||||
|
PaperProps={{
|
||||||
|
sx: [
|
||||||
|
{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
width: "calc(min(100%, max(70%, 700px)))",
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "80%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
m: "16px",
|
||||||
|
},
|
||||||
|
...(Array.isArray(paperSx) ? paperSx : [paperSx])
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QuizAnswerer
|
||||||
|
quizId={quizId}
|
||||||
|
changeFaviconAndTitle={false}
|
||||||
|
disableGlobalCss
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 10,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: "4px",
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ color: "white" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
36
src/widgets/shared/RunningStripe.tsx
Normal file
36
src/widgets/shared/RunningStripe.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Box, SxProps, Theme } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunningStripe({ sx = [] }: Props) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
height: "30px",
|
||||||
|
width: "140px",
|
||||||
|
backgroundColor: "rgba(255 255 255 / 0.6)",
|
||||||
|
animation: "runningStripe linear 3s infinite",
|
||||||
|
transform: "rotate(-60deg)",
|
||||||
|
"@keyframes runningStripe": {
|
||||||
|
"0%": {
|
||||||
|
left: "-150px",
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
"25%, 100%": {
|
||||||
|
left: "100%",
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
18
src/widgets/shared/useAutoOpenTimer.ts
Normal file
18
src/widgets/shared/useAutoOpenTimer.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
export function useAutoOpenTimer(autoOpenTime: number) {
|
||||||
|
const [isWidgetHidden, setIsWidgetHidden] = useState<boolean>(autoOpenTime ? true : false);
|
||||||
|
|
||||||
|
useEffect(function setAutoOpenTimer() {
|
||||||
|
if (!autoOpenTime) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => setIsWidgetHidden(false), autoOpenTime * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [autoOpenTime]);
|
||||||
|
|
||||||
|
return isWidgetHidden;
|
||||||
|
}
|
17
src/widgets/shared/useQuizCompletionStatus.ts
Normal file
17
src/widgets/shared/useQuizCompletionStatus.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
export function useQuizCompletionStatus(quizId: string): boolean {
|
||||||
|
return useMemo(() => {
|
||||||
|
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof sessions[quizId] === "number"
|
||||||
|
&& Date.now() - sessions[quizId] < 86400000
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [quizId]);
|
||||||
|
}
|
@ -1,58 +1,122 @@
|
|||||||
import { QuizAnswerer } from "@/index";
|
|
||||||
import lightTheme from "@/utils/themes/light";
|
import lightTheme from "@/utils/themes/light";
|
||||||
import { Box, Button, Grow, ThemeProvider } from "@mui/material";
|
import { Button, Fade, ThemeProvider, useMediaQuery } from "@mui/material";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import QuizDialog from "../shared/QuizDialog";
|
||||||
|
import RunningStripe from "../shared/RunningStripe";
|
||||||
|
import { useAutoOpenTimer } from "../shared/useAutoOpenTimer";
|
||||||
|
import { useQuizCompletionStatus } from "../shared/useQuizCompletionStatus";
|
||||||
|
|
||||||
|
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
const WIDGET_DEFAULT_WIDTH = "600px";
|
||||||
|
const WIDGET_DEFAULT_HEIGHT = "800px";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
quizId: string;
|
quizId: string;
|
||||||
position: "left" | "right";
|
position: "left" | "right";
|
||||||
|
buttonBackgroundColor?: string;
|
||||||
|
buttonTextColor?: string;
|
||||||
|
dialogDimensions?: { width: string; height: string; };
|
||||||
|
fullScreen?: boolean;
|
||||||
|
buttonFlash?: boolean;
|
||||||
|
/**
|
||||||
|
* Скрывать виджет первые X секунд
|
||||||
|
*/
|
||||||
|
autoOpenTime?: number;
|
||||||
|
/**
|
||||||
|
* Открыть квиз через X секунд, 0 - сразу
|
||||||
|
*/
|
||||||
|
autoShowQuizTime?: number | null;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuizSideButton({ quizId, position }: Props) {
|
export default function QuizSideButton({
|
||||||
|
quizId,
|
||||||
|
position,
|
||||||
|
buttonBackgroundColor,
|
||||||
|
buttonTextColor,
|
||||||
|
dialogDimensions,
|
||||||
|
fullScreen = false,
|
||||||
|
buttonFlash = false,
|
||||||
|
autoOpenTime = 0,
|
||||||
|
autoShowQuizTime = null,
|
||||||
|
hideOnMobile = false,
|
||||||
|
}: Props) {
|
||||||
const [isQuizShown, setIsQuizShown] = useState<boolean>(false);
|
const [isQuizShown, setIsQuizShown] = useState<boolean>(false);
|
||||||
|
const isMobile = useMediaQuery("(max-width: 600px)");
|
||||||
|
const isQuizCompleted = useQuizCompletionStatus(quizId);
|
||||||
|
const [isFlashEnabled, setIsFlashEnabled] = useState<boolean>(buttonFlash);
|
||||||
|
const isWidgetHidden = useAutoOpenTimer(autoOpenTime);
|
||||||
|
const preventQuizAutoShowRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(function setAutoShowQuizTimer() {
|
||||||
|
if (autoShowQuizTime === null) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!preventQuizAutoShowRef.current) setIsQuizShown(true);
|
||||||
|
}, autoShowQuizTime * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [autoShowQuizTime]);
|
||||||
|
|
||||||
|
function openQuiz() {
|
||||||
|
preventQuizAutoShowRef.current = true;
|
||||||
|
setIsQuizShown(true);
|
||||||
|
setIsFlashEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOnMobile && isMobile) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
{isQuizShown ? (
|
<QuizDialog
|
||||||
<Grow in={true}>
|
open={isQuizShown}
|
||||||
<Box
|
quizId={quizId}
|
||||||
sx={[
|
onClose={() => setIsQuizShown(false)}
|
||||||
{
|
hideBackdrop
|
||||||
position: "fixed",
|
disableScrollLock
|
||||||
height: `calc(min(calc(100% - ${PADDING * 2}px), 800px))`,
|
paperSx={[
|
||||||
width: `calc(min(calc(100% - ${PADDING * 2}px), 600px))`,
|
{
|
||||||
},
|
m: 0,
|
||||||
position === "left" && {
|
},
|
||||||
bottom: PADDING,
|
!(isMobile || fullScreen) && {
|
||||||
left: PADDING,
|
position: "absolute",
|
||||||
},
|
bottom: PADDING,
|
||||||
position === "right" && {
|
right: position === "right" ? PADDING : undefined,
|
||||||
bottom: PADDING,
|
left: position === "left" ? PADDING : undefined,
|
||||||
right: PADDING,
|
width: dialogDimensions?.width ?? WIDGET_DEFAULT_WIDTH,
|
||||||
},
|
maxWidth: `calc(100% - ${PADDING * 2}px)`,
|
||||||
]}
|
height: dialogDimensions?.height ?? WIDGET_DEFAULT_HEIGHT,
|
||||||
>
|
maxHeight: `calc(100% - ${PADDING * 2}px)`,
|
||||||
<QuizAnswerer
|
},
|
||||||
quizId={quizId}
|
(isMobile || fullScreen) && {
|
||||||
changeFaviconAndTitle={false}
|
position: "relative",
|
||||||
disableGlobalCss
|
width: "100%",
|
||||||
/>
|
height: "100%",
|
||||||
</Box>
|
maxHeight: "100%",
|
||||||
</Grow>
|
borderRadius: 0,
|
||||||
) : (
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Fade in={!isWidgetHidden} timeout={400}>
|
||||||
<Button
|
<Button
|
||||||
className="pena-quiz-widget-button"
|
className="pena-quiz-widget-button"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setIsQuizShown(true)}
|
onClick={openQuiz}
|
||||||
|
disableFocusRipple
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
|
display: isQuizShown ? "none" : "block",
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
height: "70px",
|
height: "70px",
|
||||||
width: `calc(min(calc(100% - ${PADDING * 2}px), 600px))`,
|
width: "600px",
|
||||||
|
maxWidth: `calc(100% - ${PADDING * 2}px)`,
|
||||||
|
backgroundColor: buttonBackgroundColor,
|
||||||
|
color: buttonTextColor,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
position === "left" && {
|
position === "left" && {
|
||||||
bottom: PADDING,
|
bottom: PADDING,
|
||||||
@ -65,8 +129,9 @@ export default function QuizSideButton({ quizId, position }: Props) {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Пройти квиз
|
Пройти квиз
|
||||||
|
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</Fade>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
@ -3,22 +3,23 @@ import QuizSideButton from "./QuizSideButton";
|
|||||||
import { ComponentPropsWithoutRef } from "react";
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<typeof QuizSideButton>;
|
||||||
|
|
||||||
export class SideWidget {
|
export class SideWidget {
|
||||||
root: Root | undefined;
|
root: Root | undefined;
|
||||||
element = document.createElement("div");
|
element = document.createElement("div");
|
||||||
|
|
||||||
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizSideButton>) {
|
constructor(props: Props) {
|
||||||
this.element.style.setProperty("display", "none");
|
this.element.style.setProperty("display", "none");
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
|
|
||||||
this.root = createRoot(this.element);
|
this.root = createRoot(this.element);
|
||||||
|
|
||||||
this.root.render(
|
this.render(props);
|
||||||
<QuizSideButton
|
}
|
||||||
quizId={quizId}
|
|
||||||
position={position}
|
render(props: Props) {
|
||||||
/>
|
this.root?.render(<QuizSideButton {...props} />);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
Loading…
Reference in New Issue
Block a user