diff --git a/README.md b/README.md index acf0098..4147516 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ The main idea is to collect all the disgusting features from today's website in - [x] Wheel of fortune coupon modal - [x] Add confirmation when trying to leave the page - [x] Update title while the user is on a different tab -- [ ] Age verification on some images +- [x] Asking for notification: no worries, the website won't send any notification +- [ ] Funny, silly contents (inspired Onion news) - [ ] Newsletter modal when the user leaves the screen or scrolls down a bit -- [ ] Asking for notification: no worries, the website won't send any notification - [ ] Asking for location permission: no worries, the website won't use your location - [ ] Sticky video player obscuring the page visibility. (+audio) - [ ] Randomly loading images while scrolling. - [ ] Low Quality images -- [ ] Funny, silly contents (inspired Onion news) +- [ ] Age verification on some images **Stretch goal experiments**: diff --git a/next-i18next.config.js b/next-i18next.config.js index cf3f1aa..7625ab2 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -2,7 +2,7 @@ const isDev = process.env.NODE_ENV === 'development'; /** @type {import('next-i18next').UserConfig} */ module.exports = { reloadOnPrerender: isDev, - debug: isDev, + debug: false, // isDev, i18n: { defaultLocale: 'en', locales: ['en', 'hu'], diff --git a/next.config.js b/next.config.js index 69fd73e..c53bb9e 100644 --- a/next.config.js +++ b/next.config.js @@ -5,4 +5,7 @@ module.exports = { poweredByHeader: false, trailingSlash: true, i18n, + compiler: { + styledComponents: true, + }, }; diff --git a/package.json b/package.json index 5b41e47..117c0e4 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@vercel/analytics": "^1.2.2", "color": "^4.2.3", "eslint-config-next": "^14", - "i18next": "^23.10.1", "modern-normalize": "^2.0.0", "next": "^14.1.3", "next-i18next": "^15.2.0", @@ -39,7 +38,6 @@ "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-fast-marquee": "^1.6.4", - "react-i18next": "^14.1.0", "react-redux": "^9.1.0", "react-share": "^5.1.0", "react-timeago": "^7.2.0", @@ -78,5 +76,9 @@ "prettier": "^3.2.5", "tsx": "^4.7.1", "typescript": "^5.3" + }, + "peerDependencies": { + "react-i18next": "^14.1.0", + "i18next": "^23.10.1" } } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 1297c58..6ddf557 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -8,6 +8,17 @@ "yes": "Yes", "no": "No" }, + "actions": { + "dismiss": "Dismiss", + "cancel": "Cancel", + "accept": "Accept", + "close": "Close", + "ok": "OK" + }, + "status": { + "unknown": "Unknown", + "not_set": "Not set" + }, "navigation": { "home": "Home", "hotThings": "Hot things", @@ -17,6 +28,11 @@ }, "experiences": { "marquee_variants": ["πŸ“£ Come back please πŸƒβ€β™€οΈπŸƒ We have candy!! 🚐"], - "array_paged_variants": ["⭐️ HEY YOU 🫡", "😜 YES YOU 😱", "πŸ“£ COME BACK πŸƒ"] + "array_paged_variants": ["⭐️ HEY YOU 🫡", "😜 YES YOU 😱", "πŸ“£ COME BACK πŸƒ"], + "notification_permission_manual": { + "title": "You can manually change the notification permission up here", + "description": "We would like to send you notifications sometimes, could you be so kind to allow us to do so? πŸ™πŸ₯ΊπŸ™" + }, + "exit_prompt": "I'd reconsider leaving before some bad things happend to you. Are you sure?" } } diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index c7f4dc9..b1457ad 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -8,10 +8,11 @@ "adult_filter": "Filter adult contents" }, "consent_section": { - "title": "Consent", - "allow_cookies": "Allow non-essential cookies", - "allow_notification": "Allow notification", - "enable_location": "Allow location" + "title": "Consent and permissions", + "permission_disclaimer": "Permission settings are managed by the browser, if you want to change theme you can do it from the site settings in your browser.", + "essential_cookies": "Allow essential cookies", + "notification_permission": "Notification permission", + "location_permission": "Location permission" }, "experience_section": { "title": "Experience", diff --git a/public/locales/hu/settings.json b/public/locales/hu/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/locales/hu/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/components/atoms/Button.tsx b/src/components/atoms/Button.tsx index d955aca..4a12748 100644 --- a/src/components/atoms/Button.tsx +++ b/src/components/atoms/Button.tsx @@ -4,29 +4,11 @@ import { ButtonHTMLAttributes, FunctionComponent } from 'react'; import { cssVars } from '@/styles/theme'; type Props = ButtonHTMLAttributes & { - variant: 'primary' | 'secondary' | 'tertiary'; + variant?: 'primary' | 'secondary' | 'tertiary'; }; -const StyledButton = styled.button<{ - $background: string; - $backgroundAlt: string; - $textColor: string; -}>` - cursor: pointer; - background: ${(props) => props.$background}; - color: ${(props) => props.$textColor}; - transition: background 0.1s ease-in-out; - &:hover { - background: ${(props) => props.$backgroundAlt}; - } - &:disabled { - filter: grayscale(100%); - cursor: default; - } -`; - const Button: FunctionComponent = ({ - variant, + variant = 'primary', children, onClick, disabled, @@ -71,4 +53,22 @@ const Button: FunctionComponent = ({ ); }; +const StyledButton = styled.button<{ + $background: string; + $backgroundAlt: string; + $textColor: string; +}>` + cursor: pointer; + background: ${(props) => props.$background}; + color: ${(props) => props.$textColor}; + transition: background 0.1s ease-in-out; + &:hover { + background: ${(props) => props.$backgroundAlt}; + } + &:disabled { + filter: grayscale(100%); + cursor: default; + } +`; + export default Button; diff --git a/src/components/atoms/DimmerOverlay.tsx b/src/components/atoms/DimmerOverlay.tsx index c3cca70..3e78c51 100644 --- a/src/components/atoms/DimmerOverlay.tsx +++ b/src/components/atoms/DimmerOverlay.tsx @@ -9,7 +9,7 @@ import { styled } from 'styled-components'; import { cssVars } from '@/styles/theme'; export type DimmerOverlayProps = PropsWithChildren<{ - show: boolean; + visible: boolean; onClose?: () => void; closeOnEsc?: boolean; closeOnClickOutside?: boolean; @@ -22,7 +22,7 @@ export type DimmerOverlayProps = PropsWithChildren<{ */ const DimmerOverlay: FunctionComponent = ({ children, - show, + visible, onClose, closeOnEsc = true, closeOnClickOutside = true, @@ -41,15 +41,15 @@ const DimmerOverlay: FunctionComponent = ({ }, [handleKeyDown]); return ( - closeOnClickOutside && onClose?.()}> {children} - + ); }; -const Dimmer = styled.div` +const Wrapper = styled.div` position: fixed; display: flex; top: 0; diff --git a/src/components/atoms/FormCheckbox.tsx b/src/components/atoms/FormCheckbox.tsx new file mode 100644 index 0000000..851ecef --- /dev/null +++ b/src/components/atoms/FormCheckbox.tsx @@ -0,0 +1,18 @@ +import { DetailedHTMLProps, InputHTMLAttributes } from 'react'; + +export type FormCheckbox = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & { + onValueChange?: (value: boolean) => void; +}; +const FormCheckbox = ({ onChange, onValueChange, ...rest }: FormCheckbox) => { + const onChangeProxy = (e: React.ChangeEvent) => { + onChange?.(e); + onValueChange?.(e.target.checked); + }; + + return ; +}; + +export default FormCheckbox; diff --git a/src/components/atoms/FormSelect.tsx b/src/components/atoms/FormSelect.tsx new file mode 100644 index 0000000..098d633 --- /dev/null +++ b/src/components/atoms/FormSelect.tsx @@ -0,0 +1,33 @@ +import { DetailedHTMLProps, SelectHTMLAttributes } from 'react'; + +export type SelectRowProps = DetailedHTMLProps< + SelectHTMLAttributes, + HTMLSelectElement +> & { + values: { value: string; label: string }[]; + selected: string; + onValueChange: (value: string) => void; +}; +const FormSelect = ({ + values, + onChange, + onValueChange, + ...rest +}: SelectRowProps) => { + const onChangeProxy = (e: React.ChangeEvent) => { + onChange?.(e); + onValueChange?.(values[e.target.selectedIndex].value); + }; + + return ( + + ); +}; + +export default FormSelect; diff --git a/src/components/organisms/ShareModal.tsx b/src/components/organisms/ShareModal.tsx index be74ded..cbf35a2 100644 --- a/src/components/organisms/ShareModal.tsx +++ b/src/components/organisms/ShareModal.tsx @@ -36,7 +36,7 @@ const ShareModal: FunctionComponent = ({ show, handleClose }) => { } - show={show} + visible={show} onClose={handleClose}> Sharing is caring, please show this awefully anoying website to your friends. diff --git a/src/config.ts b/src/config.ts index c2cba66..80e52e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ const config = { contactEmail: 'info@themostannoyingwebsite.com', defaultColorScheme: 'dark' as const, + isBrowser: typeof window !== 'undefined', }; export default config; diff --git a/src/features/chat_bubble/components/ActionButton.tsx b/src/features/chat_bubble/components/ActionButton.tsx index e9b1d04..a6bc840 100644 --- a/src/features/chat_bubble/components/ActionButton.tsx +++ b/src/features/chat_bubble/components/ActionButton.tsx @@ -14,6 +14,7 @@ import { useAppSelector } from '@/redux/hooks'; import { cssVars } from '@/styles/theme'; import { selectEnableSound } from '@/redux/selectors/preference'; import { selectInteractionUnlocked } from '@/redux/selectors/runtime'; +import useSendNotification from '@/hooks/useSendNotification'; const zIndexBase = 20; @@ -96,6 +97,7 @@ const ActionButton: FunctionComponent = () => { const [history, setHistory] = useState([initialMessage()] as HistoryItem[]); const [isOpen, setIsOpen] = useState(false); const [badgeCounter, setBadgeCounter] = useState(1); + const notification = useSendNotification(); const notificationSfx = useAudio('/assets/sfx/notification_chord1.wav'); const preventClose: MouseEventHandler = (e) => e.stopPropagation(); @@ -112,6 +114,16 @@ const ActionButton: FunctionComponent = () => { notificationSfx.play(); }, [enableSound, notificationSfx]); + const sendNotification = useCallback( + (message: string) => { + notification.send({ + title: 'New message!', + body: message, + }); + }, + [notification], + ); + const addRandomBotMessage = useCallback(() => { const pool = messages.filter( (message) => !history.some((item) => item.text === message), @@ -124,8 +136,9 @@ const ActionButton: FunctionComponent = () => { if (!isOpen) { setBadgeCounter((prev) => prev + 1); playSound(); + sendNotification(randomMessage); } - }, [addHistory, history, isOpen, playSound]); + }, [addHistory, history, isOpen, playSound, sendNotification]); const closeHistory = () => setIsOpen(false); const toggleHistory: MouseEventHandler = useCallback(() => { diff --git a/src/features/notification/components/ManualModal.tsx b/src/features/notification/components/ManualModal.tsx new file mode 100644 index 0000000..9d66a91 --- /dev/null +++ b/src/features/notification/components/ManualModal.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { FunctionComponent } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'next-i18next'; + +import { cssVars } from '@/styles/theme'; +import DimmerOverlay from '@/components/atoms/DimmerOverlay'; +import Button from '@/components/atoms/Button'; + +type ManualModalProps = { + visible?: boolean; + onDismiss: () => void; +}; +const ManualModal: FunctionComponent = ({ + visible = false, + onDismiss, +}) => { + const { t } = useTranslation('common'); + + return ( + + + {t('experiences.notification_permission_manual.title')} +

{t('experiences.notification_permission_manual.description')}

+ +
+
+ ); +}; + +const Wrapper = styled.div` + position: fixed; + top: 1rem; + left: 5rem; + padding: 1rem 2rem; + max-width: min(calc(100vw - 10rem), 30rem); + background: ${cssVars.color.surface}; + color: ${cssVars.color.onSurface}; + border-radius: 0.5rem; +`; +const Title = styled.h3` + font-size: 1.5rem; + margin: 0 0 1rem 0; +`; + +export default ManualModal; diff --git a/src/features/notification/components/NotificationPermissionEperience.tsx b/src/features/notification/components/NotificationPermissionEperience.tsx new file mode 100644 index 0000000..31dd913 --- /dev/null +++ b/src/features/notification/components/NotificationPermissionEperience.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { FunctionComponent, useRef, useState } from 'react'; + +import { useAppDispatch } from '@/redux/hooks'; +import { syncPermissions } from '@/redux/slices/consent'; +import { getNotificationPermissionState } from '@/utils/permission'; +import useScrollDistanceTrigger from '@/hooks/useScrollDistanceTrigger'; + +import ManualModal from './ManualModal'; + +export type NotificationPermissionExperienceProps = { + scrollDistanceTrigger?: number; +}; +const NotificationPermissionExperience: FunctionComponent< + NotificationPermissionExperienceProps +> = ({ scrollDistanceTrigger = 200 }) => { + const initialState = useRef(getNotificationPermissionState()).current; + useScrollDistanceTrigger({ + threshold: scrollDistanceTrigger, + onTrigger: () => enterFlow(), + }); + const [manualModalVisible, setManualModalVisible] = useState(false); + const dispatch = useAppDispatch(); + + const enterFlow = async () => { + if (initialState !== 'default') { + return; + } + + const result = await Notification.requestPermission(); + dispatch(syncPermissions()); + if (result === 'denied') { + setManualModalVisible(true); + } + }; + + const onManualModalDismiss = () => { + setManualModalVisible(false); + dispatch(syncPermissions()); + }; + + return ( + + ); +}; + +export default NotificationPermissionExperience; diff --git a/src/features/page_title/components/PageTitleExperience.tsx b/src/features/page_title/components/PageTitleExperience.tsx index 2b78c55..d28372b 100644 --- a/src/features/page_title/components/PageTitleExperience.tsx +++ b/src/features/page_title/components/PageTitleExperience.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useTranslation } from 'next-i18next'; import { useAppSelector } from '@/redux/hooks'; diff --git a/src/features/wheel_of_fortune/components/ActionButton.tsx b/src/features/wheel_of_fortune/components/ActionButton.tsx index 8cb80c7..47f6c22 100644 --- a/src/features/wheel_of_fortune/components/ActionButton.tsx +++ b/src/features/wheel_of_fortune/components/ActionButton.tsx @@ -7,7 +7,28 @@ import DimmerOverlay from '@/components/atoms/DimmerOverlay'; import ModalContent from './ModalContent'; -const zIndexBase = 30; +const ActionButton: FunctionComponent = () => { + const [isOpen, setOpen] = useState(false); + + return ( + + setOpen(false)} + closeOnClickOutside={false} + closeOnEsc> +
e.stopPropagation()} hidden={!isOpen}> + setOpen(false)} /> +
+
+ setOpen(true)}> + + +
+ ); +}; + +const Z_INDEX_BASE = 30; const wiggleAnim = keyframes` 0% { transform: rotate(0deg); } @@ -22,7 +43,7 @@ const Wrap = styled.div` position: fixed; left: 0; top: 50%; - z-index: ${zIndexBase}; + z-index: ${Z_INDEX_BASE}; `; const Icon = styled.div` padding: 1rem 1rem 1rem 3rem; @@ -42,25 +63,4 @@ const Icon = styled.div` } `; -const ActionButton: FunctionComponent = () => { - const [isOpen, setIsOpen] = useState(false); - - return ( - - setIsOpen(false)} - closeOnClickOutside={false} - closeOnEsc> -
e.stopPropagation()} hidden={!isOpen}> - setIsOpen(false)} /> -
-
- setIsOpen(true)}> - - -
- ); -}; - export default ActionButton; diff --git a/src/hooks/useScrollDistanceTrigger.ts b/src/hooks/useScrollDistanceTrigger.ts new file mode 100644 index 0000000..5778489 --- /dev/null +++ b/src/hooks/useScrollDistanceTrigger.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type ConditionalTriggerProps = { + threshold: number; + onTrigger?: () => void; +}; +const useScrollDistanceTrigger = ({ + threshold, + onTrigger, +}: ConditionalTriggerProps) => { + const [isCompleted, setCompleted] = useState(false); + + const onScroll = useCallback(() => { + if (window.scrollY >= threshold) { + setCompleted(true); + onTrigger?.(); + } + }, [onTrigger, threshold]); + + useEffect(() => { + if (isCompleted) { + return; + } + + document.addEventListener('scroll', onScroll); + return () => document.removeEventListener('scroll', onScroll); + }, [isCompleted, onScroll]); +}; + +export default useScrollDistanceTrigger; diff --git a/src/hooks/useSendNotification.ts b/src/hooks/useSendNotification.ts new file mode 100644 index 0000000..6e983e8 --- /dev/null +++ b/src/hooks/useSendNotification.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import config from '@/config'; + +type Props = { + autoRequest?: boolean; +}; +const useSendNotification = ({ autoRequest = false }: Props = {}) => { + const send = useCallback( + async (data: { title: string; body?: string; data?: unknown }) => { + const permission = Notification.permission; + if (!config.isBrowser) { + return false; + } + + if (permission === 'default' && autoRequest) { + await Notification.requestPermission(); + } + + const result = new Notification(data.title, { + body: data.body, + icon: '/manifest/android-chrome-256x256.png', + vibrate: [200, 100, 200], + data: data.data, + }); + + console.log('YO', result); + }, + [autoRequest], + ); + + return { send }; +}; + +export default useSendNotification; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index b9bef20..700fbd6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,17 +1,19 @@ import '@/styles/globals.css'; import Head from 'next/head'; -import { appWithTranslation } from 'next-i18next'; +import { UserConfig, appWithTranslation } from 'next-i18next'; import english from '@/public/locales/en/common.json'; import MainLayout from '@/components/templates/MainLayout'; import ProviderContainer from '@/providers/ProviderContainer'; import GeneratedMetaHead from '@/components/templates/GeneratedMetaHead'; +import nextI18NextConfig from '@/../next-i18next.config.js'; + import type { AppProps } from 'next/app'; const TheMostAnnoyingWebsite = ({ Component, pageProps }: AppProps) => { // Can't use translations here yet, description will be set on page level - // https://github.com/i18next/next-i18next?tab=readme-ov-file#serversidetranslations + // https://github.com/i18next/next-i18next/tree/v15.2.0#serversidetranslations const description = english.meta.description; return ( @@ -34,4 +36,17 @@ const TheMostAnnoyingWebsite = ({ Component, pageProps }: AppProps) => { ); }; -export default appWithTranslation(TheMostAnnoyingWebsite); +// This is to avoid the following build error: +// `react-i18next:: You will need to pass in an i18next instance by using initReactI18next` +// https://github.com/i18next/next-i18next/tree/v15.2.0#usage-with-fallback-ssg-pages +const emptyInitialI18NextConfig: UserConfig = { + i18n: { + defaultLocale: nextI18NextConfig.i18n.defaultLocale, + locales: nextI18NextConfig.i18n.locales, + }, +}; + +export default appWithTranslation( + TheMostAnnoyingWebsite, + emptyInitialI18NextConfig, +); diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx new file mode 100644 index 0000000..398a504 --- /dev/null +++ b/src/pages/_document.tsx @@ -0,0 +1,32 @@ +import Document, { DocumentContext } from 'next/document'; +import { ServerStyleSheet } from 'styled-components'; + +class AppDocument extends Document { + static async getInitialProps(ctx: DocumentContext) { + const sheet = new ServerStyleSheet(); + const originalRenderPage = ctx.renderPage; + + try { + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => (props) => + sheet.collectStyles(), + }); + + const initialProps = await Document.getInitialProps(ctx); + return { + ...initialProps, + styles: ( + <> + {initialProps.styles} + {sheet.getStyleElement()} + + ), + }; + } finally { + sheet.seal(); + } + } +} + +export default AppDocument; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index c889e9d..0cc9423 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -2,6 +2,7 @@ import ReactTimeAgo from 'react-timeago'; import { styled } from 'styled-components'; import { useTranslation } from 'next-i18next'; import { NextPage } from 'next'; +import { FunctionComponent, PropsWithChildren } from 'react'; import { cssRule, cssVars } from '@/styles/theme'; import { useAppDispatch, useAppSelector } from '@/redux/hooks'; @@ -9,7 +10,6 @@ import { UserColorScheme, actions as preferenceActions, } from '@/redux/slices/preference'; -import { actions as consentActions } from '@/redux/slices/consent'; import { actions as experienceActions } from '@/redux/slices/experience'; import selectPreference from '@/redux/selectors/preference'; import selectExperience from '@/redux/selectors/experience'; @@ -17,6 +17,8 @@ import selectConsent from '@/redux/selectors/consent'; import selectRuntime from '@/redux/selectors/runtime'; import { makeI18nStaticProps } from '@/utils/i18n'; import SiteTitle from '@/components/atoms/SiteTitle'; +import FormSelect from '@/components/atoms/FormSelect'; +import FormCheckbox from '@/components/atoms/FormCheckbox'; const Blocks = styled.div` display: grid; @@ -40,64 +42,21 @@ const BlockBody = styled.div<{ $gap?: boolean }>` flex-direction: column; gap: ${(props) => (props.$gap ? cssVars.spacing.gap : 0)}; `; -const RowWithLabel = styled.div` - label { - display: flex; - justify-content: space-between; - } +const LabelRow = styled.label` + display: flex; + justify-content: space-between; `; -type FormElementProps = { - label: string; - name: string; -}; - -type ToggableRowProps = FormElementProps & { - checked: boolean; - onChange: (value: boolean) => void; -}; -const ToggableRow = ({ label, name, checked, onChange }: ToggableRowProps) => ( - - - -); - -type SelectRowProps = FormElementProps & { - values: { value: string; label: string }[]; - selected: string; - onChange: (value: string) => void; + {children} + + ); }; -const SelectRow = ({ - label, - name, - values, - selected, - onChange, -}: SelectRowProps) => ( - - - -); const PrivacyPolicy: NextPage = () => { const dispatch = useAppDispatch(); @@ -118,13 +77,7 @@ const PrivacyPolicy: NextPage = () => { dispatch(preferenceActions.setEnableSound(value)); const onAdultFilterChange = (value: boolean) => dispatch(preferenceActions.setAdultFilter(value)); - // Consent block - const onAllowCookiesChange = (value: boolean) => - dispatch(consentActions.setAllowCookies(value)); - const onAlowNotificationChange = (value: boolean) => - dispatch(consentActions.setAllowNotification(value)); - const onAllowLocationChange = (value: boolean) => - dispatch(consentActions.setAllowLocation(value)); + // Experience block const onAlowMockChatChange = (value: boolean) => dispatch(experienceActions.setMockChat(value)); @@ -152,91 +105,99 @@ const PrivacyPolicy: NextPage = () => { {t('preference_section.title')} - - - - + + + + + + + + + + + + {t('consent_section.title')} - - - + + + +
+ {t('consent_section.permission_disclaimer')} + + {`${consent.permission.notification || tCommon('status.not_set')}`} + + + {`${consent.permission.location || tCommon('status.not_set')}`} +
{t('experience_section.title')} - - - - - + + + + + + + + + + + + + + + diff --git a/src/providers/ExperienceProvider.tsx b/src/providers/ExperienceProvider.tsx index 4527cc9..d0c3a22 100644 --- a/src/providers/ExperienceProvider.tsx +++ b/src/providers/ExperienceProvider.tsx @@ -1,28 +1,29 @@ import { FunctionComponent, PropsWithChildren } from 'react'; import { useBeforeUnload } from 'react-use'; +import { useTranslation } from 'next-i18next'; import useFirstInteractionListener from '@/hooks/useFirstInteractionListener'; import useDocumentVisibilityListener from '@/hooks/useDocumentVisibilityListener'; import { selectExitPrompt } from '@/redux/selectors/experience'; import { useAppSelector } from '@/redux/hooks'; import PageTitleExperience from '@/features/page_title/components/PageTitleExperience'; +import NotificationPermissionExperience from '@/features/notification/components/NotificationPermissionEperience'; const ExperienceProvider: FunctionComponent = ({ children, }) => { const exitPrompt = useAppSelector(selectExitPrompt); + const { t } = useTranslation('common'); useFirstInteractionListener(); useDocumentVisibilityListener(); - useBeforeUnload( - exitPrompt, - `I'd reconsider leaving before some bad things happend to you. Are you sure?`, - ); + useBeforeUnload(exitPrompt, t('experiences.exit_prompt')); return ( <> + {children} ); diff --git a/src/redux/selectors/consent.ts b/src/redux/selectors/consent.ts index b5e606c..f1bc613 100644 --- a/src/redux/selectors/consent.ts +++ b/src/redux/selectors/consent.ts @@ -3,11 +3,11 @@ import type { AppRootState } from '@/redux/store'; const selectConsent = (state: AppRootState) => state.consent; export const selectReviewCompleted = (state: AppRootState) => state.consent.reviewCompleted; -export const selectAllowCookies = (state: AppRootState) => - state.consent.allowCookies; -export const selectAllowLocation = (state: AppRootState) => - state.consent.allowLocation; -export const selectAllowNotification = (state: AppRootState) => - state.consent.allowNotification; +export const selectCookiesConsent = (state: AppRootState) => + state.consent.cookies; +export const selectPermissionForLocation = (state: AppRootState) => + state.consent.permission.location; +export const selectPermissionForNotification = (state: AppRootState) => + state.consent.permission.notification; export default selectConsent; diff --git a/src/redux/slices/consent.ts b/src/redux/slices/consent.ts index d1d5284..13a4f6f 100644 --- a/src/redux/slices/consent.ts +++ b/src/redux/slices/consent.ts @@ -1,19 +1,44 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + getLocationPermissionState, + getNotificationPermissionState, +} from '@/utils/permission'; export interface ConsentState { reviewCompleted: boolean; - allowCookies: boolean; - allowLocation: boolean | null; - allowNotification: boolean | null; + cookies: { + essential: boolean; + }; + permission: { + location?: PermissionState; + notification?: NotificationPermission; + }; } const initialState: ConsentState = { reviewCompleted: false, - allowCookies: true, - allowLocation: null, - allowNotification: null, + cookies: { + essential: true, + }, + permission: {}, }; +export const syncLocationPermission = createAsyncThunk( + 'consent/syncLocationPermission', + () => getLocationPermissionState(), +); + +export const syncPermissions = createAsyncThunk( + 'consent/syncPermissions', + async (): Promise => { + return { + location: await getLocationPermissionState(), + notification: getNotificationPermissionState(), + }; + }, +); + export const consentSlice = createSlice({ name: 'consent', initialState, @@ -21,16 +46,42 @@ export const consentSlice = createSlice({ setReviewCompleted: (state, action: PayloadAction) => { state.reviewCompleted = action.payload; }, - setAllowCookies: (state, action: PayloadAction) => { - state.allowCookies = action.payload; - }, - setAllowLocation: (state, action: PayloadAction) => { - state.allowLocation = action.payload; + setCookieConsent: ( + state, + action: PayloadAction>, + ) => { + state.cookies = { + ...action.payload, + essential: state.cookies.essential, + }; }, - setAllowNotification: (state, action: PayloadAction) => { - state.allowNotification = action.payload; + syncNotificationPermission: (state) => { + state.permission.notification = getNotificationPermissionState(); }, }, + extraReducers: (builder) => { + builder + .addCase(syncLocationPermission.pending, (state) => { + state.permission.location = undefined; + }) + .addCase(syncLocationPermission.fulfilled, (state, action) => { + state.permission.location = action.payload; + }) + .addCase(syncLocationPermission.rejected, (state) => { + state.permission.location = undefined; + }); + + builder + .addCase(syncPermissions.pending, (state) => { + state.permission = {}; + }) + .addCase(syncPermissions.fulfilled, (state, action) => { + state.permission = action.payload; + }) + .addCase(syncPermissions.rejected, (state) => { + state.permission = {}; + }); + }, }); export const actions = consentSlice.actions; diff --git a/src/utils/permission.ts b/src/utils/permission.ts new file mode 100644 index 0000000..5f70f04 --- /dev/null +++ b/src/utils/permission.ts @@ -0,0 +1,9 @@ +import config from '@/config'; + +export const getNotificationPermissionState = () => + config.isBrowser ? Notification.permission : undefined; + +export const getLocationPermissionState = async () => + config.isBrowser + ? (await navigator.permissions.query({ name: 'geolocation' })).state + : undefined;