Merge branch 'widget-params' into dev

This commit is contained in:
nflnkr 2024-05-13 19:39:38 +03:00
commit 8276355ed5
20 changed files with 834 additions and 206 deletions

@ -19,6 +19,7 @@ export const ApologyPage = ({ error }: Props) => {
alignItems: "center",
justifyContent: "center",
height: "100%",
backgroundColor: "#F2F3F7",
}}
>
<Typography

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>
);
}

@ -1,10 +1,10 @@
// import "https://markknol.github.io/console-log-viewer/console-log-viewer.js";
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 { StrictMode, lazy } from "react";
const router = createBrowserRouter([
const routes: RouteObject[] = [
{
path: "/",
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")!);
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";
type Props = Omit<ComponentPropsWithoutRef<typeof QuizBanner>, "onClose">;
export class BannerWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizBanner>) {
constructor(props: Props) {
this.element.style.setProperty("display", "none");
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(
this.render(props);
}
render(props: Props) {
this.root?.render(
<QuizBanner
quizId={quizId}
position={position}
onClose={() => this.destroy()}
{...props}
onWidgetClose={() => this.destroy()}
/>
);
}

@ -1,80 +1,210 @@
import lightTheme from "@/utils/themes/light";
import CloseIcon from '@mui/icons-material/Close';
import { Box, Button, IconButton, ThemeProvider } from "@mui/material";
import { useState } from "react";
import { Box, Button, Fade, IconButton, ThemeProvider, Typography, useMediaQuery } from "@mui/material";
import { useEffect, useRef, useState } from "react";
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;
interface Props {
position: "topleft" | "topright" | "bottomleft" | "bottomright";
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) {
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<boolean>(false);
export default function QuizBanner({
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(
<ThemeProvider theme={lightTheme}>
<Box
className="pena-quiz-widget-banner"
sx={[
{
position: "fixed",
height: "70px",
width: `calc(min(calc(100% - ${PADDING * 2}px), max(500px, 70%)))`,
},
position === "topleft" && {
top: PADDING,
left: PADDING,
},
position === "topright" && {
top: PADDING,
right: PADDING,
},
position === "bottomleft" && {
bottom: PADDING,
left: PADDING,
},
position === "bottomright" && {
bottom: PADDING,
right: PADDING,
},
]}
>
<Button
onClick={() => setIsQuizDialogOpen(p => !p)}
variant="contained"
sx={{
height: "100%",
width: "100%",
}}
<Fade in={!isQuizShown}>
<Box
className="pena-quiz-widget-banner"
sx={[
{
position: "fixed",
height: "120px",
width: bannerFullWidth ? "100%" : "800px",
maxWidth: `calc(100% - ${PADDING * 2}px)`,
},
position === "topleft" && {
top: bannerFullWidth ? 0 : PADDING,
left: bannerFullWidth ? 0 : PADDING,
},
position === "topright" && {
top: bannerFullWidth ? 0 : PADDING,
right: bannerFullWidth ? 0 : PADDING,
},
position === "bottomleft" && {
bottom: bannerFullWidth ? 0 : PADDING,
left: bannerFullWidth ? 0 : PADDING,
},
position === "bottomright" && {
bottom: bannerFullWidth ? 0 : PADDING,
right: bannerFullWidth ? 0 : PADDING,
},
pulsation && {
":before": {
content: "''",
position: "absolute",
height: "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>
<IconButton
onClick={onClose}
sx={{
position: "absolute",
top: 0,
right: 0,
p: 0,
width: "34px",
height: "34px",
borderRadius: "4px",
backgroundColor: "#333647",
}}
>
<CloseIcon sx={{ color: "#FFFFFF" }} />
</IconButton>
</Box>
<Button
onClick={openQuiz}
variant="contained"
sx={[
{
display: "flex",
gap: "20px",
overflow: "hidden",
height: "100%",
width: "100%",
px: "28px",
color: buttonTextColor,
backgroundColor: buttonBackgroundColor,
borderRadius: rounded ? "8px" : 0,
justifyContent: "start",
},
withShadow && {
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
open={isQuizDialogOpen}
open={isQuizShown}
quizId={quizId}
onClose={() => setIsQuizDialogOpen(false)}
onClose={() => setIsQuizShown(false)}
disableScrollLock
/>
</ThemeProvider>,
document.body

@ -1,25 +1,28 @@
import { ComponentPropsWithoutRef } from "react";
import { Root, createRoot } from "react-dom/client";
import { pollForSelector } from "../shared/pollForSelector";
import OpenQuizButton from "./OpenQuizButton";
import { createPortal } from "react-dom";
import { pollForSelector } from "../pollForSelector";
type ButtonWidgetProps = Omit<ComponentPropsWithoutRef<typeof OpenQuizButton>, "fixedSide">;
export class ButtonWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, selector, selectorPollingTimeLimit = 60 }: {
quizId: string;
constructor(props: ButtonWidgetProps & {
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
}) {
const { selector, selectorPollingTimeLimit = 60 } = props;
const element = document.querySelector(selector);
if (element) {
this.root = createRoot(element);
this.root.render(<OpenQuizButton quizId={quizId} />);
this.render(props);
return;
}
@ -31,36 +34,38 @@ export class ButtonWidget {
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
this.root = createRoot(element);
this.root.render(<OpenQuizButton quizId={quizId} />);
this.render(props);
});
}
render(props: ButtonWidgetProps) {
this.root?.render(<OpenQuizButton {...props} />);
}
destroy() {
if (this.root) this.root.unmount();
this.element.remove();
}
}
type ButtonWidgetFixedProps = Omit<ComponentPropsWithoutRef<typeof OpenQuizButton>, "selector"> & {
fixedSide: "left" | "right";
};
export class ButtonWidgetFixed {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, side }: {
quizId: string;
side: "left" | "right";
}) {
constructor(props: ButtonWidgetFixedProps) {
this.element.style.setProperty("display", "none");
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(createPortal(
<OpenQuizButton
fixedSide={side}
quizId={quizId}
/>,
document.body
));
this.render(props);
}
render(props: ButtonWidgetFixedProps) {
this.root?.render(createPortal(<OpenQuizButton {...props} />, document.body));
}
destroy() {

@ -1,26 +1,108 @@
import lightTheme from "@/utils/themes/light";
import { Button, ThemeProvider } from "@mui/material";
import { useState } from "react";
import QuizDialog from "../QuizDialog";
import { Button, ThemeProvider, useMediaQuery } from "@mui/material";
import { useEffect, useRef, useState } from "react";
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 {
fixedSide?: "left" | "right";
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) {
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<boolean>(false);
export default function OpenQuizButton({
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 (
<ThemeProvider theme={lightTheme}>
<Button
className="pena-quiz-widget-button"
onClick={() => setIsQuizDialogOpen(p => !p)}
onClick={openQuiz}
variant="contained"
disableFocusRipple
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) && {
position: "fixed",
@ -38,12 +120,17 @@ export default function OpenQuizButton({ quizId, fixedSide }: Props) {
},
]}
>
Пройти квиз
{buttonText}
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
</Button>
<QuizDialog
open={isQuizDialogOpen}
open={isQuizShown}
quizId={quizId}
onClose={() => setIsQuizDialogOpen(false)}
onClose={() => setIsQuizShown(false)}
paperSx={{
width: dialogDimensions?.width ?? WIDGET_DEFAULT_WIDTH,
height: dialogDimensions?.height ?? WIDGET_DEFAULT_HEIGHT,
}}
/>
</ThemeProvider>
);

@ -1,29 +1,27 @@
import QuizAnswerer from "@/components/QuizAnswerer";
import { ComponentPropsWithoutRef } from "react";
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 {
root: Root | undefined;
constructor({ selector, quizId, selectorPollingTimeLimit = 60 }: {
quizId: string;
constructor(props: Props & {
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
}) {
const { selector, selectorPollingTimeLimit = 60 } = props;
const element = document.querySelector(selector);
if (element) {
this.root = createRoot(element);
this.root.render(
<QuizAnswerer
quizId={quizId}
changeFaviconAndTitle={false}
disableGlobalCss
/>
);
this.render(props);
return;
}
@ -35,16 +33,14 @@ export class ContainerWidget {
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
this.root = createRoot(element);
this.root.render(
<QuizAnswerer
quizId={quizId}
changeFaviconAndTitle={false}
disableGlobalCss
/>
);
this.render(props);
});
}
render(props: Props) {
this.root?.render(<QuizContainer {...props} />);
}
destroy() {
if (this.root) this.root.unmount();
}

@ -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 QuizDialog from "../QuizDialog";
import { ComponentPropsWithoutRef } from "react";
import QuizPopup from "./QuizPopup";
type Props = ComponentPropsWithoutRef<typeof QuizPopup>;
export class PopupWidget {
root: Root | undefined;
element: HTMLDivElement;
element = document.createElement("div");
constructor({ quizId }: {
quizId: string;
}) {
this.element = document.createElement("div");
constructor(props: Props) {
this.element.style.setProperty("display", "none");
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(
<QuizDialog
quizId={quizId}
onClose={() => this.destroy()}
/>
);
this.render(props);
}
render(props: Props) {
this.root?.render(<QuizPopup {...props} />);
}
destroy() {

@ -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,
}}
/>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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]),
]}
/>
);
}

@ -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;
}

@ -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 { Box, Button, Grow, ThemeProvider } from "@mui/material";
import { useState } from "react";
import { Button, Fade, ThemeProvider, useMediaQuery } from "@mui/material";
import { useEffect, useRef, useState } from "react";
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 WIDGET_DEFAULT_WIDTH = "600px";
const WIDGET_DEFAULT_HEIGHT = "800px";
interface Props {
quizId: string;
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 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(
<ThemeProvider theme={lightTheme}>
{isQuizShown ? (
<Grow in={true}>
<Box
sx={[
{
position: "fixed",
height: `calc(min(calc(100% - ${PADDING * 2}px), 800px))`,
width: `calc(min(calc(100% - ${PADDING * 2}px), 600px))`,
},
position === "left" && {
bottom: PADDING,
left: PADDING,
},
position === "right" && {
bottom: PADDING,
right: PADDING,
},
]}
>
<QuizAnswerer
quizId={quizId}
changeFaviconAndTitle={false}
disableGlobalCss
/>
</Box>
</Grow>
) : (
<QuizDialog
open={isQuizShown}
quizId={quizId}
onClose={() => setIsQuizShown(false)}
hideBackdrop
disableScrollLock
paperSx={[
{
m: 0,
},
!(isMobile || fullScreen) && {
position: "absolute",
bottom: PADDING,
right: position === "right" ? PADDING : undefined,
left: position === "left" ? PADDING : undefined,
width: dialogDimensions?.width ?? WIDGET_DEFAULT_WIDTH,
maxWidth: `calc(100% - ${PADDING * 2}px)`,
height: dialogDimensions?.height ?? WIDGET_DEFAULT_HEIGHT,
maxHeight: `calc(100% - ${PADDING * 2}px)`,
},
(isMobile || fullScreen) && {
position: "relative",
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
},
]}
/>
<Fade in={!isWidgetHidden} timeout={400}>
<Button
className="pena-quiz-widget-button"
variant="contained"
onClick={() => setIsQuizShown(true)}
onClick={openQuiz}
disableFocusRipple
sx={[
{
display: isQuizShown ? "none" : "block",
position: "fixed",
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" && {
bottom: PADDING,
@ -65,8 +129,9 @@ export default function QuizSideButton({ quizId, position }: Props) {
]}
>
Пройти квиз
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
</Button>
)}
</Fade>
</ThemeProvider>,
document.body
);

@ -3,22 +3,23 @@ import QuizSideButton from "./QuizSideButton";
import { ComponentPropsWithoutRef } from "react";
type Props = ComponentPropsWithoutRef<typeof QuizSideButton>;
export class SideWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizSideButton>) {
constructor(props: Props) {
this.element.style.setProperty("display", "none");
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(
<QuizSideButton
quizId={quizId}
position={position}
/>
);
this.render(props);
}
render(props: Props) {
this.root?.render(<QuizSideButton {...props} />);
}
destroy() {