Merge branch 'dev' into 'staging'

use QuizDialog in side widget

See merge request frontend/squzanswerer!156
This commit is contained in:
Nastya 2024-05-28 18:32:20 +00:00
commit 205f652dd7
31 changed files with 958 additions and 327 deletions

2
.gitignore vendored

@ -11,7 +11,7 @@ node_modules
dist
dist-package
dist-ssr
widget
/widget
*.local
# Editor directories and files

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

@ -1,5 +1,5 @@
import QuizAnswerer from "./components/QuizAnswerer";
import type { QuizSettings } from "@model/settingsData";
export type { QuizSettings } from "@model/settingsData";
export type * from "./model/widget";
export { QuizAnswerer };
export type { QuizSettings };

@ -0,0 +1,28 @@
export interface BannerWidgetComponentProps {
quizId: string;
position: "topleft" | "topright" | "bottomleft" | "bottomright";
onWidgetClose?: () => void;
dialogDimensions?: { width: string; height: string; };
appealText?: string;
quizHeaderText?: string;
buttonTextColor?: string;
buttonBackgroundColor?: string;
/**
* Показывать виджет через X секунд
*/
autoShowWidgetTime?: number;
/**
* Открыть квиз через X секунд, 0 - сразу, null - не открывать
*/
autoShowQuizTime?: number | null;
openOnLeaveAttempt?: boolean;
buttonFlash?: boolean;
hideOnMobile?: boolean;
withShadow?: boolean;
rounded?: boolean;
bannerFullWidth?: boolean;
pulsation?: boolean;
fullScreen?: boolean;
}
export type BannerWidgetParams = Omit<BannerWidgetComponentProps, "onWidgetClose">;

@ -0,0 +1,30 @@
export interface ButtonWidgetComponentProps {
quizId: string;
fixedSide?: "left" | "right";
dialogDimensions?: { width: string; height: string; };
/**
* Открыть квиз через X секунд, 0 - сразу, null - не открывать
*/
autoShowQuizTime?: number | null;
hideOnMobile?: boolean;
openOnLeaveAttempt?: boolean;
buttonFlash?: boolean;
withShadow?: boolean;
rounded?: boolean;
buttonText?: string;
buttonTextColor?: string;
buttonBackgroundColor?: string;
fullScreen?: boolean;
}
export type ButtonWidgetParams = Omit<ButtonWidgetComponentProps, "fixedSide"> & {
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
};
export type ButtonWidgetFixedParams = Omit<ButtonWidgetComponentProps, "selector"> & {
fixedSide: "left" | "right";
};

@ -0,0 +1,15 @@
import { ButtonWidgetComponentProps } from "./button";
export type ContainerWidgetComponentProps = ButtonWidgetComponentProps & {
quizId: string;
showButtonOnMobile?: boolean;
dimensions?: { width: string; height: string; };
};
export type ContainerWidgetParams = ContainerWidgetComponentProps & {
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
};

@ -0,0 +1,7 @@
export * from "./banner";
export * from "./button";
export * from "./container";
export * from "./popup";
export * from "./side";
export type WidgetType = "container" | "button" | "popup" | "banner" | "side";

13
lib/model/widget/popup.ts Normal file

@ -0,0 +1,13 @@
export interface PopupWidgetComponentProps {
quizId: string;
dialogDimensions?: { width: string; height: string; };
/**
* Открыть квиз через X секунд, 0 - сразу, null - не открывать
*/
autoShowQuizTime?: number | null;
hideOnMobile?: boolean;
openOnLeaveAttempt?: boolean;
fullScreen?: boolean;
}
export type PopupWidgetParams = PopupWidgetComponentProps;

20
lib/model/widget/side.ts Normal file

@ -0,0 +1,20 @@
export interface SideWidgetComponentProps {
quizId: string;
position: "left" | "right";
buttonBackgroundColor?: string;
buttonTextColor?: string;
dialogDimensions?: { width: string; height: string; };
fullScreen?: boolean;
buttonFlash?: boolean;
/**
* Показывать виджет через X секунд
*/
autoShowWidgetTime?: number;
/**
* Открыть квиз через X секунд, 0 - сразу, null - не открывать
*/
autoShowQuizTime?: number | null;
hideOnMobile?: boolean;
}
export type SideWidgetParams = SideWidgetComponentProps;

@ -1,39 +0,0 @@
import BlackItalic from "./Lato/Lato-BlackItalic.ttf"
import ExtraBold from "./Lato/Lato-ExtraBold.ttf"
import Light from "./Lato/Lato-Light.ttf"
import SemiBold from "./Lato/Lato-SemiBold.ttf"
import Black from "./Lato/Lato-Black.ttf"
import ExtraLightItalic from "./Lato/Lato-ExtraLightItalic.ttf"
import MediumItalic from "./Lato/Lato-MediumItalic.ttf"
import ThinItalic from "./Lato/Lato-ThinItalic.ttf"
import BoldItalic from "./Lato/Lato-BoldItalic.ttf"
import ExtraLight from "./Lato/Lato-ExtraLight.ttf"
import Medium from "./Lato/Lato-Medium.ttf"
import Thin from "./Lato/Lato-Thin.ttf"
import Bold from "./Lato/Lato-Bold.ttf"
import Italic from "./Lato/Lato-Italic.ttf"
import Regular from "./Lato/Lato-Regular.ttf"
import ExtraBoldItalic from "./Lato/Lato-ExtraBoldItalic.ttf"
import LightItalic from "./Lato/Lato-LightItalic.ttf"
import SemiBoldItalic from "./Lato/Lato-SemiBoldItalic.ttf"
export const Latos = [
{name: BlackItalic, format: "ttf"},
{name: ExtraBold, format: "ttf"},
{name: Light, format: "ttf"},
{name: SemiBold, format: "ttf"},
{name: Black, format: "ttf"},
{name: ExtraLightItalic, format: "ttf"},
{name: MediumItalic, format: "ttf"},
{name: ThinItalic, format: "ttf"},
{name: BoldItalic, format: "ttf"},
{name: ExtraLight, format: "ttf"},
{name: Medium, format: "ttf"},
{name: Thin, format: "ttf"},
{name: Bold, format: "ttf"},
{name: Italic, format: "ttf"},
{name: Regular, format: "ttf"},
{name: ExtraBoldItalic, format: "ttf"},
{name: LightItalic, format: "ttf"},
{name: SemiBoldItalic, format: "ttf"}
]

100
src/mui.d.ts → lib/utils/themes/mui.d.ts vendored Executable file → Normal file

@ -1,50 +1,50 @@
import "@material-ui/styles";
declare module "@mui/material/styles" {
interface Palette {
lightPurple: Palette["primary"],
darkPurple: Palette["primary"],
brightPurple: Palette["primary"],
fadePurple: Palette["primary"],
grey1: Palette["primary"],
grey2: Palette["primary"],
grey3: Palette["primary"],
grey4: Palette["primary"],
orange: Palette["primary"],
navbarbg: Palette["primary"],
}
interface PaletteOptions {
lightPurple?: PaletteOptions["primary"],
darkPurple?: PaletteOptions["primary"],
brightPurple?: PaletteOptions["primary"],
fadePurple?: PaletteOptions["primary"],
grey1?: PaletteOptions["primary"],
grey2?: PaletteOptions["primary"],
grey3?: PaletteOptions["primary"],
grey4?: PaletteOptions["primary"],
orange?: PaletteOptions["primary"],
navbarbg?: PaletteOptions["primary"],
}
interface TypographyVariants {
infographic: React.CSSProperties;
p1: React.CSSProperties;
}
interface TypographyVariantsOptions {
infographic?: React.CSSProperties;
p1?: React.CSSProperties;
}
}
declare module "@mui/material/Typography" {
interface TypographyPropsVariantOverrides {
infographic: true;
p1: true;
}
}
type DataAttributeKey = `data-${string}`;
declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
[dataAttribute: DataAttributeKey]: unknown;
}
}
import "@material-ui/styles";
declare module "@mui/material/styles" {
interface Palette {
lightPurple: Palette["primary"],
darkPurple: Palette["primary"],
brightPurple: Palette["primary"],
fadePurple: Palette["primary"],
grey1: Palette["primary"],
grey2: Palette["primary"],
grey3: Palette["primary"],
grey4: Palette["primary"],
orange: Palette["primary"],
navbarbg: Palette["primary"],
}
interface PaletteOptions {
lightPurple?: PaletteOptions["primary"],
darkPurple?: PaletteOptions["primary"],
brightPurple?: PaletteOptions["primary"],
fadePurple?: PaletteOptions["primary"],
grey1?: PaletteOptions["primary"],
grey2?: PaletteOptions["primary"],
grey3?: PaletteOptions["primary"],
grey4?: PaletteOptions["primary"],
orange?: PaletteOptions["primary"],
navbarbg?: PaletteOptions["primary"],
}
interface TypographyVariants {
infographic: React.CSSProperties;
p1: React.CSSProperties;
}
interface TypographyVariantsOptions {
infographic?: React.CSSProperties;
p1?: React.CSSProperties;
}
}
declare module "@mui/material/Typography" {
interface TypographyPropsVariantOverrides {
infographic: true;
p1: true;
}
}
type DataAttributeKey = `data-${string}`;
declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
[dataAttribute: DataAttributeKey]: unknown;
}
}

@ -1,6 +1,6 @@
{
"name": "@frontend/squzanswerer",
"version": "1.0.38",
"version": "1.0.44",
"type": "module",
"main": "./dist-package/index.js",
"module": "./dist-package/index.js",

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 { BannerWidget as Widget } from "./widgets";
const widgetProps: ConstructorParameters<typeof Widget>[0] = {
quizId: "3c49550d-8c77-4788-bc2d-42586a261514",
position: "bottomright",
};
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>
);
}

@ -1,23 +1,26 @@
import { BannerWidgetParams } from "@/model/widget/banner";
import { Root, createRoot } from "react-dom/client";
import QuizBanner from "./QuizBanner";
import { ComponentPropsWithoutRef } from "react";
export class BannerWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizBanner>) {
constructor(props: BannerWidgetParams) {
this.element.style.setProperty("display", "none");
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(
this.render(props);
}
render(props: BannerWidgetParams) {
this.root?.render(
<QuizBanner
quizId={quizId}
position={position}
onClose={() => this.destroy()}
{...props}
onWidgetClose={() => this.destroy()}
/>
);
}

@ -1,80 +1,207 @@
import { BannerWidgetComponentProps } from "@/model/widget/banner";
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";
import { useAutoOpenTimer } from "../shared/useAutoOpenTimer";
const PADDING = 10;
interface Props {
position: "topleft" | "topright" | "bottomleft" | "bottomright";
quizId: string;
onClose: () => void;
}
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,
autoShowWidgetTime = 0,
dialogDimensions,
fullScreen = false,
}: BannerWidgetComponentProps) {
const isMobile = useMediaQuery("(max-width: 600px)");
const [isQuizShown, setIsQuizShown] = useState<boolean>(false);
const [isFlashEnabled, setIsFlashEnabled] = useState<boolean>(buttonFlash);
const isWidgetHidden = useAutoOpenTimer(autoShowWidgetTime);
const isQuizCompleted = useQuizCompletionStatus(quizId);
const preventQuizAutoShowRef = useRef<boolean>(false);
const preventOpenOnLeaveAttemptRef = useRef<boolean>(false);
export default function QuizBanner({ quizId, position, onClose }: Props) {
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<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,
<Fade in={!isQuizShown && !isWidgetHidden}>
<Box
className="pena-quiz-widget-banner"
sx={[
{
position: "fixed",
height: "120px",
width: bannerFullWidth ? "100%" : "800px",
maxWidth: bannerFullWidth ? "100%" : `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 && !bannerFullWidth ? "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
onClick={openQuiz}
variant="contained"
sx={[
{
display: "flex",
gap: "20px",
overflow: "hidden",
height: "100%",
width: "100%",
px: "28px",
color: buttonTextColor,
backgroundColor: buttonBackgroundColor,
borderRadius: rounded && !bannerFullWidth ? "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={isQuizShown}
quizId={quizId}
onClose={() => setIsQuizShown(false)}
disableScrollLock
paperSx={[
(isMobile || fullScreen) ? {
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
m: 0,
} : {
width: dialogDimensions?.width,
height: dialogDimensions?.height,
},
]}
>
<Button
onClick={() => setIsQuizDialogOpen(p => !p)}
variant="contained"
sx={{
height: "100%",
width: "100%",
}}
>
Пройти квиз
</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>
<QuizDialog
open={isQuizDialogOpen}
quizId={quizId}
onClose={() => setIsQuizDialogOpen(false)}
/>
</ThemeProvider>,
document.body

@ -1,25 +1,20 @@
import { Root, createRoot } from "react-dom/client";
import OpenQuizButton from "./OpenQuizButton";
import { ButtonWidgetFixedParams, ButtonWidgetParams } from "@/model/widget/button";
import { createPortal } from "react-dom";
import { pollForSelector } from "../pollForSelector";
import { Root, createRoot } from "react-dom/client";
import { pollForSelector } from "../shared/pollForSelector";
import OpenQuizButton from "./OpenQuizButton";
export class ButtonWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, selector, selectorPollingTimeLimit = 60 }: {
quizId: string;
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
}) {
constructor(props: ButtonWidgetParams) {
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,13 +26,16 @@ export class ButtonWidget {
pollForSelector(selector, selectorPollingTimeLimit, (element) => {
this.root = createRoot(element);
this.root.render(<OpenQuizButton quizId={quizId} />);
this.render(props);
});
}
render(props: Omit<ButtonWidgetParams, "selector" | "selectorPollingTimeLimit">) {
this.root?.render(<OpenQuizButton {...props} />);
}
destroy() {
if (this.root) this.root.unmount();
this.element.remove();
}
}
@ -45,22 +43,17 @@ export class ButtonWidgetFixed {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, side }: {
quizId: string;
side: "left" | "right";
}) {
constructor(props: ButtonWidgetFixedParams) {
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: ButtonWidgetFixedParams) {
this.root?.render(createPortal(<OpenQuizButton {...props} />, document.body));
}
destroy() {

@ -1,26 +1,88 @@
import { ButtonWidgetComponentProps } from "@/model/widget/button";
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";
interface Props {
fixedSide?: "left" | "right";
quizId: string;
}
export default function OpenQuizButton({
quizId,
fixedSide,
autoShowQuizTime = null,
dialogDimensions,
hideOnMobile,
openOnLeaveAttempt,
buttonFlash = false,
withShadow = false,
rounded = false,
buttonText = "Пройти квиз",
buttonTextColor,
buttonBackgroundColor,
fullScreen = false,
}: ButtonWidgetComponentProps) {
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);
export default function OpenQuizButton({ quizId, fixedSide }: Props) {
const [isQuizDialogOpen, setIsQuizDialogOpen] = useState<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",
py: "23px",
px: "40px",
fontSize: "20px",
color: buttonTextColor,
backgroundColor: buttonBackgroundColor,
boxShadow: withShadow ? "2px 5px 20px 2px rgba(25, 6, 50, 0.4), 0 2px 10px 0 rgba(35, 17, 58, 0.1)" : "none",
borderRadius: rounded ? "30px" : 0,
},
Boolean(fixedSide) && {
position: "fixed",
@ -38,12 +100,25 @@ 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={[
(isMobile || fullScreen) ? {
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
m: 0,
} : {
width: dialogDimensions?.width,
height: dialogDimensions?.height,
},
]}
/>
</ThemeProvider>
);

@ -1,29 +1,19 @@
import QuizAnswerer from "@/components/QuizAnswerer";
import { ContainerWidgetParams } from "@/model/widget/container";
import { Root, createRoot } from "react-dom/client";
import { pollForSelector } from "../pollForSelector";
import { pollForSelector } from "../shared/pollForSelector";
import QuizContainer from "./QuizContainer";
export class ContainerWidget {
root: Root | undefined;
constructor({ selector, quizId, selectorPollingTimeLimit = 60 }: {
quizId: string;
selector: string;
/**
* In seconds, null - polling disabled
*/
selectorPollingTimeLimit?: number | null;
}) {
constructor(props: ContainerWidgetParams) {
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 +25,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: Omit<ContainerWidgetParams, "selector" | "selectorPollingTimeLimit">) {
this.root?.render(<QuizContainer {...props} />);
}
destroy() {
if (this.root) this.root.unmount();
}

@ -0,0 +1,29 @@
import QuizAnswerer from "@/components/QuizAnswerer";
import { ContainerWidgetComponentProps } from "@/model/widget/container";
import { Box, useMediaQuery } from "@mui/material";
import OpenQuizButton from "../button/OpenQuizButton";
export default function QuizContainer(props: ContainerWidgetComponentProps) {
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,23 @@
import { PopupWidgetParams } from "@/model/widget/popup";
import { Root, createRoot } from "react-dom/client";
import QuizDialog from "../QuizDialog";
import QuizPopup from "./QuizPopup";
export class PopupWidget {
root: Root | undefined;
element: HTMLDivElement;
element = document.createElement("div");
constructor({ quizId }: {
quizId: string;
}) {
this.element = document.createElement("div");
constructor(props: PopupWidgetParams) {
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: PopupWidgetParams) {
this.root?.render(<QuizPopup {...props} />);
}
destroy() {

@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import QuizDialog from "../shared/QuizDialog";
import { useQuizCompletionStatus } from "../shared/useQuizCompletionStatus";
import { useMediaQuery } from "@mui/material";
import { PopupWidgetComponentProps } from "@/model/widget/popup";
export default function QuizPopup({
quizId,
dialogDimensions,
autoShowQuizTime = null,
hideOnMobile = false,
openOnLeaveAttempt = false,
fullScreen = false,
}: PopupWidgetComponentProps) {
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={[
(isMobile || fullScreen) ? {
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
m: 0,
} : {
width: dialogDimensions?.width,
height: dialogDimensions?.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: "70px",
width: "140px",
background: "linear-gradient(0deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.1) 100%)",
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,102 @@
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";
import { SideWidgetComponentProps } from "@/model/widget/side";
const PADDING = 10;
const WIDGET_DEFAULT_WIDTH = "600px";
const WIDGET_DEFAULT_HEIGHT = "800px";
interface Props {
quizId: string;
position: "left" | "right";
}
export default function QuizSideButton({ quizId, position }: Props) {
export default function QuizSideButton({
quizId,
position,
buttonBackgroundColor,
buttonTextColor,
dialogDimensions,
fullScreen = false,
buttonFlash = false,
autoShowWidgetTime = 0,
autoShowQuizTime = null,
hideOnMobile = false,
}: SideWidgetComponentProps) {
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(autoShowWidgetTime);
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) ? {
width: "100%",
height: "100%",
maxHeight: "100%",
borderRadius: 0,
} : {
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)`,
},
]}
/>
<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 +109,9 @@ export default function QuizSideButton({ quizId, position }: Props) {
]}
>
Пройти квиз
{!isQuizCompleted && isFlashEnabled && <RunningStripe />}
</Button>
)}
</Fade>
</ThemeProvider>,
document.body
);

@ -1,24 +1,23 @@
import { SideWidgetParams } from "@/model/widget/side";
import { Root, createRoot } from "react-dom/client";
import QuizSideButton from "./QuizSideButton";
import { ComponentPropsWithoutRef } from "react";
export class SideWidget {
root: Root | undefined;
element = document.createElement("div");
constructor({ quizId, position }: ComponentPropsWithoutRef<typeof QuizSideButton>) {
constructor(props: SideWidgetParams) {
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: SideWidgetParams) {
this.root?.render(<QuizSideButton {...props} />);
}
destroy() {