Merge branch 'dev' into 'staging'
use QuizDialog in side widget See merge request frontend/squzanswerer!156
This commit is contained in:
commit
205f652dd7
2
.gitignore
vendored
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 };
|
||||
|
28
lib/model/widget/banner.ts
Normal file
28
lib/model/widget/banner.ts
Normal file
@ -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">;
|
30
lib/model/widget/button.ts
Normal file
30
lib/model/widget/button.ts
Normal file
@ -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";
|
||||
};
|
15
lib/model/widget/container.ts
Normal file
15
lib/model/widget/container.ts
Normal file
@ -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;
|
||||
};
|
7
lib/model/widget/index.ts
Normal file
7
lib/model/widget/index.ts
Normal file
@ -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
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
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
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
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>
|
||||
);
|
||||
}
|
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 { 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();
|
||||
}
|
||||
|
29
src/widgets/container/QuizContainer.tsx
Normal file
29
src/widgets/container/QuizContainer.tsx
Normal file
@ -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() {
|
||||
|
74
src/widgets/popup/QuizPopup.tsx
Normal file
74
src/widgets/popup/QuizPopup.tsx
Normal file
@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
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: "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]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
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,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() {
|
||||
|
Loading…
Reference in New Issue
Block a user