diff --git a/apps/services/auth/ids-api/src/openapi.yaml b/apps/services/auth/ids-api/src/openapi.yaml index 0c95bf04..9384b979 100644 --- a/apps/services/auth/ids-api/src/openapi.yaml +++ b/apps/services/auth/ids-api/src/openapi.yaml @@ -2360,7 +2360,7 @@ components: expiration: format: date-time type: string - example: 2025-05-07T12:54:09.342Z + example: 2025-05-08T07:40:13.069Z consumedTime: format: date-time type: string @@ -2413,7 +2413,7 @@ components: expiration: format: date-time type: string - example: 2025-05-07T12:54:09.401Z + example: 2025-05-08T07:40:13.132Z consumedTime: type: object data: diff --git a/apps/services/auth/personal-representative-public/src/openapi.yaml b/apps/services/auth/personal-representative-public/src/openapi.yaml index 6c01c93f..ea625109 100644 --- a/apps/services/auth/personal-representative-public/src/openapi.yaml +++ b/apps/services/auth/personal-representative-public/src/openapi.yaml @@ -343,11 +343,11 @@ components: validFrom: format: date-time type: string - example: 2025-05-05T12:54:04.011Z + example: 2025-05-06T07:40:07.938Z validTo: format: date-time type: string - example: 2025-05-07T12:54:04.011Z + example: 2025-05-08T07:40:07.938Z required: - code - description diff --git a/apps/services/auth/personal-representative/src/openapi.yaml b/apps/services/auth/personal-representative/src/openapi.yaml index c2c27db4..95e12525 100644 --- a/apps/services/auth/personal-representative/src/openapi.yaml +++ b/apps/services/auth/personal-representative/src/openapi.yaml @@ -1032,11 +1032,11 @@ components: validFrom: format: date-time type: string - example: 2025-05-05T12:54:07.876Z + example: 2025-05-06T07:40:11.453Z validTo: format: date-time type: string - example: 2025-05-07T12:54:07.876Z + example: 2025-05-08T07:40:11.453Z required: - code - description @@ -1135,7 +1135,7 @@ components: validTo: format: date-time type: string - example: 2025-05-07T12:54:07.866Z + example: 2025-05-08T07:40:11.441Z rights: example: >- [{code:"health", description:"health descr", validFrom:"xx.yy.zzzz", @@ -1222,7 +1222,7 @@ components: validTo: format: date-time type: string - example: 2025-05-07T12:54:07.869Z + example: 2025-05-08T07:40:11.444Z rightCodes: example: '["health", "finance"]' description: >- @@ -1254,7 +1254,7 @@ components: validTo: format: date-time type: string - example: 2025-05-07T12:54:07.874Z + example: 2025-05-08T07:40:11.450Z required: - code - name diff --git a/apps/services/endorsements/api/src/openapi.yaml b/apps/services/endorsements/api/src/openapi.yaml index 9c9927f3..91a5fb49 100644 --- a/apps/services/endorsements/api/src/openapi.yaml +++ b/apps/services/endorsements/api/src/openapi.yaml @@ -1054,11 +1054,11 @@ components: openedDate: format: date-time type: string - default: 2025-05-06T12:51:50.224Z + default: 2025-05-07T07:37:58.838Z closedDate: format: date-time type: string - default: 2025-06-06T12:51:50.224Z + default: 2025-06-07T07:37:58.838Z adminLock: type: boolean default: false diff --git a/apps/services/user-notification/src/openapi.yaml b/apps/services/user-notification/src/openapi.yaml index 6469e158..f1d0e6ec 100644 --- a/apps/services/user-notification/src/openapi.yaml +++ b/apps/services/user-notification/src/openapi.yaml @@ -806,11 +806,11 @@ components: created: format: date-time type: string - example: '2025-05-06T12:51:43.540Z' + example: '2025-05-07T07:37:52.399Z' updated: format: date-time type: string - example: '2025-05-06T12:51:43.540Z' + example: '2025-05-07T07:37:52.399Z' read: type: boolean example: false diff --git a/apps/tax/.eslintrc.json b/apps/tax/.eslintrc.json index eb932df6..b0b7131a 100644 --- a/apps/tax/.eslintrc.json +++ b/apps/tax/.eslintrc.json @@ -1,10 +1,55 @@ { "extends": ["plugin:@nx/react", "../../.eslintrc.json"], "ignorePatterns": ["!**/*"], - "rules": {}, + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "enforceBuildableLibDependency": true, + "allowCircularSelfDependency": true, + "allow": ["../../../infra/src/dsl"], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": ["*"] + } + ] + } + ], + "simple-import-sort/imports": [ + "warn", + { + "groups": [ + // React related packages come first, followed by all external imports. + ["^react", "^\\w", "^@(?!island).+"], + // Then island.is packages. + ["^(@island.is).*"], + // Then all other imports in this order: "../", "./", "./css" + [ + "^\\.\\.(?!/?$)", + "^\\.\\./?$", + "^\\./(?=.*/)(?!/?$)", + "^\\.(?!/?$)", + "^\\./?$", + "^.+\\.?(css)$" + ] + ] + } + ] + }, + "plugins": ["simple-import-sort"], "overrides": [ - { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, - { "files": ["*.ts", "*.tsx"], "rules": {} }, - { "files": ["*.js", "*.jsx"], "rules": {} } + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } ] } diff --git a/apps/tax/components/FixedNav/FixedNav.css.ts b/apps/tax/components/FixedNav/FixedNav.css.ts new file mode 100644 index 00000000..28b5e354 --- /dev/null +++ b/apps/tax/components/FixedNav/FixedNav.css.ts @@ -0,0 +1,83 @@ +import { style } from '@vanilla-extract/css' + +import { theme, themeUtils } from '@island.is/island-ui/theme' +import { STICKY_NAV_HEIGHT } from '@island.is/tax/constants' + +export const wrapper = style({ + position: 'fixed', + display: 'flex', + width: '100%', + left: 0, + right: 0, + top: 0, + margin: 0, + padding: 0, + height: STICKY_NAV_HEIGHT, + zIndex: 1000, + backgroundColor: theme.color.blue400, + transform: `translateY(-100%)`, + opacity: 0, + visibility: 'hidden', + transition: + 'opacity 150ms ease, transform 150ms ease, visibility 0ms linear 150ms', +}) + +export const container = style({ + margin: '0 auto', + padding: 0, +}) + +export const show = style({ + transform: `translateY(0%)`, + opacity: 1, + visibility: 'visible', + transition: + 'opacity 150ms ease, transform 150ms ease, visibility 0ms linear 0ms', +}) + +export const arrowButton = style({ + display: 'flex', + height: 40, + width: 40, + alignItems: 'center', + justifyContent: 'center', + fontWeight: theme.typography.semiBold, + borderRadius: 8, + outline: 'none', + cursor: 'pointer', + transition: 'box-shadow .25s, color .25s, background-color .25s', + backgroundColor: theme.color.transparent, + boxShadow: `inset 0 0 0 1px ${theme.color.white}`, + color: theme.color.white, + ':active': { + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, + ':focus': { + color: theme.color.dark400, + backgroundColor: theme.color.mint400, + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, + ':hover': { + backgroundColor: theme.color.transparent, + boxShadow: `inset 0 0 0 2px ${theme.color.dark100}`, + color: theme.color.dark100, + }, + selectors: { + '&:focus:active': { + backgroundColor: theme.color.transparent, + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, + }, + ...themeUtils.responsiveStyle({ + md: { + height: 48, + width: 48, + }, + }), +}) + +export const logo = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}) diff --git a/apps/tax/components/FixedNav/FixedNav.tsx b/apps/tax/components/FixedNav/FixedNav.tsx new file mode 100644 index 00000000..fe0ede26 --- /dev/null +++ b/apps/tax/components/FixedNav/FixedNav.tsx @@ -0,0 +1,107 @@ +import React, { FC, useState } from 'react' +import cn from 'classnames' + +import { + Box, + FocusableBox, + GridContainer, + Hidden, + Icon, + Logo, +} from '@island.is/island-ui/core' + +import { useScrollPosition } from '../../hooks/useScrollPosition' +import * as styles from './FixedNav.css' + +export const FixedNav: FC> = () => { + const [show, setShow] = useState(false) + + useScrollPosition( + ({ prevPos, currPos }) => { + let px = -600 + + if (typeof window !== `undefined`) { + px = window.innerHeight * -1 + } + + const goingDown = currPos.y < prevPos.y + const canShow = px > currPos.y + + setShow(canShow && !goingDown) + }, + [setShow], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore make web strict + null, + false, + 150, + ) + + return ( +
+ + + + + + + + + + + + + + + + SearchInput + + + + + + + +
+ ) +} + +export default FixedNav diff --git a/apps/tax/components/Header/ComboButton.css.ts b/apps/tax/components/Header/ComboButton.css.ts new file mode 100644 index 00000000..f7ecd9e2 --- /dev/null +++ b/apps/tax/components/Header/ComboButton.css.ts @@ -0,0 +1,63 @@ +import { style } from '@vanilla-extract/css' + +import { theme } from '@island.is/island-ui/theme' + +export const buttonBase = style({ + fontFamily: 'IBM Plex Sans, sans-serif', + fontStyle: 'normal', + fontWeight: theme.typography.semiBold, + borderRadius: theme.border.radius.large, + fontSize: 12, + lineHeight: 1.333333, + minHeight: 40, + padding: '0 12px', + display: 'flex', + alignItems: 'center', + outline: 'none', + backgroundColor: theme.color.transparent, + boxShadow: `inset 0 0 0 1px ${theme.color.blue200}`, + transition: 'box-shadow .25s, color .25s', + ':hover': { + boxShadow: `inset 0 0 0 1px ${theme.color.blue400}`, + }, + ':focus': { + color: theme.color.dark400, + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, + ':active': { + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, +}) + +export const searchButton = style({ + borderRadius: `${theme.border.radius.large} 0 0 ${theme.border.radius.large}`, + marginRight: '-1px', +}) + +export const menuButton = style({ + borderRadius: `0 ${theme.border.radius.large} ${theme.border.radius.large} 0`, +}) + +export const buttonText = style({ + marginRight: '8px', +}) + +export const white = style({ + backgroundColor: theme.color.transparent, + boxShadow: `inset 0 0 0 1px ${theme.color.white}`, + color: theme.color.white, + + ':hover': { + color: theme.color.white, + boxShadow: `inset 0 0 0 1px ${theme.color.white}`, + backgroundColor: theme.color.transparent, + }, + ':focus': { + color: theme.color.white, + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + backgroundColor: theme.color.transparent, + }, + ':active': { + boxShadow: `inset 0 0 0 3px ${theme.color.mint400}`, + }, +}) diff --git a/apps/tax/components/Header/ComboButton.tsx b/apps/tax/components/Header/ComboButton.tsx new file mode 100644 index 00000000..0385684d --- /dev/null +++ b/apps/tax/components/Header/ComboButton.tsx @@ -0,0 +1,64 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React, { useContext } from 'react' +import cn from 'classnames' +import { Button as ReaButton } from 'reakit/Button' + +import { Box, ColorSchemeContext, Icon } from '@island.is/island-ui/core' + +import * as styles from './ComboButton.css' + +interface ComboButtonProps { + sideBarMenuOpen: () => void + sideMenuSearchFocus: () => void + showSearch: boolean +} + +export const ComboButton = ({ + sideBarMenuOpen, + sideMenuSearchFocus, + showSearch, +}: ComboButtonProps) => { + const { colorScheme } = useContext(ColorSchemeContext) + const colorSchemeIsWhite = colorScheme === 'white' + + return ( + <> + {showSearch && ( + + + + )} + + MenuCaption + + + + ) +} + +export default ComboButton diff --git a/apps/tax/components/Header/Header.tsx b/apps/tax/components/Header/Header.tsx new file mode 100644 index 00000000..97c03f2c --- /dev/null +++ b/apps/tax/components/Header/Header.tsx @@ -0,0 +1,117 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React, { FC, useContext } from 'react' + +import { + Box, + Button, + ButtonTypes, + ColorSchemeContext, + Column, + Columns, + DropdownMenu, + FocusableBox, + GridColumn, + GridContainer, + GridRow, + Hidden, + Input, + Logo, + ResponsiveSpace, +} from '@island.is/island-ui/core' +import { LayoutProps } from '@island.is/tax/layouts/main' + +interface HeaderProps { + showSearchInHeader?: boolean + buttonColorScheme?: ButtonTypes['colorScheme'] + languageToggleQueryParams?: LayoutProps['languageToggleQueryParams'] + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore make web strict + megaMenuData + organizationSearchFilter?: string + searchPlaceholder?: string + customTopLoginButtonItem?: LayoutProps['customTopLoginButtonItem'] + loginButtonType?: 'dropdown' | 'link' +} + +const marginLeft = [1, 1, 1, 2] as ResponsiveSpace + +export const Header: FC> = ({ + showSearchInHeader = true, + children, +}) => { + const { colorScheme } = useContext(ColorSchemeContext) + const isWhite = colorScheme === 'white' + + return ( +
+ + + + + + + + + + + + + + + + + {showSearchInHeader && ( + + + + )} + + + + + + + + + + + + + + + {children} +
+ ) +} + +export default Header diff --git a/apps/tax/components/Main/Main.tsx b/apps/tax/components/Main/Main.tsx new file mode 100644 index 00000000..350177e3 --- /dev/null +++ b/apps/tax/components/Main/Main.tsx @@ -0,0 +1,26 @@ +import React, { useRef } from 'react' + +import { Box, BoxProps } from '@island.is/island-ui/core' + +export const Main: React.FC> = ({ + children, +}) => { + const mainRef = useRef(null) + const shouldAddLandmark = + !mainRef?.current?.querySelectorAll('#main-content').length + const boxProps: BoxProps = shouldAddLandmark + ? { + component: 'main', + tabIndex: -1, + outline: 'none', + id: 'main-content', + display: 'flex', + flexGrow: 1, + } + : {} + return ( + + {children} + + ) +} diff --git a/apps/tax/components/MobileAppBanner/MobileAppBanner.css.ts b/apps/tax/components/MobileAppBanner/MobileAppBanner.css.ts new file mode 100644 index 00000000..63078240 --- /dev/null +++ b/apps/tax/components/MobileAppBanner/MobileAppBanner.css.ts @@ -0,0 +1,23 @@ +import { globalStyle, style } from '@vanilla-extract/css' + +import { theme } from '@island.is/island-ui/theme' + +export const container = style({ + position: 'relative', + padding: '12px 24px', + marginBottom: '-8px', + boxShadow: '0px 4px 30px rgba(0, 97, 255, 0.16)', +}) + +export const buttonWrapper = style({}) + +export const closeBtn = style({ + position: 'absolute', + background: 'transparent', + top: theme.spacing[1], + left: theme.spacing[1], +}) + +globalStyle(`${buttonWrapper} button, ${buttonWrapper} button:hover`, { + background: '#fff', +}) diff --git a/apps/tax/components/MobileAppBanner/MobileAppBanner.tsx b/apps/tax/components/MobileAppBanner/MobileAppBanner.tsx new file mode 100644 index 00000000..5461eb8d --- /dev/null +++ b/apps/tax/components/MobileAppBanner/MobileAppBanner.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react' +import Cookies from 'js-cookie' + +import { + Box, + Button, + Hidden, + Icon, + Link, + Logo, + Text, + VisuallyHidden, +} from '@island.is/island-ui/core' + +import * as style from './MobileAppBanner.css' + +declare global { + interface Window { + MSStream: unknown + } +} + +export const MobileAppBanner = () => { + const [isMounted, setIsMounted] = useState(false) + const COOKIE_NAME = 'island-mobile-app-banner' + + const appleLink = 'appleLink' + const androidLink = 'androidLink' + + const [hidden, setHidden] = useState( + !!Cookies.get(COOKIE_NAME) || !appleLink || !androidLink, + ) + const [isApple, setIsApple] = useState(false) + + const getMobilePlatform = (): string => { + if (typeof navigator === 'undefined') { + return '' + } + const userAgent = navigator.userAgent || navigator.vendor + return /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream + ? 'apple' + : 'android' + } + + useEffect(() => { + setIsApple(getMobilePlatform() === 'apple') + setIsMounted(true) + }, []) + + return ( + + + + + + + + + Title + + + Subtitle + + + + + + + + + + ) +} diff --git a/apps/tax/components/PageLoader/PageLoader.tsx b/apps/tax/components/PageLoader/PageLoader.tsx new file mode 100644 index 00000000..091a188f --- /dev/null +++ b/apps/tax/components/PageLoader/PageLoader.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useRef } from 'react' +import { LoadingBarRef } from 'react-top-loading-bar' +import { useRouter } from 'next/router' + +import { PageLoader as PageLoaderUI } from '@island.is/island-ui/core' + +type RouteChangeFunction = (url: string, props: { shallow: boolean }) => void + +export const PageLoader = () => { + const router = useRouter() + const ref = useRef(null) + const state = useRef<'idle' | 'loading'>('idle') + + useEffect(() => { + const onStart: RouteChangeFunction = (_, { shallow }) => { + if (!shallow) { + state.current = 'loading' + ref.current?.continuousStart() + } + } + const onComplete: RouteChangeFunction = (_, { shallow }) => { + if (!shallow) { + state.current = 'idle' + ref.current?.complete() + } + } + const onError = () => { + if (state.current === 'loading') { + ref.current?.complete() + state.current = 'idle' + } + } + + router.events.on('routeChangeStart', onStart) + router.events.on('routeChangeComplete', onComplete) + router.events.on('routeChangeError', onError) + + return () => { + router.events.off('routeChangeStart', onStart) + router.events.off('routeChangeComplete', onComplete) + router.events.off('routeChangeError', onError) + } + }, [router.events]) + + return +} + +export default PageLoader diff --git a/apps/tax/components/SkipToMainContent/SkipToMainContent.css.ts b/apps/tax/components/SkipToMainContent/SkipToMainContent.css.ts new file mode 100644 index 00000000..8c392062 --- /dev/null +++ b/apps/tax/components/SkipToMainContent/SkipToMainContent.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css' + +import { theme } from '@island.is/island-ui/theme' + +export const container = style({ + position: 'absolute', + top: 20, + left: 20, + padding: 10, + background: theme.color.dark400, + color: theme.color.white, + borderRadius: theme.border.radius.large, + opacity: 0.8, + borderWidth: 1, + outline: 0, + textDecoration: 'none', + borderStyle: 'solid', + borderColor: theme.color.blue200, + overflow: 'hidden', + zIndex: 1, + '@media': { + [`screen and (max-width: 991px)`]: { + borderRadius: 0, + border: 'none', + }, + }, + transition: 'transform 150ms ease', + transform: `translateY(calc(-100% - 20px))`, + ':focus': { + transform: `translateY(0)`, + }, +}) diff --git a/apps/tax/components/SkipToMainContent/SkipToMainContent.tsx b/apps/tax/components/SkipToMainContent/SkipToMainContent.tsx new file mode 100644 index 00000000..7001da6b --- /dev/null +++ b/apps/tax/components/SkipToMainContent/SkipToMainContent.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +import * as styles from './SkipToMainContent.css' + +type Props = { + title?: string +} + +export const SkipToMainContent = ({ title = 'Fara beint í efnið' }: Props) => { + return ( + + {title} + + ) +} + +export default SkipToMainContent diff --git a/apps/tax/components/index.ts b/apps/tax/components/index.ts new file mode 100644 index 00000000..5fd3ef20 --- /dev/null +++ b/apps/tax/components/index.ts @@ -0,0 +1,20 @@ +/** + * Add any shared web components here. This module can be imported like this: + * + * import { ... } from '@island.is/web/components' + * + * Never import it like this: + * + * import { ... } from '../components/real' + * + * This is so production builds can better optimize chunks. Read + * `libs/shared/babel/README.md` for more details. + */ + +export * from './Header/Header' +export * from './SkipToMainContent/SkipToMainContent' +export * from './PageLoader/PageLoader' +export * from './FixedNav/FixedNav' +export * from './Main/Main' +export * from './MobileAppBanner/MobileAppBanner' +export * from './Header/Header' \ No newline at end of file diff --git a/apps/tax/constants/index.ts b/apps/tax/constants/index.ts new file mode 100644 index 00000000..42302e6c --- /dev/null +++ b/apps/tax/constants/index.ts @@ -0,0 +1,19 @@ +import type { ResponsiveSpace } from '@island.is/island-ui/core' + +export const STICKY_NAV_HEIGHT = 64 +export const STICKY_NAV_MAX_WIDTH_DEFAULT = 230 +export const STICKY_NAV_MAX_WIDTH_LG = 318 +export const SLICE_SPACING: ResponsiveSpace = 7 +export const PROJECT_STORIES_TAG_ID = '9yqOTwQYzgyej5kItFTtd' +export const FRONTPAGE_NEWS_TAG_SLUG = 'forsidufrettir' +export const PLAUSIBLE_SCRIPT_SRC = + 'https://plausible.io/js/script.outbound-links.js' +export const DIGITAL_ICELAND_PLAUSIBLE_TRACKING_DOMAIN = + 'island.is/s/stafraent-island' +export const PRELOADED_FONTS = [ + '/fonts/ibm-plex-sans-v7-latin-300.woff2', + '/fonts/ibm-plex-sans-v7-latin-regular.woff2', + '/fonts/ibm-plex-sans-v7-latin-italic.woff2', + '/fonts/ibm-plex-sans-v7-latin-500.woff2', + '/fonts/ibm-plex-sans-v7-latin-600.woff2', +] diff --git a/apps/tax/context/BackgroundSchemeContext/BackgroundSchemeContext.ts b/apps/tax/context/BackgroundSchemeContext/BackgroundSchemeContext.ts new file mode 100644 index 00000000..b040458d --- /dev/null +++ b/apps/tax/context/BackgroundSchemeContext/BackgroundSchemeContext.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react' + +export type BackgroundSchemes = 'blue' | 'purple' | 'red' | 'white' + +export interface BackgroundSchemeInterface { + backgroundScheme: BackgroundSchemes +} + +export const BackgroundSchemeContext = createContext( + { + backgroundScheme: 'white', + }, +) + +export default BackgroundSchemeContext diff --git a/apps/tax/context/ColorSchemeContext/ColorSchemeContext.ts b/apps/tax/context/ColorSchemeContext/ColorSchemeContext.ts new file mode 100644 index 00000000..0a3072b3 --- /dev/null +++ b/apps/tax/context/ColorSchemeContext/ColorSchemeContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react' + +export type ColorSchemes = 'blue' | 'purple' | 'red' + +export interface ColorSchemeInterface { + colorScheme: ColorSchemes +} + +export const ColorSchemeContext = createContext({ + colorScheme: 'blue', +}) + +export default ColorSchemeContext diff --git a/apps/tax/context/GlobalContext/GlobalContext.tsx b/apps/tax/context/GlobalContext/GlobalContext.tsx new file mode 100644 index 00000000..d3a92449 --- /dev/null +++ b/apps/tax/context/GlobalContext/GlobalContext.tsx @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { createContext, FC, useState } from 'react' + +export type NamespaceType = { + [key: string]: any +} + +export interface GlobalContextProps { + globalNamespace: NamespaceType + isServiceWeb?: boolean + contentfulIds: string[] + resolveLinkTypeLocally?: boolean + setGlobalNamespace: (ns: NamespaceType) => void + setContentfulIds: (ids: string[]) => void + setResolveLinkTypeLocally: (localResolution: boolean) => void +} + +export interface GlobalContextProviderProps { + namespace?: NamespaceType + isServiceWeb?: boolean +} + +export const GlobalContext = createContext({ + globalNamespace: {}, + contentfulIds: [], + resolveLinkTypeLocally: false, + setGlobalNamespace: () => null, + setContentfulIds: () => null, + setResolveLinkTypeLocally: () => null, +}) + +export const GlobalContextProvider: FC< + React.PropsWithChildren +> = ({ namespace = {}, isServiceWeb = false, children }) => { + const setContentfulIds = (ids: string[]) => { + setState((prevState) => ({ + ...prevState, + contentfulIds: ids, + })) + } + + const setGlobalNamespace = (ns: NamespaceType) => { + setState((prevState) => ({ ...prevState, globalNamespace: ns })) + } + + const setResolveLinkTypeLocally = (localResolution: boolean) => { + setState((prevState) => ({ + ...prevState, + resolveLinkTypeLocally: localResolution, + })) + } + + const initialState: GlobalContextProps = { + globalNamespace: namespace, + isServiceWeb, + contentfulIds: [], + setGlobalNamespace, + setContentfulIds, + setResolveLinkTypeLocally, + } + + const [state, setState] = useState(initialState) + + return ( + {children} + ) +} + +export default GlobalContext diff --git a/apps/tax/context/MenuTabsContext/MenuTabsContext.ts b/apps/tax/context/MenuTabsContext/MenuTabsContext.ts new file mode 100644 index 00000000..1a7abc15 --- /dev/null +++ b/apps/tax/context/MenuTabsContext/MenuTabsContext.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createContext } from 'react' + +export type MenuTabsProps = { + [key: string]: any +} + +export interface MenuTabsContextProps { + menuTabs: MenuTabsProps +} + +export const MenuTabsContext = createContext({ + menuTabs: {}, +}) diff --git a/apps/tax/context/index.ts b/apps/tax/context/index.ts new file mode 100644 index 00000000..f7788966 --- /dev/null +++ b/apps/tax/context/index.ts @@ -0,0 +1,3 @@ +export * from './GlobalContext/GlobalContext' +export * from './ColorSchemeContext/ColorSchemeContext' +export * from './BackgroundSchemeContext/BackgroundSchemeContext' diff --git a/apps/tax/hooks/index.ts b/apps/tax/hooks/index.ts new file mode 100644 index 00000000..145b8e7f --- /dev/null +++ b/apps/tax/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useNamespace' +export * from './useLinkResolver' \ No newline at end of file diff --git a/apps/tax/hooks/useLinkResolver/index.ts b/apps/tax/hooks/useLinkResolver/index.ts new file mode 100644 index 00000000..5e5af83b --- /dev/null +++ b/apps/tax/hooks/useLinkResolver/index.ts @@ -0,0 +1,7 @@ +export { + linkResolver, + typeResolver, + useLinkResolver, + pathIsRoute, +} from './useLinkResolver' +export type { LinkResolverResponse, LinkType } from './useLinkResolver' diff --git a/apps/tax/hooks/useLinkResolver/useLinkResolver.spec.ts b/apps/tax/hooks/useLinkResolver/useLinkResolver.spec.ts new file mode 100644 index 00000000..04e9f3fa --- /dev/null +++ b/apps/tax/hooks/useLinkResolver/useLinkResolver.spec.ts @@ -0,0 +1,235 @@ +import { + convertToRegex, + extractSlugsByRouteTemplate, + linkResolver, + LinkType, + replaceVariableInPath, + routesTemplate, + typeResolver, +} from './useLinkResolver' + +describe('Link resolver', () => { + it('should return correct path to type without variable', () => { + const nextLinks = linkResolver('search', [], 'is') + expect(nextLinks).toEqual({ + href: '/leit', + }) + }) + it('should return correct path to type with variable', () => { + const nextLinks = linkResolver('lifeeventpage', ['cat'], 'is') + expect(nextLinks).toEqual({ + href: '/lifsvidburdir/cat', + }) + }) + + it('should return correct path for all locales', () => { + const nextIsLinks = linkResolver('news', ['hundur'], 'is') + expect(nextIsLinks).toEqual({ + href: '/frett/hundur', + }) + + const nextEnLinks = linkResolver('news', ['dog'], 'en') + expect(nextEnLinks).toEqual({ + href: '/en/news/dog', + }) + }) + + it('should direct unresolvable links to 404', () => { + const nextEnLink = linkResolver('page', [], 'en') + expect(nextEnLink).toEqual({ + href: '/404', + }) + }) + + it('should handle content type with uppercase', () => { + // @ts-expect-error (testing wrong input) + const randomCasedInput: LinkType = 'lifeEventPage' + const nextLinks = linkResolver(randomCasedInput, ['cat'], 'is') + expect(nextLinks).toEqual({ + href: '/lifsvidburdir/cat', + }) + }) + + it('should handle wrong content type ', () => { + // @ts-expect-error (testing wrong input) + const unknownInput: LinkType = 'dogPark' + const nextLinks = [ + linkResolver(unknownInput, [''], 'is'), + linkResolver(unknownInput, ['cat'], 'is'), + ] + nextLinks.forEach((link) => { + expect(link).toEqual({ + href: '/404', + }) + }) + }) + + it('should handle content type as empty string', () => { + // @ts-expect-error (testing wrong input) + const emptyInput: LinkType = '' + const nextLinks = linkResolver(emptyInput, [], 'is') + expect(nextLinks).toEqual({ + href: '/404', + }) + }) + + it('should handle content type as undefined', () => { + // @ts-expect-error (testing wrong input) + const undefinedInput: LinkType = undefined as unknown // unknown because strict:false + const nextLinks = linkResolver(undefinedInput, [], 'is') + expect(nextLinks).toEqual({ + href: '/404', + }) + }) + + it('should return external urls as next links objects', () => { + const nextLinks = linkResolver('linkurl', ['https://example.com'], 'is') + expect(nextLinks).toEqual({ + href: 'https://example.com', + }) + }) + + it('should have no path repetition', () => { + const types = Object.values(routesTemplate).reduce>( + (types, templateObject) => { + if (templateObject.en) { + types.push(templateObject.en) + } + if (templateObject.is) { + types.push(templateObject.is) + } + return types + }, + [], + ) + expect(types.length === new Set(types).size).toBeTruthy() + }) + + it('should have no link type repetition', () => { + const types = Object.keys(routesTemplate).map((type) => type.toLowerCase()) + expect(types.length === new Set(types).size).toBeTruthy() + }) +}) + +describe('Type resolver', () => { + it('Should find path with variables', () => { + const types = typeResolver('/flokkur/mycustomcategory') + expect(types).toEqual({ + type: 'articlecategory', + locale: 'is', + slug: ['mycustomcategory'], + }) + }) + + it('Should match path without variables', () => { + const types = typeResolver('/frett') + expect(types).toEqual({ + type: 'newsoverview', + locale: 'is', + slug: [], + }) + }) + + it('Should support multiple locales', () => { + const typesIs = typeResolver('/frett/mycustomnews') + expect(typesIs).toEqual({ + type: 'news', + locale: 'is', + slug: ['mycustomnews'], + }) + + const typesEn = typeResolver('/en/news/mycustomnews') + expect(typesEn).toEqual({ + type: 'news', + locale: 'en', + slug: ['mycustomnews'], + }) + }) + + it('Should handle undefined', () => { + // @ts-expect-error (testing wrong input) + const undefinedInput: string = undefined as unkown // unknown because strict:false + const types = typeResolver(undefinedInput) + expect(types).toBeNull() + }) + + it('Should handle empty path', () => { + const types = typeResolver('') + expect(types).toBeNull() + }) + + it('Should not resolve partial matches when ignore dynamic is false', () => { + const types = typeResolver('/fretr/andesbar/other') + expect(types).toBeNull() + }) + + it('Should resolve partial matches when ignore dynamic is true', () => { + const types = typeResolver('/frett/mycustomnews', true) + expect(types).toEqual({ + type: 'newsoverview', + locale: 'is', + slug: [], + }) + }) + + it('Should resolve paths with dashes', () => { + const types = typeResolver('/andes-foo/andes-bar') + expect(types).toEqual({ + type: 'subarticle', + locale: 'is', + slug: ['andes-foo', 'andes-bar'], + }) + }) +}) + +describe('Extract slugs by route template', () => { + it('Should handle short static paths', () => { + const slug = extractSlugsByRouteTemplate('/en', '/en') + expect(slug).toEqual([]) + }) + + it('Should handle short dynamic paths', () => { + const slug = extractSlugsByRouteTemplate('/theslug', '/[slug]') + expect(slug).toEqual(['theslug']) + }) + + it('Should handle long static paths', () => { + const slug = extractSlugsByRouteTemplate( + '/mega/long/path', + '/mega/long/path', + ) + expect(slug).toEqual([]) + }) + + it('Should handle long dynamic paths', () => { + const slug = extractSlugsByRouteTemplate( + '/mega/long/path', + '/[slug]/[subSlug]/[subSubSlug]', + ) + expect(slug).toEqual(['mega', 'long', 'path']) + }) +}) + +describe('Replace variable in path', () => { + it('Should replace variable ins string', () => { + const path = replaceVariableInPath('/test/[replaceme]', 'case') + expect(path).toEqual('/test/case') + }) +}) + +describe('Convert to regex', () => { + it('Should convert dynamic template string to regex', () => { + const regex = convertToRegex('/test/[replaceme]') + expect(regex).toEqual('^\\/test\\/[-\\w]+$') + }) + + it('Should convert deep dynamic template string to regex', () => { + const regex = convertToRegex('/test/[replaceme]/then/[me]/ending') + expect(regex).toEqual('^\\/test\\/[-\\w]+\\/then\\/[-\\w]+\\/ending$') + }) + + it('Should convert static template string to regex', () => { + const regex = convertToRegex('/test') + expect(regex).toEqual('^\\/test$') + }) +}) diff --git a/apps/tax/hooks/useLinkResolver/useLinkResolver.ts b/apps/tax/hooks/useLinkResolver/useLinkResolver.ts new file mode 100644 index 00000000..522a1947 --- /dev/null +++ b/apps/tax/hooks/useLinkResolver/useLinkResolver.ts @@ -0,0 +1,501 @@ +import { useContext } from 'react' + +import { defaultLanguage } from '@island.is/shared/constants' +import { Locale } from '@island.is/shared/types' + +export interface LinkResolverResponse { + href: string +} + +interface LinkResolverInput { + linkType: LinkType + variables?: string[] + locale?: Locale +} + +interface TypeResolverResponse { + locale: Locale + type?: LinkType + slug?: string[] +} + +export type LinkType = keyof typeof routesTemplate | 'linkurl' | 'link' + +/* +The order here matters for type resolution, arrange overlapping types from most specific to least specific for correct type resolution +This should only include one entry for each type +This should only include one instance of a pathTemplate +A locale can be ignored by setting it's value to an empty string +Keys in routesTemplate should ideally match lowercased __typename of graphql api types to allow them to be passed directly to the link resolver +*/ +export const routesTemplate = { + organizationnewsoverview: { + is: '/s/[organization]/frett', + en: '/en/o/[organization]/news', + }, + organizationeventoverview: { + is: '/s/[organization]/vidburdir', + en: '/en/o/[organization]/events', + }, + aboutsubpage: { + is: '/s/stafraent-island/[slug]', + en: '', + }, + applications: { + is: '/yfirlit-umsokna', + en: '/en/applications-overview', + }, + page: { + is: '/stafraent-island', + en: '', + }, + search: { + is: '/leit', + en: '/en/search', + }, + articlecategories: { + is: '/flokkur', + en: '/en/category', + }, + articlecategory: { + is: '/flokkur/[slug]', + en: '/en/category/[slug]', + }, + articlegroup: { + is: '/flokkur/[slug]#[subgroupSlug]', + en: '/en/category/[slug]#[subgroupSlug]', + }, + news: { + is: '/frett/[slug]', + en: '/en/news/[slug]', + }, + newsoverview: { + is: '/frett', + en: '/en/news', + }, + manual: { + is: '/handbaekur/[slug]', + en: '/en/manuals/[slug]', + }, + manualchangelog: { + is: '/handbaekur/[slug]/breytingasaga', + en: '/en/manuals/[slug]/changelog', + }, + manualchapteritem: { + is: '/handbaekur/[slug]/[chapterSlug]?selectedItemId=[chapterItemId]', + en: '/en/manuals/[slug]/[chapterSlug]?selectedItemId=[chapterItemId]', + }, + manualchapter: { + is: '/handbaekur/[slug]/[chapterSlug]', + en: '/en/manuals/[slug]/[chapterSlug]', + }, + vacancies: { + is: '/starfatorg', + en: '', + }, + vacancydetails: { + is: '/starfatorg/[id]', + en: '', + }, + pensioncalculatorresults: { + is: '/s/tryggingastofnun/reiknivel/nidurstodur', + en: '/en/o/social-insurance-administration/calculator/results', + }, + pensioncalculator: { + is: '/s/tryggingastofnun/reiknivel', + en: '/en/o/social-insurance-administration/calculator', + }, + directorateoflabourmypages: { + is: '/s/vinnumalastofnun/minar-sidur', + en: '/en/o/directorate-of-labour/my-pages', + }, + digitalicelandservices: { + is: '/s/stafraent-island/thjonustur', + en: '/en/o/digital-iceland/island-services', + }, + digitalicelandservicesdetailpage: { + is: '/s/stafraent-island/thjonustur/[slug]', + en: '/en/o/digital-iceland/island-services/[slug]', + }, + digitalicelandcommunityoverview: { + is: '/s/stafraent-island/island-is-samfelagid', + en: '/en/o/digital-iceland/island-is-community', + }, + digitalicelandcommunitydetailpage: { + is: '/s/stafraent-island/island-is-samfelagid/[slug]', + en: '/en/o/digital-iceland/island-is-community/[slug]', + }, + organizationservices: { + is: '/s/[slug]/thjonusta', + en: '/en/o/[slug]/services', + }, + organizationpublishedmaterial: { + is: '/s/[slug]/utgefid-efni', + en: '/en/o/[slug]/published-material', + }, + auctions: { + is: '/s/syslumenn/uppbod', + en: '', + }, + apicataloguedetailpage: { + is: '/s/stafraent-island/vefthjonustur/[slug]', + en: '/en/o/digital-iceland/webservices/[slug]', + }, + apicataloguepage: { + is: '/s/stafraent-island/vefthjonustur', + en: '/en/o/digital-iceland/webservices', + }, + organizationnews: { + is: '/s/[organization]/frett/[slug]', + en: '/en/o/[organization]/news/[slug]', + }, + organizationevent: { + is: '/s/[organization]/vidburdir/[slug]', + en: '/en/o/[organization]/events/[slug]', + }, + organizationsubpage: { + is: '/s/[slug]/[subSlug]', + en: '/en/o/[slug]/[subSlug]', + }, + organizationpage: { + is: '/s/[slug]', + en: '/en/o/[slug]', + }, + blooddonationrestrictionlist: { + is: '/s/blodbankinn/ahrif-a-blodgjof', + en: '/en/o/icelandic-blood-bank/affecting-factors', + }, + blooddonationrestrictiondetails: { + is: '/s/blodbankinn/ahrif-a-blodgjof/[id]', + en: '/en/o/icelandic-blood-bank/affecting-factors/[id]', + }, + organizationparentsubpagechild: { + is: '/s/[slug]/[subSlug]/[childSlug]', + en: '/en/o/[slug]/[subSlug]/[childSlug]', + }, + grantsplaza: { + is: '/styrkjatorg', + en: '/en/grants-plaza', + }, + grantsplazasearch: { + is: '/styrkjatorg/styrkir', + en: '/en/grants-plaza/grants', + }, + grantsplazagrant: { + is: '/styrkjatorg/styrkur/[id]', + en: '/en/grants-plaza/grant/[id]', + }, + organizations: { + is: '/s', + en: '/en/o', + }, + opendatapage: { + is: '/gagnatorg', + en: '/en/gagnatorg', + }, + opendatasubpage: { + is: '/gagnatorg/[slug]', + en: '/en/gagnatorg/[slug]', + }, + projectnews: { + is: '/v/[slug]/frett/[subSlug]', + en: '/en/p/[slug]/news/[subSlug]', + }, + projectnewsoverview: { + is: '/v/[slug]/frett', + en: '/en/p/[slug]/news', + }, + projectsubpage: { + is: '/v/[slug]/[subSlug]', + en: '/en/p/[slug]/[subSlug]', + }, + projectpage: { + is: '/v/[slug]', + en: '/en/p/[slug]', + }, + organizationsubpagelistitem: { + is: '/s/[organization]/[slug]/[listItemSlug]', + en: '/en/o/[organization]/[slug]/[listItemSlug]', + }, + projectsubpagelistitem: { + is: '/v/[project]/[slug]/[listItemSlug]', + en: '/en/p/[project]/[slug]/[listItemSlug]', + }, + lifeevents: { + is: '/lifsvidburdir', + en: '/en/life-events', + }, + lifeeventpage: { + is: '/lifsvidburdir/[slug]', + en: '/en/life-events/[slug]', + }, + regulation: { + is: '/reglugerdir/nr/[number]', + en: '', + }, + regulationshome: { + is: '/reglugerdir', + en: '', + }, + ojoiadvert: { + is: '/stjornartidindi/nr/[number]', + en: '', + }, + ojoisearch: { + is: '/stjornartidindi/leit', + en: '', + }, + ojoicategories: { + is: '/stjornartidindi/malaflokkar', + en: '', + }, + ojoirss: { + is: '/stjornartidindi/rss', + en: '', + }, + ojoihome: { + is: '/stjornartidindi', + en: '', + }, + ojoiabout: { + is: '/stjornartidindi/um', + en: '', + }, + ojoihelp: { + is: '/stjornartidindi/leidbeiningar', + en: '', + }, + login: { + is: '/innskraning', + en: '/en/login', + }, + serviceweb: { + is: '/adstod', + en: '/en/help', + }, + servicewebsearch: { + is: '/adstod/leit', + en: '/en/help/search', + }, + serviceweborganization: { + is: '/adstod/[slug]', + en: '/en/help/[slug]', + }, + servicewebcontact: { + is: '/adstod/[organizationSlug]/hafa-samband', + en: '/en/help/[organizationSlug]/contact-us', + }, + serviceweborganizationsearch: { + is: '/adstod/[organizationSlug]/leit', + en: '/en/help/[organizationSlug]/search', + }, + supportcategory: { + is: '/adstod/[organizationSlug]/[categorySlug]', + en: '/en/help/[organizationSlug]/[categorySlug]', + }, + supportqna: { + is: '/adstod/[organizationSlug]/[categorySlug]/[questionSlug]', + en: '/en/help/[organizationSlug]/[categorySlug]/[questionSlug]', + }, + subarticle: { + is: '/[slug]/[subSlug]', + en: '/en/[slug]/[subSlug]', + }, + article: { + is: '/[slug]', + en: '/en/[slug]', + }, + universitysearchdetails: { + is: '/haskolanam/[id]', + en: '/en/university-studies/[id]', + }, + universitysearchcomparison: { + is: '/haskolanam/samanburdur', + en: '/en/university-studies/comparison', + }, + universitysearch: { + is: '/haskolanam/leit', + en: '/en/university-studies/search', + }, + universitysub: { + is: '/haskolanam/upplysingar/[subSlug]', + en: '/en/university-studies/info/[subSlug]', + }, + universitylandingpage: { + is: '/haskolanam', + en: '/en/university-studies', + }, + oskalistithjodarinnar: { + is: '/oskalisti-thjodarinnar', + en: '', + }, + homepage: { + is: '/', + en: '/en', + }, + undirskriftalistar: { + is: '/undirskriftalistar', + en: '/en/petitions', + }, +} + +// This considers one block ("[someVar]") to be one variable and ignores the path variables name +export const replaceVariableInPath = ( + path: string, + replacement: string, +): string => { + return path.replace(/\[\w+\]/, replacement) +} + +// converts a path template to a regex query for matching +export const convertToRegex = (routeTemplate: string) => { + const query = routeTemplate + .replace(/\//g, '\\/') // escape slashes to match literal "/" in route template + .replace(/\[\w+\]/g, '[-\\w]+') // make path variables be regex word matches + return `^${query}$` // to prevent partial matches +} + +// extracts slugs from given path +export const extractSlugsByRouteTemplate = ( + path: string, + template: string, +): string[] => { + const pathParts = path.split('/') + const templateParts = template.split('/') + + return pathParts.filter((_, index) => { + return templateParts[index]?.startsWith('[') ?? false + }) +} + +/** Check if path is of link type */ +export const pathIsRoute = ( + path: string, + linkType: LinkType, + locale?: Locale, +) => { + const segments = path.split('/').filter((x) => x) + + const localeSegment = '' + const firstSegment = (localeSegment ? segments[1] : segments[0]) ?? '' + + const current = `/${ + localeSegment ? localeSegment + '/' : '' + }${firstSegment}`.replace(/\/$/, '') + + return current === linkResolver(linkType, [], locale).href +} + +/* +Finds the correct path for a given type and locale. +Returns /404 if no path is found +*/ +export const linkResolver = ( + linkType: LinkResolverInput['linkType'], + variables: LinkResolverInput['variables'] = [], + locale: LinkResolverInput['locale'] = defaultLanguage, +): LinkResolverResponse => { + /* + We lowercase here to allow components to pass unmodified __typename fields + The __typename fields seem to have case issues, that will be addressed at a later time + We also guard against accidental passing of nully values. ?? + */ + const type = linkType?.toLowerCase() as + | LinkResolverInput['linkType'] + | undefined + | null + + // special case for external url resolution + if (type === 'linkurl') { + return { + href: variables[0], + } + } + + // special case when link with slug is passed directly to the linkresolver + if (type === 'link') { + return { + href: variables.join('/'), + } + } + + // We consider path not found if it has no entry in routesTemplate or if the found path is empty + if (type && routesTemplate[type] && routesTemplate[type][locale]) { + const typePath = routesTemplate[type][locale] + + if (variables.length) { + // populate path templates with variables + return { + href: variables.reduce( + (path, slug) => replaceVariableInPath(path, slug), + typePath, + ), + } + } else { + // there are no variables, return path template as path + return { + href: typePath, + } + } + } else { + // we return to 404 page if no path is found, if this happens we have a bug + return { + href: '/404', + } + } +} + +/* +The type resolver returns a best guess type for a given path +This should be reliable as long as we are able to arrange routesTemplate in a uniquely resolvable order +*/ +export const typeResolver = ( + path: string, + skipDynamic = false, +): TypeResolverResponse | null => { + for (const [type, locales] of Object.entries(routesTemplate)) { + for (const [locale, routeTemplate] of Object.entries(locales)) { + // we are skipping all route types that have path variables and all locales that have no path templates + if ((skipDynamic && routeTemplate.includes('[')) || !routeTemplate) { + continue + } + + // handle homepage en path + if (path === '/en') { + return { type: 'homepage', locale: 'en', slug: [] } + } + + // convert the route template string into a regex query + const regex = convertToRegex(routeTemplate) + + // if the path starts with the routeTemplate string or matches dynamic route regex we have found the type + if ( + (!skipDynamic && path?.match(regex)) || + (skipDynamic && path?.startsWith(routeTemplate)) + ) { + return { + slug: extractSlugsByRouteTemplate(path, routeTemplate), + type, + locale, + } as TypeResolverResponse + } + } + } + + return null +} + +/* +The link resolver hook handles locale automatically +this allows components to be language agnostic +*/ +export const useLinkResolver = () => { + const wrappedLinkResolver = ( + linkType: LinkResolverInput['linkType'], + variables: LinkResolverInput['variables'] = [], + ) => linkResolver(linkType, variables) + return { + typeResolver, + linkResolver: wrappedLinkResolver, + } +} diff --git a/apps/tax/hooks/useNamespace.spec.ts b/apps/tax/hooks/useNamespace.spec.ts new file mode 100644 index 00000000..64c9ae0c --- /dev/null +++ b/apps/tax/hooks/useNamespace.spec.ts @@ -0,0 +1,94 @@ +import { useNamespace, useNamespaceStrict } from './useNamespace' + +type Messages = { + title: 'My Title' + explicitlyEmpty: '' + maybeNully?: 'Optional' + nully: null +} + +const messages: Messages = { + title: 'My Title', + explicitlyEmpty: '', + nully: null, +} + +describe('useNamespaceStrict', () => { + const n = useNamespaceStrict(messages) + { + /* eslint-disable @typescript-eslint/no-unused-vars */ + // Quick function signature testing + const fooTyped1: 'My Title' = n('title') + const fooTyped2: 'nully' = n('nully') + const fooTyped3: 'nully' = n('nully', null) + const fooTyped4: 'hello' = n('nully', 'hello') + let fooTyped5 = n('maybeNully') // fooTyped5 should be of type `"Optional" | "maybeNully"` + fooTyped5 = 'Optional' // Allowed + fooTyped5 = 'maybeNully' // This assignment should also be allowed + // @ts-expect-error (proof it doesn't return any) + const fooErr1: RegExp = n('title') + /* eslint-enable */ + } + + it('should return a string value', () => { + expect(n('title')).toEqual('My Title') + expect(n('title', 'Ignored fallback')).toEqual('My Title') + }) + it('should return a explicitly empty string values', () => { + expect(n('explicitlyEmpty')).toEqual('') + expect(n('explicitlyEmpty', 'Ignored fallback')).toEqual('') + }) + + it('should return the key for defined but nully values', () => { + expect(n('nully')).toEqual('nully') + expect(n('nully', undefined)).toEqual('nully') + }) + it('should return non-nully fallback value for defined but nully values', () => { + expect(n('nully', 'Fallback text')).toEqual('Fallback text') + }) + + // Test handling of "invalid/unknown" keys + // @ts-expect-error (testing bad input) + const invalidInput: keyof typeof messages = 'not_a_key' + + it('should return the key for unknown keys', () => { + expect(n(invalidInput)).toEqual(invalidInput) + expect(n(invalidInput, undefined)).toEqual(invalidInput) + }) + it('should return non-nully fallback value for unknown keys', () => { + expect(n(invalidInput, 'Fallback text')).toEqual('Fallback text') + }) +}) + +describe('useNamespace', () => { + const n = useNamespace(messages) + n('asdfasdf') + n('asdfasdf', null) + n('asdfasdf', 'yo') + + it('should return a string value', () => { + expect(n('title')).toEqual('My Title') + expect(n('title', 'Ignored fallback')).toEqual('My Title') + }) + it('should return a explicitly empty string values', () => { + expect(n('explicitlyEmpty')).toEqual('') + expect(n('explicitlyEmpty', 'Ignored fallback')).toEqual('') + }) + + it('should return the key for defined but nully values', () => { + expect(n('nully')).toEqual('nully') + expect(n('nully', undefined)).toEqual('nully') + }) + it('should return non-nully fallback value for defined but nully values', () => { + expect(n('nully', 'Fallback text')).toEqual('Fallback text') + }) + + // Test handling of "invalid/unknown" keys + it('should return the key for unknown keys', () => { + expect(n('not_a_key')).toEqual('not_a_key') + expect(n('not_a_key', undefined)).toEqual('not_a_key') + }) + it('should return non-nully fallback value for unknown keys', () => { + expect(n('not_a_key', 'Fallback text')).toEqual('Fallback text') + }) +}) diff --git a/apps/tax/hooks/useNamespace.ts b/apps/tax/hooks/useNamespace.ts new file mode 100644 index 00000000..ec5cf444 --- /dev/null +++ b/apps/tax/hooks/useNamespace.ts @@ -0,0 +1,43 @@ +// type Messages = { [key: string]: string } +type Nully = undefined | null +type NamespaceValues = string | ReadonlyArray +export type NamespaceMessages = Readonly< + Record +> + +type RetVal = Value extends Nully + ? Fallback + : Value extends Exclude + ? Value + : Exclude | Fallback + +export type NamespaceGetter = { + (key: K, fallback?: Nully): RetVal + (key: K, fallback: F): RetVal< + M[K], + F + > + /** @deprecated + * Sloppy fallback version (based on the assumption that this is somehow desirable/practical) + */ + (key: string, fallback?: any): any +} + +// NOTE: Typesafe/clever/strict signature to provide nice auto-complete for cases +// where `namespace`'s type is fully described +export function useNamespaceStrict( + namespace: M = {} as M, +): NamespaceGetter { + return (key: string, fallback?: any) => { + return namespace[key] ?? fallback ?? key + } +} + +// NOTE: Dumb signature that accepts arbitrary `key` values and returns any +// (The "clever" signature above is entirely opt-in) +// TODO: mark this as @deprecated +export function useNamespace(namespace = {}) { + return (key: string, fallback?: any) => { + return namespace[key as keyof typeof namespace] ?? fallback ?? key + } +} diff --git a/apps/tax/hooks/useScrollPosition/index.ts b/apps/tax/hooks/useScrollPosition/index.ts new file mode 100644 index 00000000..510bb26a --- /dev/null +++ b/apps/tax/hooks/useScrollPosition/index.ts @@ -0,0 +1 @@ +export { useScrollPosition } from './useScrollPosition' diff --git a/apps/tax/hooks/useScrollPosition/useIsomorphicLayoutEffect.ts b/apps/tax/hooks/useScrollPosition/useIsomorphicLayoutEffect.ts new file mode 100644 index 00000000..49ca31f3 --- /dev/null +++ b/apps/tax/hooks/useScrollPosition/useIsomorphicLayoutEffect.ts @@ -0,0 +1,4 @@ +import { useEffect, useLayoutEffect } from 'react' + +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect diff --git a/apps/tax/hooks/useScrollPosition/useScrollPosition.ts b/apps/tax/hooks/useScrollPosition/useScrollPosition.ts new file mode 100644 index 00000000..3cbb11a0 --- /dev/null +++ b/apps/tax/hooks/useScrollPosition/useScrollPosition.ts @@ -0,0 +1,79 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { DependencyList, MutableRefObject, useRef } from 'react' + +import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' + +interface ScrollProps { + prevPos: { + x: number + y: number + } + currPos: { + x: number + y: number + } +} + +interface GetScrollPositionProps { + element?: MutableRefObject + useWindow?: boolean +} + +const isBrowser = typeof window !== `undefined` + +const getScrollPosition = ({ element, useWindow }: GetScrollPositionProps) => { + if (!isBrowser) return { x: 0, y: 0 } + + const target = element?.current || document.body + const position = target.getBoundingClientRect() + + return useWindow + ? { x: window.scrollX, y: window.scrollY } + : { x: position.left, y: position.top } +} + +export const useScrollPosition = ( + effect: (props: ScrollProps) => void, + deps?: DependencyList, + element?: MutableRefObject, + useWindow?: boolean, + wait?: number, +) => { + const position = useRef(getScrollPosition({ useWindow })) + + // ReturnType because of incompatible setTimeout signatures of Node and the browser. + // (Tests are run in browser mode) + let throttleTimeout: ReturnType | null = null + + const callBack = () => { + const currPos = getScrollPosition({ element, useWindow }) + effect({ prevPos: position.current, currPos }) + position.current = currPos + throttleTimeout = null + } + + useIsomorphicLayoutEffect(() => { + if (!isBrowser) { + return + } + + const handleScroll = () => { + if (wait) { + if (throttleTimeout === null) { + throttleTimeout = setTimeout(callBack, wait) + } + } else { + callBack() + } + } + + window.addEventListener('scroll', handleScroll) + + return () => { + window.removeEventListener('scroll', handleScroll) + throttleTimeout && clearTimeout(throttleTimeout) + } + }, deps) +} + +export default useScrollPosition diff --git a/apps/tax/layouts/Illustration.tsx b/apps/tax/layouts/Illustration.tsx new file mode 100644 index 00000000..b23e955f --- /dev/null +++ b/apps/tax/layouts/Illustration.tsx @@ -0,0 +1,278 @@ +import * as React from 'react' + +function SvgComponent( + props: React.JSX.IntrinsicAttributes & React.SVGProps, +) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default SvgComponent diff --git a/apps/tax/layouts/main.css.ts b/apps/tax/layouts/main.css.ts new file mode 100644 index 00000000..4ef332f6 --- /dev/null +++ b/apps/tax/layouts/main.css.ts @@ -0,0 +1,12 @@ +import { style } from '@vanilla-extract/css' + +export const illustration = style({ + position: 'relative', + display: 'block', + margin: '0 auto', + height: 'auto', + width: '100%', + marginBottom: '-1.8%', + paddingTop: 120, + maxWidth: 1116, +}) diff --git a/apps/tax/layouts/main.tsx b/apps/tax/layouts/main.tsx new file mode 100644 index 00000000..14530435 --- /dev/null +++ b/apps/tax/layouts/main.tsx @@ -0,0 +1,655 @@ +import React, { useEffect, useState } from 'react' +import Cookies from 'js-cookie' +import getConfig from 'next/config' +import Head from 'next/head' +import { useRouter } from 'next/router' + +import { + AlertBanner, + AlertBannerVariants, + Box, + ButtonTypes, + ColorSchemeContext, + ColorSchemes, + Footer, + FooterLinkProps, + Hidden, + Page, +} from '@island.is/island-ui/core' +import { CACHE_CONTROL_HEADER } from '@island.is/shared/constants' +import { Locale } from '@island.is/shared/types' +import { stringHash } from '@island.is/shared/utils' +import { + Header, + Main, + MobileAppBanner, + PageLoader, + SkipToMainContent, +} from '@island.is/tax/components' +import { userMonitoring } from '@island.is/user-monitoring' + +import { PRELOADED_FONTS } from '../constants' +import { GlobalContextProvider } from '../context' +import { MenuTabsContext } from '../context/MenuTabsContext/MenuTabsContext' +import { + ContentLanguage, + GetAlertBannerQuery, + GetArticleCategoriesQuery, + GetGroupedMenuQuery, + GetNamespaceQuery, + GetOrganizationPageQuery, + GetSingleArticleQuery, + Menu, + QueryGetAlertBannerArgs, + QueryGetArticleCategoriesArgs, + QueryGetGroupedMenuArgs, + QueryGetNamespaceArgs, +} from '../graphql/schema' +import { useNamespace } from '../hooks' +import { + linkResolver as LinkResolver, + LinkType, + pathIsRoute, + useLinkResolver, +} from '../hooks/useLinkResolver' +import { Screen, ScreenContext } from '../types' +import { extractOrganizationSlugFromPathname } from '../utils/organization' +import { + formatMegaMenuCategoryLinks, + formatMegaMenuLinks, +} from '../utils/processMenuData' +import Illustration from './Illustration' +import * as styles from './main.css' + +const { publicRuntimeConfig = {} } = getConfig() ?? {} + +const IS_MOCK = + process.env.NODE_ENV !== 'production' && process.env.API_MOCKS === 'true' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error make web strict +const absoluteUrl = (req, setLocalhost) => { + let protocol = 'https:' + let host = req + ? req.headers['x-forwarded-host'] || req.headers['host'] + : window.location.host + if (host.indexOf('localhost') > -1) { + if (setLocalhost) host = setLocalhost + protocol = 'http:' + } + return { + protocol: protocol, + host: host, + origin: protocol + '//' + host, + } +} + +export interface LayoutProps { + showSearchInHeader?: boolean + wrapContent?: boolean + showHeader?: boolean + headerColorScheme?: ColorSchemes + headerButtonColorScheme?: ButtonTypes['colorScheme'] + showFooter?: boolean + showFooterIllustration?: boolean + categories: GetArticleCategoriesQuery['getArticleCategories'] + topMenuCustomLinks?: FooterLinkProps[] + footerUpperInfo?: FooterLinkProps[] + footerUpperContact?: FooterLinkProps[] + footerLowerMenu?: FooterLinkProps[] + footerMiddleMenu?: FooterLinkProps[] + footerTagsMenu?: FooterLinkProps[] + namespace: Record + alertBannerContent?: GetAlertBannerQuery['getAlertBanner'] + organizationAlertBannerContent?: GetAlertBannerQuery['getAlertBanner'] + articleAlertBannerContent?: GetAlertBannerQuery['getAlertBanner'] + customAlertBannerContent?: GetAlertBannerQuery['getAlertBanner'] + languageToggleQueryParams?: Record> + footerVersion?: 'default' | 'organization' + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + respOrigin + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + megaMenuData + customTopLoginButtonItem: LayoutComponentProps['customTopLoginButtonItem'] + children?: React.ReactNode +} + +if (publicRuntimeConfig.ddLogsClientToken && typeof window !== 'undefined') { + userMonitoring.initDdLogs({ + service: 'islandis', + clientToken: publicRuntimeConfig.ddLogsClientToken, + env: publicRuntimeConfig.environment || 'local', + version: publicRuntimeConfig.appVersion || 'local', + }) +} +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +const Layout: Screen = ({ + showSearchInHeader = true, + wrapContent = true, + showHeader = true, + headerColorScheme, + headerButtonColorScheme, + showFooter = true, + showFooterIllustration = false, + categories, + topMenuCustomLinks, + footerUpperInfo, + footerUpperContact, + footerLowerMenu, + footerMiddleMenu, + namespace, + alertBannerContent, + organizationAlertBannerContent, + articleAlertBannerContent, + customAlertBannerContent, + languageToggleQueryParams, + footerVersion = 'default', + respOrigin, + children, + megaMenuData, + customTopLoginButtonItem, +}) => { + const { linkResolver } = useLinkResolver() + const n = useNamespace(namespace) + const router = useRouter() + const fullUrl = `${respOrigin}${router.asPath}` + + const menuTabs = [ + { + title: 'title', + externalLinksHeading: 'test', + links: categories.map((x) => { + return { + title: x.title, + href: linkResolver(x.__typename as LinkType, [x.slug]).href, + } + }), + }, + { + title: 'title', + links: topMenuCustomLinks, + externalLinksHeading: 'externalSite', + externalLinks: footerLowerMenu, + }, + ] + + const [alertBanners, setAlertBanners] = useState([]) + + useEffect(() => { + setAlertBanners( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + [ + { + bannerId: `alert-${stringHash( + JSON.stringify(alertBannerContent ?? {}), + )}`, + ...alertBannerContent, + }, + { + bannerId: `organization-alert-${stringHash( + JSON.stringify(organizationAlertBannerContent ?? {}), + )}`, + ...organizationAlertBannerContent, + }, + { + bannerId: `article-alert-${stringHash( + JSON.stringify(articleAlertBannerContent ?? {}), + )}`, + ...articleAlertBannerContent, + }, + { + bannerId: `custom-alert-${stringHash( + JSON.stringify(customAlertBannerContent ?? {}), + )}`, + ...customAlertBannerContent, + }, + ].filter( + (banner) => !Cookies.get(banner.bannerId) && banner?.showAlertBanner, + ), + ) + }, [ + alertBannerContent, + articleAlertBannerContent, + organizationAlertBannerContent, + customAlertBannerContent, + ]) + + const isServiceWeb = pathIsRoute(router.asPath, 'serviceweb') + + return ( + + + + {PRELOADED_FONTS.map((href, index) => { + return ( + + ) + })} + + + + + + + {n('title')} + + + + + + + + + + + + + + + + + {alertBanners.map((banner) => ( + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + if (banner.dismissedForDays !== 0) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + Cookies.set(banner.bannerId, 'hide', { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error make web strict + expires: banner.dismissedForDays, + }) + } + }} + closeButtonLabel={'Close'} + /> + ))} + + + + + + {showHeader && ( + +
+ + )} +
+ {wrapContent ? ( + + {children} + + ) : ( + children + )} +
+ + {showFooter && ( + + {footerVersion === 'default' && ( + <> + {showFooterIllustration && ( + + )} +