diff --git a/frontend/package.json b/frontend/package.json index c208a952aa..e2592b9048 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,7 @@ "@stripe/react-stripe-js": "^2.1.1", "@stripe/stripe-js": "^1.54.1", "@tabler/icons-react": "^3.35.0", - "@tanstack/react-query": "5.76.1", + "@tanstack/react-query": "5.102.2", "@tanstack/react-table": "^8.21.3", "@tiptap/core": "^3.3.0", "@tiptap/extension-image": "^3.3.0", @@ -105,6 +105,7 @@ "vite-plugin-copy": "^0.1.6" }, "resolutions": { - "prosemirror-model": "^1.25.7" + "prosemirror-model": "^1.25.7", + "prosemirror-view": "1.41.8" } } diff --git a/frontend/server.js b/frontend/server.js index 244c7a644c..1cbdb4c1ca 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -126,11 +126,12 @@ Sitemap: ${frontendUrl}/sitemap.xml render = (await dynamicImport(path.join(__dirname, "./dist/server/entry.server.js"))).render; } - const { appHtml, dehydratedState, helmetContext } = await render( + const { appHtml, dehydratedState, helmetContext, themeColors } = await render( { req, res }, ssrManifest ); const stringifiedState = htmlSafeJsonStringify(dehydratedState); + const stringifiedThemeColors = htmlSafeJsonStringify(themeColors); const helmetHtml = Object.values(helmetContext.helmet || {}) .map((value) => value.toString() || "") @@ -148,7 +149,7 @@ Sitemap: ${frontendUrl}/sitemap.xml const html = template .replace("", () => headSnippets.join("\n")) .replace("", () => appHtml) - .replace("", () => ``) + .replace("", () => ``) .replace("", () => envVariablesHtml) .replace(/.*?/s, () => helmetHtml); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e69be445c5..c435865922 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,9 +5,9 @@ import {i18n} from "@lingui/core"; import {I18nProvider} from "@lingui/react"; import {ModalsProvider} from "@mantine/modals"; import {DatesProvider} from "@mantine/dates"; -import {HydrationBoundary, QueryClient, QueryClientProvider} from "@tanstack/react-query"; +import {DehydratedState, HydrationBoundary, QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {Helmet, HelmetProvider} from "react-helmet-async"; -import {generateColors} from '@mantine/colors-generator'; +import type {ThemeColors} from "./utilites/themeColors.ts"; import "@mantine/core/styles/global.css"; import "@mantine/core/styles.css"; @@ -23,7 +23,6 @@ import {ThirdPartyScripts} from "./components/common/ThirdPartyScripts"; import {getConfig} from "./utilites/config.ts"; import {CookieConsentBanner} from "./components/common/CookieConsentBanner"; import {isConsentPending, setConsentState, updateGoogleConsentMode} from "./utilites/trackingPixels/consent"; -import "./utilites/dateLocales.ts"; declare global { interface Window { @@ -35,8 +34,9 @@ export const App: FC< PropsWithChildren<{ queryClient: QueryClient; locale: string; + themeColors: ThemeColors; helmetContext?: any; - dehydratedState?: unknown; + dehydratedState?: DehydratedState; }> > = (props) => { const [isLoadedOnBrowser, setIsLoadedOnBrowser] = React.useState(false); @@ -75,10 +75,7 @@ export const App: FC< import("./LiquidTokenControl")); + +prefetchOnIdle(liquidTokenControlModule.load); + +export const LiquidTokenControl = (props: LiquidTokenControlProps) => { + const module = liquidTokenControlModule.useModule(); + + if (!module) { + return null; + } + + return ; +}; diff --git a/frontend/src/components/common/Editor/Editor.tsx b/frontend/src/components/common/Editor/Editor.tsx new file mode 100644 index 0000000000..0552305633 --- /dev/null +++ b/frontend/src/components/common/Editor/Editor.tsx @@ -0,0 +1,224 @@ +import {Link, RichTextEditor} from "@mantine/tiptap"; +import {useEditor} from "@tiptap/react"; +import StarterKit from '@tiptap/starter-kit'; +import {TextAlign} from '@tiptap/extension-text-align'; +import {Color, TextStyle} from '@tiptap/extension-text-style'; +import React, {useEffect, useState} from "react"; +import {InputDescription, InputError, InputLabel, MantineFontSize} from "@mantine/core"; +import classes from "./Editor.module.scss"; +import classNames from "classnames"; +import {Trans} from "@lingui/macro"; +import {InsertImageControl} from "./Controls/InsertImageControl"; +import {ImageResize} from "./Extensions/ImageResizeExtension"; +import {Extension} from '@tiptap/core'; + +export interface EditorProps { + onChange: (value: string) => void; + value: string; + label?: React.ReactNode; + description?: React.ReactNode; + required?: boolean; + className?: string; + error?: string | React.ReactNode; + editorType?: 'full' | 'simple'; + maxLength?: number; + size?: MantineFontSize; + additionalExtensions?: Extension[]; + additionalToolbarControls?: React.ReactNode; +} + +export const Editor = ({ + error, + onChange, + value, + label = '', + required = false, + className = '', + description = '', + editorType = 'full', + maxLength, + size = 'md', + additionalExtensions = [], + additionalToolbarControls, + }: EditorProps) => { + const [charError, setCharError] = useState(null); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + link: false, + paragraph: { + HTMLAttributes: { + style: 'margin: 0.5em 0;' + } + }, + hardBreak: { + HTMLAttributes: { + 'data-type': 'hard-break' + } + } + }), + Link, + TextAlign.configure({types: ['heading', 'paragraph']}), + ImageResize, + TextStyle, + Color, + ...additionalExtensions + ], + onUpdate: ({editor}) => { + const html = editor.getHTML(); + const htmlLength = html.length; + + if (maxLength && htmlLength > maxLength) { + setCharError(`Character limit exceeded: ${htmlLength}/${maxLength}`); + } else { + setCharError(null); + } + + onChange(html); + }, + }); + + useEffect(() => { + if (value && editor) { + if (value !== editor.getHTML()) { + editor.commands.setContent(value, {emitUpdate: false, parseOptions: {preserveWhitespace: "full"}}); + } + const htmlLength = value.length; + + if (maxLength && htmlLength > maxLength) { + setCharError(HTML character limit exceeded: {htmlLength}/{maxLength}); + } else { + setCharError(null); + } + } + }, [value, editor, maxLength]); + + return ( +
+ {label && editor?.commands.focus()}>{label}} + {description && ( +
+ {description} +
+ )} + + + {editorType === 'full' && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {editorType === 'simple' && ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {additionalToolbarControls} + + + + + {(charError || error) && ( +
+ {error || charError} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/common/Editor/index.tsx b/frontend/src/components/common/Editor/index.tsx index 39a76af95d..0bfcbc5207 100644 --- a/frontend/src/components/common/Editor/index.tsx +++ b/frontend/src/components/common/Editor/index.tsx @@ -1,224 +1,27 @@ -import {Link, RichTextEditor} from "@mantine/tiptap"; -import {useEditor} from "@tiptap/react"; -import StarterKit from '@tiptap/starter-kit'; -import {TextAlign} from '@tiptap/extension-text-align'; -import {Color, TextStyle} from '@tiptap/extension-text-style'; -import React, {useEffect, useState} from "react"; -import {InputDescription, InputError, InputLabel, MantineFontSize} from "@mantine/core"; +import {InputLabel, Skeleton} from "@mantine/core"; +import {prefetchOnIdle} from "../../../utilites/helpers.ts"; +import {createLazyModule} from "../../../utilites/lazyModule.ts"; +import type {EditorProps} from "./Editor"; import classes from "./Editor.module.scss"; import classNames from "classnames"; -import {Trans} from "@lingui/macro"; -import {InsertImageControl} from "./Controls/InsertImageControl"; -import {ImageResize} from "./Extensions/ImageResizeExtension"; -import {Extension} from '@tiptap/core'; -interface EditorProps { - onChange: (value: string) => void; - value: string; - label?: React.ReactNode; - description?: React.ReactNode; - required?: boolean; - className?: string; - error?: string | React.ReactNode; - editorType?: 'full' | 'simple'; - maxLength?: number; - size?: MantineFontSize; - additionalExtensions?: Extension[]; - additionalToolbarControls?: React.ReactNode; -} +const editorModule = createLazyModule(() => import("./Editor")); -export const Editor = ({ - error, - onChange, - value, - label = '', - required = false, - className = '', - description = '', - editorType = 'full', - maxLength, - size = 'md', - additionalExtensions = [], - additionalToolbarControls, - }: EditorProps) => { - const [charError, setCharError] = useState(null); +prefetchOnIdle(editorModule.load); - const editor = useEditor({ - extensions: [ - StarterKit.configure({ - link: false, - paragraph: { - HTMLAttributes: { - style: 'margin: 0.5em 0;' - } - }, - hardBreak: { - HTMLAttributes: { - 'data-type': 'hard-break' - } - } - }), - Link, - TextAlign.configure({types: ['heading', 'paragraph']}), - ImageResize, - TextStyle, - Color, - ...additionalExtensions - ], - onUpdate: ({editor}) => { - const html = editor.getHTML(); - const htmlLength = html.length; +const EditorFallback = ({label, required, size = 'md', className = ''}: EditorProps) => ( +
+ {label && {label}} + +
+); - if (maxLength && htmlLength > maxLength) { - setCharError(`Character limit exceeded: ${htmlLength}/${maxLength}`); - } else { - setCharError(null); - } +export const Editor = (props: EditorProps) => { + const module = editorModule.useModule(); - onChange(html); - }, - }); + if (!module) { + return ; + } - useEffect(() => { - if (value && editor) { - if (value !== editor.getHTML()) { - editor.commands.setContent(value, {emitUpdate: false, parseOptions: {preserveWhitespace: "full"}}); - } - const htmlLength = value.length; - - if (maxLength && htmlLength > maxLength) { - setCharError(HTML character limit exceeded: {htmlLength}/{maxLength}); - } else { - setCharError(null); - } - } - }, [value, editor, maxLength]); - - return ( -
- {label && editor?.commands.focus()}>{label}} - {description && ( -
- {description} -
- )} - - - {editorType === 'full' && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} - - {editorType === 'simple' && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - )} - - {additionalToolbarControls} - - - - - {(charError || error) && ( -
- {error || charError} -
- )} -
- ); + return ; }; diff --git a/frontend/src/components/common/NumberSelector/index.tsx b/frontend/src/components/common/NumberSelector/index.tsx index e93a9d6ee1..52762312ec 100644 --- a/frontend/src/components/common/NumberSelector/index.tsx +++ b/frontend/src/components/common/NumberSelector/index.tsx @@ -5,7 +5,6 @@ import {IconMinus, IconPlus} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import classes from './NumberSelector.module.scss'; import classNames from "classnames"; -import _ from "lodash"; interface NumberSelectorProps extends TextInputProps { formInstance: UseFormReturnType; @@ -17,16 +16,19 @@ interface NumberSelectorProps extends TextInputProps { onLimitReached?: () => void; } +const getFormValue = (values: Record, fieldName: string) => + Number(fieldName.split('.').reduce((acc, key) => acc?.[key], values) ?? 0); + export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues, selectorSize = 'default', onLimitReached}: NumberSelectorProps) => { const handlers = useRef(null); - const [value, setValue] = useState(() => Number(_.get(formInstance.values, fieldName) ?? 0)); + const [value, setValue] = useState(() => getFormValue(formInstance.values, fieldName)); const minValue = min || 0; const maxValue = max || 100; const [sharedVals] = useState(() => { const shared = sharedValues ?? new SharedValues(maxValue); - shared.changeValue(Number(_.get(formInstance.values, fieldName) ?? 0)); + shared.changeValue(getFormValue(formInstance.values, fieldName)); return shared; }); @@ -35,7 +37,7 @@ export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues, }, [value]); useEffect(() => { - const formValue = Number(_.get(formInstance.values, fieldName) ?? 0); + const formValue = getFormValue(formInstance.values, fieldName); if (formValue !== value) { const adjustedDifference = sharedVals.changeValue(formValue - value); setValue(value + adjustedDifference); diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index 5ad8a751fb..10b87da92b 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -5,10 +5,13 @@ import {router} from "./router"; import {App} from "./App"; import {queryClient} from "./utilites/queryClient"; import {dynamicActivateLocale, getClientLocale, getSupportedLocale,} from "./locales.ts"; +import type {ThemeColors} from "./utilites/themeColors.ts"; +import type {DehydratedState} from "@tanstack/react-query"; declare global { interface Window { - __REHYDRATED_STATE__?: unknown; + __REHYDRATED_STATE__?: DehydratedState; + __THEME_COLORS__?: ThemeColors; } } @@ -18,6 +21,8 @@ async function initClientApp() { const rawLocale = getClientLocale(); const locale = getSupportedLocale(rawLocale); await dynamicActivateLocale(locale); + const themeColors = window.__THEME_COLORS__ + ?? (await import("./utilites/themeColors.ts")).generateThemeColors(); // Resolve lazy-loaded routes before hydration const matches = matchRoutes(router, window.location)?.filter((m) => m.route.lazy); @@ -34,7 +39,7 @@ async function initClientApp() { hydrateRoot( document.getElementById("app") as HTMLElement, - + ); diff --git a/frontend/src/entry.server.tsx b/frontend/src/entry.server.tsx index e70afdf92c..87cfd8f748 100644 --- a/frontend/src/entry.server.tsx +++ b/frontend/src/entry.server.tsx @@ -8,6 +8,9 @@ import {setAuthToken} from "./utilites/apiClient.ts"; import {createStaticHandler, createStaticRouter, StaticRouterProvider} from "react-router"; import {dynamicActivateLocale} from "./locales.ts"; import {setSsrQueryClient} from "./utilites/ssrQueryClient.ts"; +import {generateThemeColors} from "./utilites/themeColors.ts"; + +const themeColors = generateThemeColors(); const getLocale = (req: express.Request): string => { if (req.cookies.locale) { @@ -59,6 +62,7 @@ export async function render(params: { queryClient={queryClient} helmetContext={helmetContext} locale={getLocale(params.req)} + themeColors={themeColors} > { return "en"; }; +const dayjsLocaleLoaders: Partial Promise>> = { + de: () => import("dayjs/locale/de"), + fr: () => import("dayjs/locale/fr"), + it: () => import("dayjs/locale/it"), + nl: () => import("dayjs/locale/nl"), + pt: () => import("dayjs/locale/pt"), + es: () => import("dayjs/locale/es"), + "zh-cn": () => import("dayjs/locale/zh-cn"), + "pt-br": () => import("dayjs/locale/pt-br"), + vi: () => import("dayjs/locale/vi"), + "zh-hk": () => import("dayjs/locale/zh-hk"), + tr: () => import("dayjs/locale/tr"), + hu: () => import("dayjs/locale/hu"), + sk: () => import("dayjs/locale/sk"), + el: () => import("dayjs/locale/el"), +}; + export async function dynamicActivateLocale(locale: string) { try { locale = availableLocales.includes(locale) ? locale : "en"; - const module = (await import(`./locales/${locale}.po`)); + const [module] = await Promise.all([ + import(`./locales/${locale}.po`), + dayjsLocaleLoaders[locale as SupportedLocales]?.().catch((error) => console.error("Error loading dayjs locale:", error)), + ]); i18n.load(locale, module.messages); i18n.activate(locale); } catch (error) { diff --git a/frontend/src/utilites/dateLocales.ts b/frontend/src/utilites/dateLocales.ts index 0032ed3d69..728fb57848 100644 --- a/frontend/src/utilites/dateLocales.ts +++ b/frontend/src/utilites/dateLocales.ts @@ -1,21 +1,5 @@ import { SupportedLocales } from '../locales.ts'; -import 'dayjs/locale/en'; -import 'dayjs/locale/de'; -import 'dayjs/locale/fr'; -import 'dayjs/locale/it'; -import 'dayjs/locale/nl'; -import 'dayjs/locale/pt'; -import 'dayjs/locale/es'; -import 'dayjs/locale/zh-cn'; -import 'dayjs/locale/pt-br'; -import 'dayjs/locale/vi'; -import 'dayjs/locale/zh-hk'; -import 'dayjs/locale/tr'; -import 'dayjs/locale/hu'; -import 'dayjs/locale/sk'; -import 'dayjs/locale/el'; - export const localeFormats: Record { export const isSsr = () => import.meta.env.SSR; +export const prefetchOnIdle = (load: () => Promise) => { + if (isSsr()) return; + const whenIdle = window.requestIdleCallback ?? ((callback: () => void) => window.setTimeout(callback, 1)); + whenIdle(() => load().catch(() => undefined)); +}; + export const safeSessionStorageGet = (key: string): string | null => { if (isSsr()) return null; try { diff --git a/frontend/src/utilites/lazyModule.ts b/frontend/src/utilites/lazyModule.ts new file mode 100644 index 0000000000..65102f5507 --- /dev/null +++ b/frontend/src/utilites/lazyModule.ts @@ -0,0 +1,38 @@ +import {useEffect, useSyncExternalStore} from "react"; + +export const createLazyModule = (importModule: () => Promise) => { + let loaded: T | null = null; + let pending: Promise | null = null; + const listeners = new Set<() => void>(); + + const load = () => { + pending ??= importModule().then((module) => { + loaded = module; + listeners.forEach((listener) => listener()); + return module; + }, (error) => { + pending = null; + throw error; + }); + return pending; + }; + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const useModule = (): T | null => { + const module = useSyncExternalStore(subscribe, () => loaded, () => null); + useEffect(() => { + if (!module) { + load(); + } + }, [module]); + return module; + }; + + return {load, useModule}; +}; diff --git a/frontend/src/utilites/themeColors.ts b/frontend/src/utilites/themeColors.ts new file mode 100644 index 0000000000..2ee0c41bdc --- /dev/null +++ b/frontend/src/utilites/themeColors.ts @@ -0,0 +1,10 @@ +import {generateColors} from "@mantine/colors-generator"; +import {MantineColorsTuple} from "@mantine/core"; +import {getConfig} from "./config.ts"; + +export type ThemeColors = Record<"primary" | "secondary", MantineColorsTuple>; + +export const generateThemeColors = (): ThemeColors => ({ + primary: generateColors(getConfig("VITE_APP_PRIMARY_COLOR", "#40296C") as string), + secondary: generateColors(getConfig("VITE_APP_SECONDARY_COLOR", "#3d0b44") as string), +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d54461420d..2799e52088 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1433,17 +1433,17 @@ resolved "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz" integrity sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ== -"@tanstack/query-core@5.76.0": - version "5.76.0" - resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.76.0.tgz" - integrity sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg== +"@tanstack/query-core@5.102.2": + version "5.102.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.102.2.tgz#b320ef63aadf55d0ab55ee4f92b602f72521a9ed" + integrity sha512-zQ5794PXBlV5Wl7N23SR1/Ss+wPE/h2Ye4XlKpm3omN8i8b/Fd4DAdqpLS2AXj5M/RMObt3k5qy3E7kzt/AvBA== -"@tanstack/react-query@5.76.1": - version "5.76.1" - resolved "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.76.1.tgz" - integrity sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw== +"@tanstack/react-query@5.102.2": + version "5.102.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.102.2.tgz#e336b8e9da552f052de67e24b380c7502764a820" + integrity sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ== dependencies: - "@tanstack/query-core" "5.76.0" + "@tanstack/query-core" "5.102.2" "@tanstack/react-table@^8.21.3": version "8.21.3" @@ -4432,16 +4432,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.39.1: - version "1.41.3" - resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz" - integrity sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ== - dependencies: - prosemirror-model "^1.20.0" - prosemirror-state "^1.0.0" - prosemirror-transform "^1.1.0" - -prosemirror-view@^1.38.1: +prosemirror-view@1.41.8, prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1: version "1.41.8" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz#bfb48d9dc328f1aa2a0eea1600b0828818be03f1" integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==