diff --git a/site/package-lock.json b/site/package-lock.json index 28e177b0..39b320a4 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -16,9 +16,12 @@ "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "framer-motion": "^11.2.11", + "i18next": "^23.11.5", + "plausible-tracker": "^0.3.9", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "react-i18next": "^14.1.2" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.4.0", @@ -8639,6 +8642,14 @@ "node": ">=14" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -8833,6 +8844,28 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "23.11.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.5.tgz", + "integrity": "sha512-41pvpVbW9rhZPk5xjCX2TPJi2861LEig/YRhUkY+1FQ2IQPS0bKUDYnEqY8XPPbB48h1uIwLnP9iiEfuSl20CA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -12401,6 +12434,14 @@ "node": ">=4" } }, + "node_modules/plausible-tracker": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.9.tgz", + "integrity": "sha512-hMhneYm3GCPyQon88SZrVJx+LlqhM1kZFQbuAgXPoh/Az2YvO1B6bitT9qlhpiTdJlsT5lsr3gPmzoVjb5CDXA==", + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", @@ -13398,6 +13439,27 @@ "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-i18next": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.2.tgz", + "integrity": "sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -15638,6 +15700,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/site/package.json b/site/package.json index 62b483ce..223f0fdd 100644 --- a/site/package.json +++ b/site/package.json @@ -23,9 +23,12 @@ "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "framer-motion": "^11.2.11", + "i18next": "^23.11.5", + "plausible-tracker": "^0.3.9", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "react-i18next": "^14.1.2" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.4.0", diff --git a/site/src/components/Analytics/AnalyticsProvider.tsx b/site/src/components/Analytics/AnalyticsProvider.tsx new file mode 100644 index 00000000..c31fcec6 --- /dev/null +++ b/site/src/components/Analytics/AnalyticsProvider.tsx @@ -0,0 +1,57 @@ +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import Plausible from 'plausible-tracker'; +import { PlausibleInitOptions } from 'plausible-tracker/build/main/lib/tracker'; + +import { analyticsContext, IAnalyticsContext } from './useAnalyticsContext'; + +export const AnalyticsProvider = ({ + options, + children, +}: PropsWithChildren<{ options: PlausibleInitOptions }>) => { + const [plausible] = useState(() => Plausible(options)); + + const trackEvent: IAnalyticsContext['trackEvent'] = useCallback( + (eventName, props) => { + const timestamp = performance.now(); + plausible.trackEvent(eventName, { + // Additional props for every event + props: { + // Current location + location: location.toString(), + // Time since visit page + timestamp: timestamp, + timestampSeconds: timestamp / 1000, + ...props, + }, + }); + }, + [plausible], + ); + + // Setup default analytic listeners + useEffect(() => { + plausible.enableAutoPageviews(); + plausible.enableAutoOutboundTracking(); + + // Track clicks + document.body.addEventListener('click', (event: MouseEvent) => { + // Explore click targets to find a link element + const targets = event?.composedPath() || [event.target]; + for (const target of targets) { + if (!(target instanceof HTMLAnchorElement)) continue; + + trackEvent('Link click', { + url: target.href, + text: target.innerText, + }); + break; + } + }); + }, [plausible, trackEvent]); + + return ( + + {children} + + ); +}; diff --git a/site/src/components/Analytics/useAnalyticsContext.tsx b/site/src/components/Analytics/useAnalyticsContext.tsx new file mode 100644 index 00000000..3bd5013e --- /dev/null +++ b/site/src/components/Analytics/useAnalyticsContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +export type IAnalyticsContext = { + trackEvent: (eventName: string, props: Record) => void; +}; + +export const analyticsContext = createContext(null as any); + +export const useAnalyticsContext = () => { + const context = useContext(analyticsContext); + if (context === null) + throw new Error( + 'Analytics context is not available. Make sure component is wrapped with AnalyticsProvider', + ); + + return context; +}; diff --git a/site/src/components/Landing/Landing.tsx b/site/src/components/Landing/Landing.tsx index b721cf5c..ddca32fd 100644 --- a/site/src/components/Landing/Landing.tsx +++ b/site/src/components/Landing/Landing.tsx @@ -1,13 +1,22 @@ import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { clsx } from 'clsx'; import { Button, HStack, Icon, Image, Link, Text, VStack } from '@chakra-ui/react'; +import { buildPathGetter } from '@site/src/utils/url'; +import { useAnalyticsContext } from '../Analytics/useAnalyticsContext'; +import enLocale from './locales/en.json'; import Logo from './logo.svg'; import styles from './Landing.module.css'; export const Landing = ({ baseUrl }: { baseUrl: string }) => { - const getUrl = (path: string) => [baseUrl, path].join('/').replace(/\/{2,}/g, '/'); + const { t, i18n } = useTranslation('landing'); + i18n.addResourceBundle('en', 'landing', enLocale); + + const getUrl = buildPathGetter(baseUrl); + + const { trackEvent } = useAnalyticsContext(); return ( @@ -26,32 +35,34 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { }} > - Features + {t('navigation.features.content')} - GitHub + {t('navigation.github.content')} - + - Linguist is a privacy focused, full‑featured translation - solution + {t(['sections.hero.title'])} - Translate web pages, highlighted text, Netflix subtitles - and private messages. Speak the translated text, and save - important translations to your personal dictionary to - learn words even offline. + {t(['sections.hero.description'])} @@ -68,8 +79,13 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { target="_blank" href="https://chrome.google.com/webstore/detail/gbefmodhlophhakmoecijeppjblibmie" px={4} + onClick={() => { + trackEvent('Download link: Click', { + target: 'chrome', + }); + }} > - Install for Chrome + {t('install.chrome')} @@ -115,7 +136,7 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { > - Features + {t('features.title')} @@ -133,17 +154,10 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Offline translation and privacy + {t('features.items.offlineTranslation.title')} - Linguist can translate texts even without the - internet - a feature that no other extension has. - The offline translator allows you to translate - texts on your device without sending any private - messages over the internet, ensuring your privacy. - Simply enable the feature on the options page to - maintain your privacy while translating work - emails and personal messages. + {t('features.items.offlineTranslation.content')} @@ -164,15 +178,10 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Full page translation + {t('features.items.fullPageTranslation.title')} - Fast and high quality whole page translation in - one click, even for a private pages that need - login. Flexible configuration for auto translation - based on domain name and languages. Translation is - available by hotkey. You may see an original text - by hover on it. + {t('features.items.fullPageTranslation.content')} @@ -195,14 +204,14 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Translation for selected text + {t( + 'features.items.selectedTextTranslation.title', + )} - Encountering unfamiliar words while reading an - online article? Just select text on the page and - click the button to translate it. You can speak - the translated and original text, and save the - translation to your dictionary. + {t( + 'features.items.selectedTextTranslation.content', + )} @@ -223,12 +232,10 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Text translation always at hand + {t('features.items.textTranslation.title')} - If you need to translate any text - just click the - Linguist button to open the pop-up window. No more - tabs with translation services, just use Linguist. + {t('features.items.textTranslation.content')} @@ -247,18 +254,10 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Make your own personal knowledge base + {t('features.items.knowledgeBase.title')} - Any translated text is saved in the history, and - you can add your favorite translations to your - dictionary. You can search for translations in - both your dictionary and history, and even filter - your translations by language. The dictionary - feature is available even when you are offline, - making it an ideal tool for language learners or - travelers who require constant access to their - word lists. + {t('features.items.knowledgeBase.content')} @@ -279,20 +278,16 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { spacing={6} > - Custom translators + {t('features.items.customTranslators.title')} - Unlike other browser extensions, Linguist is not - just a wrapper over the Google Translator Widget; - it's a complete and independent translation - system. If you are not satisfied with embedded - translators, you may use Linguist with your - favorite translation service, just by add a custom - translator. Read more about in{' '} - - docs - - . + , + ]} + /> @@ -301,39 +296,37 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { - Open source + {t('sections.opensource.title')} - Linguist is completely free,{' '} - - open-source - - , and it does not collect any user data to sell. You may{' '} - - support the project - {' '} - with your donations to help Linguist maintain its independence - and high quality. Share Linguist with your friends, to make it - popular together! + , + , + ]} + /> - Support + {t('sections.support.title')} - For support contact{' '} - - support@linguister.io - - . If you have bug -{' '} - - create issue - {' '} - on GitHub. + + support@linguister.io + , + , + ]} + /> @@ -347,7 +340,13 @@ export const Landing = ({ baseUrl }: { baseUrl: string }) => { }} > - Created by FluidMinds team. + FluidMinds team, + ]} + /> diff --git a/site/src/components/Landing/locales/en.json b/site/src/components/Landing/locales/en.json new file mode 100644 index 00000000..0cbe9288 --- /dev/null +++ b/site/src/components/Landing/locales/en.json @@ -0,0 +1,60 @@ +{ + "navigation": { + "features": { + "content": "Features" + }, + "github": { + "content": "GitHub" + } + }, + "install": { + "chrome": "Install for Chrome", + "firefox": "Install for Firefox" + }, + "sections": { + "hero": { + "title": "Linguist is a privacy‑focused, full‑featured translation solution.", + "description": "Translate web pages, highlighted text, Netflix subtitles, and private messages. Speak the translated text and save important translations to your personal dictionary to learn words even offline." + }, + "opensource": { + "title": "Open source", + "content": "Linguist is completely free, <0>open-source, and it does not collect any user data to sell.

You may <1>support the project with your donations to help Linguist maintain its independence and high quality. Share Linguist with your friends to make it popular together!" + }, + "support": { + "title": "Support", + "content": "For support contact <0/>. If you have bug - <1>create issue on GitHub." + }, + "createdBy": { + "content": "Created by <0/>." + } + }, + "features": { + "title": "Features", + "items": { + "offlineTranslation": { + "title": "Offline translation and privacy", + "content": "Linguist can translate texts even without the internet - a feature that no other extension has. The offline translator allows you to translate texts on your device without sending any private messages over the internet, ensuring your privacy. Simply enable the feature on the options page to maintain your privacy while translating work emails and personal messages." + }, + "fullPageTranslation": { + "title": "Full page translation", + "content": "Fast and high-quality whole-page translation in one click, even for private pages that need login. Flexible configuration for auto-translation based on domain name and languages. Translation is available by hotkey. You may see the original text by hovering over it." + }, + "selectedTextTranslation": { + "title": "Translation for selected text", + "content": "Encountering unfamiliar words while reading an online article? Just select the text on the page and click the button to translate it. You can speak the translated and original text and save the translation to your dictionary." + }, + "textTranslation": { + "title": "Text translation always at hand", + "content": "If you need to translate any text, just click the Linguist button to open the pop-up window. No more tabs with translation services, just use Linguist." + }, + "knowledgeBase": { + "title": "Make your own personal knowledge base", + "content": "Any translated text is saved in the history, and you can add your favorite translations to your dictionary. You can search for translations in both your dictionary and history, and even filter your translations by language. The dictionary feature is available even when you are offline, making it an ideal tool for language learners or travelers who require constant access to their word lists." + }, + "customTranslators": { + "title": "Custom translators", + "content": "Unlike other browser extensions, Linguist is not just a wrapper over the Google Translator Widget; it's a complete and independent translation system. If you are not satisfied with embedded translators, you can use Linguist with your favorite translation service by adding a custom translator. Read more about it in <0>docs." + } + } + } +} diff --git a/site/src/components/PageLayout/PageLayout.tsx b/site/src/components/PageLayout/PageLayout.tsx index 3d4c695b..37b54d95 100644 --- a/site/src/components/PageLayout/PageLayout.tsx +++ b/site/src/components/PageLayout/PageLayout.tsx @@ -1,7 +1,10 @@ +import '../i18n'; + import React, { FC, PropsWithChildren } from 'react'; import { ChakraBaseProvider } from '@chakra-ui/react'; import Head from '@docusaurus/Head'; +import { AnalyticsProvider } from '../Analytics/AnalyticsProvider'; import theme from '../theme'; export const PageLayout: FC = ({ children }) => { @@ -9,11 +12,6 @@ export const PageLayout: FC = ({ children }) => { -