diff --git a/.gitignore b/.gitignore index 43724026..fbb5a34f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # production /build +/build_auth # misc .DS_Store diff --git a/Makefile b/Makefile index 6af6f3b9..38e4a324 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,16 @@ preproduction-build: clean production-build: clean REACT_APP_ENV=production REACT_APP_CLIENT=instat REACT_APP_STRIPE_PK=pk_live_ANI76cBhSo69DZUxPmyRVIZW npm run build +auth-build: + rm -rf build_auth + REACT_APP_ENV=staging BUILD_PATH=build_auth GENERATE_SOURCEMAP=false npm run build:auth + npx gzipper --verbose build_auth + +auth-production-build: + rm -rf build_auth + REACT_APP_ENV=production BUILD_PATH=build_auth GENERATE_SOURCEMAP=false npm run build:auth + npx gzipper --verbose build_auth + facr-build: clean REACT_APP_ENV=staging REACT_APP_CLIENT=facr npm run build @@ -35,6 +45,7 @@ preprod: preproduction-build facr-prod: facr-production-build rsync -zavP build/ -e 'ssh -p 666' ott@de.instat.tv:/usr/local/www/ott/facr-wwwroot/ rsync -zavP build/ -e 'ssh -p 666' ott@fr.instat.tv:/usr/local/www/ott/facr-wwwroot/ + rsync -zavP build/ -e 'ssh -p 666' ott@bkup.instat.tv:/usr/local/www/ott/facr-wwwroot/ stage: build rsync -zavP build/ -e 'ssh -p 666' ott-staging@staging.instat.tv:/usr/local/www/ott-staging/wwwroot/ diff --git a/package.json b/package.json index c1b3c7f0..d9ac5482 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "lint": "eslint \"src/**/*.{ts,tsx}\"", "stylelint": "stylelint \"src/**/*.{ts,tsx}\"", "storybook": "start-storybook -p 9009 -s public", - "build-storybook": "build-storybook -s public" + "build-storybook": "build-storybook -s public", + "start:auth": "REACT_APP_TYPE=auth-service react-scripts start", + "build:auth": "REACT_APP_TYPE=auth-service react-scripts build", + "test:auth": "REACT_APP_TYPE=auth-service react-scripts test" }, "dependencies": { "@stripe/react-stripe-js": "^1.4.0", diff --git a/public/clients/facr/desc/index.js b/public/clients/facr/desc/index.js deleted file mode 100644 index 94ceaf79..00000000 --- a/public/clients/facr/desc/index.js +++ /dev/null @@ -1,2 +0,0 @@ -const title = 'FACR.TV - The home of Czech football streaming' -const desc = 'Live sports streaming platform. All matches playing under the auspices of Czech Republic FA. Access to full matches, various player playlists, and highlights. Free access in the Czech Republic. Available across all devices' diff --git a/public/clients/instat/desc/index.js b/public/clients/instat/desc/index.js deleted file mode 100644 index 2e617e79..00000000 --- a/public/clients/instat/desc/index.js +++ /dev/null @@ -1,2 +0,0 @@ -const title = 'InStat TV - The Home of Sports Streaming' -const desc = 'Live sports streaming platform. Football, basketball, ice hockey and more. Access to various player playlists and game highlights. Multiple subscription options. Available across all devices.' diff --git a/public/images/facr_auth_logo.png b/public/images/facr_auth_logo.png new file mode 100644 index 00000000..56fa0150 Binary files /dev/null and b/public/images/facr_auth_logo.png differ diff --git a/public/index.html b/public/index.html index 56d9d6d5..8cc210f9 100644 --- a/public/index.html +++ b/public/index.html @@ -2,46 +2,42 @@ - - - - - - + - - - + <% if (process.env.REACT_APP_TYPE === 'ott') { %> + + + + + + <% } %> diff --git a/public/terms-and-conditions.html b/public/terms-and-conditions.html new file mode 100644 index 00000000..e66f9024 --- /dev/null +++ b/public/terms-and-conditions.html @@ -0,0 +1,1147 @@ + + + + +
+

+
+
+
+

+ +

+ Privacy Policy and Statement +

+ +

+ In accordance with Data Protection and Privacy law +

+ +

+ InStat Limited as a data controller +

+ +

+ InStat is committed to the privacy of those that we engage with + and this statements details our approach. While using this site or + providing personal data to us in the course of business, we will + manage your data in accordance with this privacy statement. +

+ + + +

+ InStat provides an online platform that allows to broadcast sports + video content. This is a legitimate interest pursued by InStat. + Personal data processed by the business is done so in accordance with + current Data Protection Regulation and GDPR. +

+ +

+ Personal Data and Collection +

+ +

+ We may obtain personal data including name, address, phone numbers, + e-mail address, other electronic identifiers, title, images, IP + address, company details, information about usage of InStat services + (e.g., the types of content that you engage with), device information, + transaction information including a credit card number and other + authentication information (only when the transaction on our products + is made) and other information provided by you or by other people when + they use our products in accordance with this policy and with data + protection law. We may also obtain similar information from other + sources such as club, league or broadcast game footage, or from your + use of InStat services, or when you sign up to our services, or attend + events or otherwise engage with the business. +

+ +

+ Purpose of Use +

+ +

+ We use personal data for the purposes for which it was provided to us + as expressed at the point of collection or as is obvious in the + context of collection. Typically, information is collected for the following purposes: +

+ + + +

+ Your data may also be used in the course of system maintenance; + system logs, diagnosis of issues on company systems and the company + web site, or for site optimisation procedures. +

+ +

+ Broadcasting services for rightholders +

+ +

+ Where we process or arrange for processing of personal data on your + behalf for the purposes of delivering services, including but not + limited to the InStat sports events broadcasting services, we shall + implement appropriate technical and organisational measures in such a + manner that processing shall meet the requirements of data protection + regulation. The manifestly public nature of spots fixtures may bring + the reporting of sporting performance metrics outside of normal + requirements, however personal data in all other regards is subject to + control in accordance with regulation. This includes but is not + limited to our commitment to; +

+ +
    +
  1. + Sign a specific contract with you for such processing that sets out + the nature, subject matter, duration, and description of purpose in + accordance with the Acts +
  2. +
  3. + Where we are operating as a processor only, to process data on + specific written instruction from you +
  4. +
  5. + Keep the data confidential and secure +
  6. +
  7. + Support you in the event of audits, inspections, Access Requests or + the provision of Data Protection Impact Assessment. +
  8. +
  9. + Delete or return data upon instruction +
  10. +
  11. + Assure compliance of third parties delivering service to us for the + purpose of processing your data +
  12. +
+ +

+ We shall comply at all times with the data protections principles of + the relevant Acts. +

+ +

+ Disclosure to third parties & international transfer +

+ +

+ We take all reasonable measures to protect your personal information + while it is in our possession. Your personal information may be + transferred to third party service providers who process information + on the InStat's behalf, including providers of information technology, + identity management, website hosting and management, network services, + data analysis, anti-spam services, data back-up, security, and storage + services. +

+ +

+ Your Personal data may also be transferred to joint sponsors of + events, or to certification bodies. We may also provide access to your + personal information to law enforcement authorities, revenue + commissioners, regulatory or other government agencies, or to other + third parties should we receive a valid request compatible with + applicable law or regulation. +

+ +

+ Personal data submitted through this site may be transferred to third + party service providers or to other companies within the InStat group + of companies that are outside of the state, and outside of the + European Economic Area (EEA). Where you request goods or services to + be provided outside the EEA (European Economic Area), or to be + delivered in conjunction with others outside of the EEA, personal data + provided by you may be shared with organisations or state bodies + (customs, revenue authorities and etc.) to fulfil an agreement. +

+ +

+ Responsibility of our customers +

+ +

+ Customers and other organisations engaging with us for service shall + warrant that personal information provided to us for the + administration and delivery of goods and services being provided under + the Agreement has been obtained fairly and lawfully. Such customers or + organisations shall also warrant that subjects are aware of the + purpose for which their personal data is being used and that such data + may be transferred outside of the EEA for processing or to deliver the + service or upon your request, and that the privacy rights of subjects + have been upheld. +

+ +

+ Confidentiality & security +

+ +

+ InStat have implemented generally accepted standards of technology + and operational security to protect personal data from alteration, + unauthorised disclosure or destruction, and from use for unauthorised + purposes. Furthermore, we have taken measures to ensure that contracts + with all third parties that provide technical and processing services + include terms that specify appropriate technical and organisational + security measures to prevent accidental, unauthorised or unlawful + disclosure or processing of personal data. +

+ +

+ Data Subject's Rights +

+ +

+ Individuals have rights to: +

+ + + +

+ You can contact us to exercise these rights by e-mail at privacy@instatsport.com. We will ask for additional information to verify your identity + prior to acting upon such requests. We may charge for an access + request in accordance with law. +

+ +

+ Removal from mailing lists +

+ +

+ You may unsubscribe from our mailing lists at any time by using the + ‘unsubscribe’ button on marketing communications, or by contacting us + at privacy@instatsport.com. +

+ +

+ Reporting of Data Breaches +

+ +

+ Where a data breach occurs that poses a risk to the subject it shall + be reported to the Data Protection Commissioner DPC without delay or + at least within 72 hours. Where such breach is likely to expose the + subject to high risk it will be reported to the subject. In any event, + all breached will be managed in accordance with Irish law and + GDPR +

+ +

+ Data Retention +

+ +

+ We retain personal data that you submit to us only for as long as is + necessary and for the purposes for which it was obtained, or as + required by law. +

+ +

+ Cookies +

+ +

+ We use cookies – small text files – which are placed on your hard + drives to provide a more intuitive website experience. Cookies are a + typical part of operating procedure for most websites and most + browsers permit users to opt-out of receiving them if the user would + prefer. This may reduce some of the functionality of the site. +

+ +

+ Cookies can be deleted from your system at any time. +

+ +

+ © InStatTV.com All right reserved. +

+ +

+ +35315136855
+ +442071932715 (Support)
+ +79152327860 (tech support 24/7)
+

+
+
+

+ *ERSTE LIGA ADDITIONAL NOTICE +

+

+ In the case of ERSTE LIGA TV channel, data is processed jointly in + accordance with EU Regulation 2016/679 (hereinafter GDPR). When + registering on the InStat site, after accepting this Privacy Policy + and Privacy Statement, the user provides the e-mail address required + to use the service, which is handed over to the Hungarian Ice Hockey + Federation (hereinafter referred to as HIHF), the rights holder of the + ERSTE LIGA TV channel, within the framework of joint data processing. + Details of the joint processing by the HIHF: + +

+

+ Purpose of data processing: the HIHF sends marketing, PR and sports + promotional content by electronic messages to users who accept this + Privacy Statement,  to the e-mail address they have provided. + +

+

+ Legal basis for processing: consent of the data subject. + +

+

+ The scope of the data processed: the e-mail address of the user + registered on instat.tv. + +

+

+ Duration of processing: until the data subject's consent is + withdrawn. + +

+

+ Method of processing: electronically, in compliance with the + necessary security standards. + +

+

+ Data subjects' rights: + +

+
    +
  1. + The data subject has the right to withdraw consent to data + processing at any time. Withdrawal of consent does not affect the + lawfulness of the prior processing. +
  2. +
  3. + The data subject may also exercise his or her right of access to + his or her personal data (right to request information about the + processing), the right to rectification of his or her personal data + (e.g. if his or her e-mail address changes), the right to object to + processing, the right to restriction of processing (e.g. if he or + she does not wish to receive e-mails for a certain period of time), + the right to erasure or blocking of his or her data, and the right + to data portability. + +
  4. +
  5. + If the data subject wishes to exercise his or her rights in + relation to the joint processing described in this paragraph, he or + she may do so by the means listed below: + +
  6. +

    + Name: Hungarian Ice Hockey Federation (hereinafter referred to as + the "Controller") + +

    +

    + Seat: H-1146 Budapest, Istvánmezei út 1-3. + +

    +

    + Postal address: H-1146 Budapest, Istvánmezei út 1-3. + +

    +

    + Represented by: Zsolt Levente Sipos, General Secretary + +

    +

    + Phone: +36 1 460 6863 + +

    +

    + Fax: +36 1 460 6864 + +

    +

    + E-mail: adatvedelem@icehockey.hu + +

    +
+

+ HIHF ERSTE LIGA TV Terms and Conditions of Use, including the Privacy + Statement, can be found at the following link: + "ersteligatv.hu/Felhasznalasi-feltetelek" + +

+
+ + diff --git a/src/config/clients/facr.tsx b/src/config/clients/facr.tsx index 6c8a40f8..1d3ca77e 100644 --- a/src/config/clients/facr.tsx +++ b/src/config/clients/facr.tsx @@ -2,7 +2,7 @@ import { css } from 'styled-components/macro' import { PROCEDURES } from '../procedures' -import type { ClientConfig } from './types' +import { ClientConfig, ClientIds } from './types' const randomHash = () => ( (Math.random() ** Math.random()) * 9999999999999999 @@ -14,10 +14,11 @@ const params = { export const facr: ClientConfig = { auth: { - clientId: 'facr-ott-web', + clientId: ClientIds.Facr, metaDataUrlParams: `?hash=${randomHash()}`, }, defaultLanguage: 'cs', + description: 'Live sports streaming platform. All matches playing under the auspices of Czech Republic FA. Access to full matches, various player playlists, and highlights. Free access in the Czech Republic. Available across all devices', disabledPreferences: true, requests: { [PROCEDURES.get_matches]: params, @@ -45,4 +46,5 @@ export const facr: ClientConfig = { userAccountLogoHeight: 3, userAccountLogoWidth: 3.5, }, + title: 'FACR.TV - The home of Czech football streaming', } diff --git a/src/config/clients/instat.tsx b/src/config/clients/instat.tsx index 9e960018..f35e2b95 100644 --- a/src/config/clients/instat.tsx +++ b/src/config/clients/instat.tsx @@ -1,9 +1,10 @@ -import type { ClientConfig } from './types' +import { ClientConfig, ClientIds } from './types' export const instat: ClientConfig = { auth: { - clientId: 'ott-web', + clientId: ClientIds.Instat, }, + description: 'Live sports streaming platform. Football, basketball, ice hockey and more. Access to various player playlists and game highlights. Multiple subscription options. Available across all devices.', showSearch: true, styles: { background: 'background-image: url(/images/Checker.png);', @@ -16,4 +17,5 @@ export const instat: ClientConfig = { userAccountLogoHeight: 1.465, userAccountLogoWidth: 6.37, }, + title: 'InStat TV - The Home of Sports Streaming', } diff --git a/src/config/clients/types.tsx b/src/config/clients/types.tsx index 5a6e9469..d98c78da 100644 --- a/src/config/clients/types.tsx +++ b/src/config/clients/types.tsx @@ -6,12 +6,18 @@ type ProcedureName = string type RequestParameters = any type StyledCss = ReturnType +export enum ClientIds { + Facr = 'facr-ott-web', + Instat = 'ott-web', +} + export type ClientConfig = { auth: { - clientId: string, + clientId: ClientIds, metaDataUrlParams?: string, }, defaultLanguage?: Languages, + description: string, disabledPreferences?: boolean, requests?: Record, showPoweredByLogo?: boolean, @@ -28,4 +34,5 @@ export type ClientConfig = { userAccountLogoHeight?: number, userAccountLogoWidth?: number, }, + title: string, } diff --git a/src/config/userAgent.tsx b/src/config/userAgent.tsx index 0a120543..b5576ab7 100644 --- a/src/config/userAgent.tsx +++ b/src/config/userAgent.tsx @@ -1,4 +1,4 @@ -import { includes } from 'lodash' +import includes from 'lodash/includes' export const isIphone = includes(window.navigator.userAgent, 'iPhone') diff --git a/src/features/App/index.tsx b/src/features/App/index.tsx index e94c981d..4ad816a6 100644 --- a/src/features/App/index.tsx +++ b/src/features/App/index.tsx @@ -1,7 +1,9 @@ import { Suspense } from 'react' import { Router } from 'react-router-dom' -import { history } from 'config' +import { history } from 'config/history' +import { client } from 'config/clients' +import { setClientTitleAndDescription } from 'helpers/setClientHeads' import { isMatchPage } from 'helpers/isMatchPage' @@ -14,6 +16,8 @@ import { JoinMatchPage } from 'features/JoinMatchPage' import { AuthenticatedApp } from './AuthenticatedApp' +setClientTitleAndDescription(client.title, client.description) + const Main = () => { const { loadingUser, user } = useAuthStore() @@ -27,7 +31,7 @@ const Main = () => { return } -export const App = () => ( +const OTTApp = () => ( @@ -41,3 +45,5 @@ export const App = () => ( ) + +export default OTTApp diff --git a/src/features/ArrowLoader/styled.tsx b/src/features/ArrowLoader/styled.tsx index b9f617ab..ae90adb3 100644 --- a/src/features/ArrowLoader/styled.tsx +++ b/src/features/ArrowLoader/styled.tsx @@ -13,7 +13,7 @@ const rotate = keyframes` } ` -export const Wrapper = styled.button` +export const Wrapper = styled.div` box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3); border-color: transparent; width: ${({ width = 'auto' }) => width}; diff --git a/src/features/AuthServiceApp/components/App/index.tsx b/src/features/AuthServiceApp/components/App/index.tsx new file mode 100644 index 00000000..dfb2dc8f --- /dev/null +++ b/src/features/AuthServiceApp/components/App/index.tsx @@ -0,0 +1,51 @@ +import { lazy } from 'react' + +import { + Redirect, + Route, + Switch, +} from 'react-router-dom' + +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' + +import { useLexicsConfig } from 'features/LexicsStore' + +import { PAGES } from '../../config/pages' +import { lexics } from '../../config/lexics' + +const Login = lazy(() => import('../Login')) +const Registration = lazy(() => import('../Registration')) + +const Main = styled.main` + width: 100%; + ${isMobileDevice + ? css` + padding: 0 12px; + @media screen and (orientation: landscape){ + min-height: 100vh; + } + ` + : ''}; +` + +export const App = () => { + useLexicsConfig(lexics) + + return ( +
+ + + + + + + + + + + +
+ ) +} diff --git a/src/features/Common/NewInput/index.tsx b/src/features/AuthServiceApp/components/Input/index.tsx similarity index 83% rename from src/features/Common/NewInput/index.tsx rename to src/features/AuthServiceApp/components/Input/index.tsx index 34cc2fd1..2ac62d89 100644 --- a/src/features/Common/NewInput/index.tsx +++ b/src/features/AuthServiceApp/components/Input/index.tsx @@ -9,7 +9,10 @@ import { } from './styled' type Props = Pick, ( + 'autoComplete' | + 'autoFocus' | 'className' | + 'name' | 'onBlur' | 'onChange' | 'onFocus' | @@ -22,9 +25,12 @@ type Props = Pick, ( rightContent?: ReactNode, } -export const NewInput = ({ +export const Input = ({ + autoComplete, + autoFocus, className, leftContent, + name, onBlur, onChange, onFocus, @@ -41,12 +47,15 @@ export const NewInput = ({ diff --git a/src/features/Common/NewInput/styled.tsx b/src/features/AuthServiceApp/components/Input/styled.tsx similarity index 71% rename from src/features/Common/NewInput/styled.tsx rename to src/features/AuthServiceApp/components/Input/styled.tsx index 4e93df87..f3149195 100644 --- a/src/features/Common/NewInput/styled.tsx +++ b/src/features/AuthServiceApp/components/Input/styled.tsx @@ -1,25 +1,22 @@ import styled, { css } from 'styled-components/macro' -import { devices } from 'config' +import { devices } from 'config/devices' import { isMobileDevice } from 'config/userAgent' +import { client } from 'features/AuthServiceApp/config/clients' + export const Wrapper = styled.div` background-color: ${({ theme }) => theme.colors.inputs}; width: 100%; - height: 2.123rem; - ${isMobileDevice - ? css` - height: 30px; - min-height: 30px; - position: relative; - ` - : ''}; + height: 45px; :not(:last-child) { border-bottom: 0.5px solid ${({ theme }) => theme.colors.black40}; } + ${client.styles.input} ` export const Label = styled.label` + position: relative; display: flex; align-items: center; height: 100%; @@ -30,10 +27,10 @@ export const Label = styled.label` letter-spacing: -0.01em; ${isMobileDevice ? css` - font-size: 12px; - ` + font-size: 12px; + ` : ''}; - + @media ${devices.tablet} { font-size: 1.6rem; } @@ -59,30 +56,29 @@ const resetStyles = css` export const InputStyled = styled.input` ${resetStyles} - padding: 0 0.755rem; + padding: 0 16px; flex-grow: 1; height: 100%; color: ${({ theme }) => theme.colors.text100}; + font-size: 16px; + letter-spacing: 0.1px; ::placeholder { font-weight: normal; width: 100%; - color: ${({ theme }) => theme.colors.text100}; + color: ${({ theme }) => theme.colors.text50}; font-style: normal; - font-size: 0.755rem; letter-spacing: 0.1px; - ${isMobileDevice - ? css` - font-size: 12.31px; - ` - : ''} } - ${isMobileDevice + + ${({ type }) => (type === 'password' ? css` - font-size: 12.31px; - padding: 0 30px 0 10px; + padding-right: 0; + font-weight: 700; + letter-spacing: 6px; ` - : ''}; - - font-weight: ${({ type }) => (type === 'password' ? 700 : 'normal')}; + : css` + font-weight: normal; + ` + )}; ` diff --git a/src/features/AuthServiceApp/components/LanguageSelect/hooks.tsx b/src/features/AuthServiceApp/components/LanguageSelect/hooks.tsx new file mode 100644 index 00000000..375df37f --- /dev/null +++ b/src/features/AuthServiceApp/components/LanguageSelect/hooks.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react' + +import find from 'lodash/find' + +import { langsList, Languages } from 'config/languages' +import { useToggle } from 'hooks/useToggle' + +import { useLexicsStore } from 'features/LexicsStore' + +export const useLanguageSelect = () => { + const { changeLang, lang } = useLexicsStore() + const { + close, + isOpen, + open, + } = useToggle() + + const handleLangChange = (locale: Languages) => () => { + changeLang(locale) + close() + } + + const selectedLang = useMemo(() => find(langsList, { locale: lang }), [lang]) + + return { + close, + handleLangChange, + isOpen, + open, + selectedLang, + } +} diff --git a/src/features/AuthServiceApp/components/LanguageSelect/index.tsx b/src/features/AuthServiceApp/components/LanguageSelect/index.tsx new file mode 100644 index 00000000..ba22b1e5 --- /dev/null +++ b/src/features/AuthServiceApp/components/LanguageSelect/index.tsx @@ -0,0 +1,70 @@ +import map from 'lodash/map' + +import { langsList } from 'config/languages' + +import { OutsideClick } from 'features/OutsideClick' + +import { useLanguageSelect } from './hooks' +import { + Wrapper, + Button, + ButtonTitle, + ListWrapper, + LangsList, + LangsItem, + FlagIcon, + LangName, +} from './styled' + +export const LanguageSelect = () => { + const { + close, + handleLangChange, + isOpen, + open, + selectedLang, + } = useLanguageSelect() + + return ( + + {selectedLang && ( + + )} + {isOpen && ( + + + + { + map( + langsList, + ({ + className, + locale, + title, + }) => ( + + + {title} + + ), + ) + } + + + + )} + + ) +} diff --git a/src/features/AuthServiceApp/components/LanguageSelect/styled.tsx b/src/features/AuthServiceApp/components/LanguageSelect/styled.tsx new file mode 100644 index 00000000..797d2755 --- /dev/null +++ b/src/features/AuthServiceApp/components/LanguageSelect/styled.tsx @@ -0,0 +1,147 @@ +import styled, { css } from 'styled-components/macro' + +import { langsList } from 'config/languages' +import { customScrollbar } from 'features/Common/customScrollbar' + +export const Wrapper = styled.div` + position: relative; + display: flex; + align-items: center; +` + +export const Button = styled.button` + padding: 0; + border: none; + background: none; + cursor: pointer; + + display: flex; + align-items: center; + width: 192px; + height: 44px; + padding-left: 20px; + + ::after { + content: ''; + position: absolute; + right: 2px; + bottom: 50%; + transform: rotate(45deg); + width: 9px; + height: 9px; + border-right: 2px solid; + border-bottom: 2px solid; + border-color: ${({ theme }) => theme.colors.text100}; + } +` + +export const ButtonTitle = styled.span` + padding-left: 10px; + font-size: 16px; + font-weight: normal; + letter-spacing: -0.32px; + color: ${({ theme }) => theme.colors.text100}; +` + +export const ListWrapper = styled.div` + width: 192px; + height: 220px; + z-index: 1; + position: absolute; + top: calc(100% - 4px); + left: 0; + padding: 10px; + background-color: #333333; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); + border-radius: 2px; +` + +export const LangsList = styled.ul` + display: flex; + height: 100%; + flex-wrap: wrap; + overflow-y: auto; + ${customScrollbar} + + ::-webkit-scrollbar { + width: 5px; + } + + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + } +` + +type LangsItemProps = { + selected: boolean, +} + +export const LangsItem = styled.li` + padding-left: 10px; + margin-right: 10px; + text-align: center; + display: flex; + align-items: center; + width: 100%; + height: 34px; + border-radius: 2px; + transition: 0.3s; + font-weight: normal; + font-size: 16px; + color: ${({ theme }) => theme.colors.text70}; + :hover { + background-color: #99999940; + cursor: pointer; + } + + ${({ selected, theme }) => ( + selected + ? css` + font-weight: 600; + color: ${theme.colors.text100}; + ` + : '' + )} +` + +export const LangName = styled.span` + padding-left: 10px; +` + +type LanguageFlags = typeof langsList[number]['className'] +type Position = { + col: number, + row: number, +} + +const flagPositions: Record = { + cz: { col: 1, row: 3 }, + de: { col: 5, row: 4 }, + es: { col: 12, row: 10 }, + fr: { col: 1, row: 4 }, + gb: { col: 4, row: 12 }, + it: { col: 8, row: 5 }, + pl: { col: 4, row: 9 }, + pt: { col: 5, row: 9 }, + ru: { col: 8, row: 9 }, + ua: { col: 2, row: 12 }, +} + +const getFlagPosition = (flag: LanguageFlags) => { + const { col, row } = flagPositions[flag] + return `${col * -24}px ${row * -16}px` +} + +type FlagIconProps = { + flag: LanguageFlags, +} + +export const FlagIcon = styled.span` + display: inline-block; + width: 20px; + height: 14px; + background-image: url('/images/flags-sprite.png'); + background-repeat: no-repeat; + background-size: 360px; + background-position: ${({ flag }) => getFlagPosition(flag)}; +` diff --git a/src/features/AuthServiceApp/components/Login/hooks.tsx b/src/features/AuthServiceApp/components/Login/hooks.tsx new file mode 100644 index 00000000..8ca1649f --- /dev/null +++ b/src/features/AuthServiceApp/components/Login/hooks.tsx @@ -0,0 +1,75 @@ +import type { FormEvent } from 'react' +import { + useRef, + useState, + useEffect, +} from 'react' + +import { addLanguageUrlParam } from 'helpers/languageUrlParam' + +import { loginCheck } from 'features/AuthServiceApp/requests/auth' +import { getApiUrl } from 'features/AuthServiceApp/config/routes' + +import { useLexicsStore } from 'features/LexicsStore' +import { useAuthFields } from 'features/AuthServiceApp/hooks/useAuthFields' + +const url = getApiUrl('/authorize') + +export const useLoginForm = () => { + const { lang } = useLexicsStore() + const [authError, setAuthError] = useState('') + const [isFetching, setIsFetching] = useState(false) + const formRef = useRef(null) + const { + email, + error: formError, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + password, + } = useAuthFields() + + const isSubmitDisabled = ( + !email + || !password + || Boolean(formError) + || Boolean(authError) + || isFetching + ) + + const submitForm = () => formRef.current?.submit() + + const handleError = (error: string) => { + setAuthError(error) + setIsFetching(false) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setIsFetching(true) + loginCheck(email, password) + .then(submitForm) + .catch(handleError) + } + + useEffect(() => { + setAuthError('') + }, [email, password]) + + return { + authError, + email, + formError, + formRef, + handleSubmit, + isFetching, + isSubmitDisabled, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + password, + url: addLanguageUrlParam(lang, url), + } +} diff --git a/src/features/AuthServiceApp/components/Login/index.tsx b/src/features/AuthServiceApp/components/Login/index.tsx new file mode 100644 index 00000000..47a598de --- /dev/null +++ b/src/features/AuthServiceApp/components/Login/index.tsx @@ -0,0 +1,96 @@ +import { T9n } from 'features/T9n' +import { ArrowLoader } from 'features/ArrowLoader' + +import { PAGES } from '../../config/pages' +import { LanguageSelect } from '../LanguageSelect' +import { PasswordInput } from '../PasswordInput' +import { Input } from '../Input' +import { Logo } from '../Logo' +import { useLoginForm } from './hooks' +import { + InputGroup, + BlockTitle, + CenterBlock, + ButtonsBlock, + Form, + ButtonSolid, + Error, + LanguageSelectWrapper, +} from '../../styled' +import { RegisterButton } from './styled' + +const Login = () => { + const { + authError, + email, + formError, + formRef, + handleSubmit, + isFetching, + isSubmitDisabled, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + password, + url, + } = useLoginForm() + + return ( + + +
+ + + + + + + + + + + + + + { + isFetching + ? + : + } + + + + + + + + + +
+ ) +} + +export default Login diff --git a/src/features/AuthServiceApp/components/Login/styled.tsx b/src/features/AuthServiceApp/components/Login/styled.tsx new file mode 100644 index 00000000..07aa204e --- /dev/null +++ b/src/features/AuthServiceApp/components/Login/styled.tsx @@ -0,0 +1,31 @@ +import { Link } from 'react-router-dom' + +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' + +import { outlineButtonStyles } from 'features/Common/Button' + +export const RegisterButton = styled(Link)` + ${outlineButtonStyles} + width: 100%; + height: 50px; + border-radius: 5px; + margin-top: 15px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + ${isMobileDevice + ? css` + height: 44px; + margin-top: 12px; + font-weight: 500; + font-size: 17px; + border-radius: 10px; + @media screen and (orientation: landscape) { + margin: 12px auto 0; + } + ` + : ''}; +` diff --git a/src/features/AuthServiceApp/components/Logo/index.tsx b/src/features/AuthServiceApp/components/Logo/index.tsx new file mode 100644 index 00000000..3956ff6a --- /dev/null +++ b/src/features/AuthServiceApp/components/Logo/index.tsx @@ -0,0 +1,27 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' + +import { client } from '../../config/clients' + +type Props = { + height?: number, + width?: number, +} + +export const Logo = styled.div` + ${client.styles.logo}; + display: block; + background-size: contain; + background-repeat: no-repeat; + ${isMobileDevice + ? css` + width: 207px; + height: 48px; + @media screen and (orientation: landscape){ + width: 92px; + height: 22px; + } + ` + : ''} +` diff --git a/src/features/Common/PasswordInput/index.tsx b/src/features/AuthServiceApp/components/PasswordInput/index.tsx similarity index 52% rename from src/features/Common/PasswordInput/index.tsx rename to src/features/AuthServiceApp/components/PasswordInput/index.tsx index e05dbf92..48644ea7 100644 --- a/src/features/Common/PasswordInput/index.tsx +++ b/src/features/AuthServiceApp/components/PasswordInput/index.tsx @@ -2,46 +2,54 @@ import type { ComponentProps } from 'react' import styled, { css } from 'styled-components/macro' -import { devices } from 'config' import { isMobileDevice } from 'config/userAgent' -import { useToggle } from 'hooks' +import { useToggle } from 'hooks/useToggle' -import { NewInput } from 'features/Common' -import { BaseButton } from 'features/PopupComponents' +import { Input } from '../Input' -const VisibilityButton = styled(BaseButton)` - width: 1.14rem; - height: 1.14rem; - background-image: url(/images/visibility.svg); - opacity: 0.4; +const VisibilityButton = styled.button` + border: none; + background: none; background-color: transparent; - border-radius: 0; - margin-right: 0.5rem; + padding: 0; + + display: flex; + cursor: pointer; + width: 50px; + height: 100%; + opacity: 0.4; + transition: background-color 0.3s; + :hover { + background-color: rgba(255, 255, 255, 0.22); + } + + ::after { + content: ''; + width: 100%; + height: 100%; + background-image: url(/images/visibility.svg); + background-repeat: no-repeat; + background-position: center; + background-size: 22px 15px; + } + ${isMobileDevice ? css` - min-width: 13.55px; - height: 9.23px; - right: 5px; - top: 50%; - transform: translateY(-50%); - @media (orientation: landscape){ - right: 10px; - } - ` + position: unset; + width: 50px; + height: 100%; + ` : ''}; - @media ${devices.tablet} { - width: 2.2rem; - height: 1.5rem; - margin-right: 1.7rem; - } ` -type Props = ComponentProps +type Props = ComponentProps export const PasswordInput = ({ + autoComplete, className, leftContent, + name, onBlur, onChange, onFocus, @@ -51,7 +59,8 @@ export const PasswordInput = ({ }: Props) => { const { isOpen: showPassword, toggle } = useToggle() return ( - } diff --git a/src/features/AuthServiceApp/components/RegisterPopup/index.tsx b/src/features/AuthServiceApp/components/RegisterPopup/index.tsx new file mode 100644 index 00000000..8aef84d2 --- /dev/null +++ b/src/features/AuthServiceApp/components/RegisterPopup/index.tsx @@ -0,0 +1,63 @@ +import { T9n } from 'features/T9n' + +import { + Modal, + Wrapper, + Header, + HeaderTitle, + Body, + Footer, + ApplyButton, + // SendConfirmationButton, + Text, +} from './styled' + +type Props = { + email: string, + handleModalClose: () => void, + isModalOpen: boolean, +} + +export const RegisterPopup = (props: Props) => { + const { + email, + handleModalClose, + isModalOpen, + } = props + + // const handleNewConfirmation = () => { + // // TODO дописать логику для отправки доп. письма, может понадобится, когда допишут бэк + // // console.log('send new confirmation') + // } + + return ( + + +
+ + + +
+ + +   + {email}  +   + + + + + + + + +
+ handleModalClose()}>Ok + {/* + + */} +
+
+
+ ) +} diff --git a/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx b/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx new file mode 100644 index 00000000..f8877dd5 --- /dev/null +++ b/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx @@ -0,0 +1,164 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' +import { devices } from 'config/devices' + +import { ModalWindow } from 'features/Modal/styled' +import { Modal as BaseModal } from 'features/Modal' +import { Header as BaseHeader } from 'features/PopupComponents' + +import { ButtonSolid, ButtonOutline } from 'features/Common' + +export const Modal = styled(BaseModal)` + background-color: rgba(0, 0, 0, 0.7); + padding: 0 60px; + + ${ModalWindow} { + max-width: 757px; + min-height: 414px; + padding-top: 60px; + background-color: #333333; + border-radius: 5px; + + @media (max-width: 1370px) { + width: 70rem; + height: auto; + } + + + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + height: auto; + top: -7vh; + } + ` + : ''}; + } +` + +type WrapperProps = { + isFetching?: boolean, +} + +export const Wrapper = styled.div` + ${({ isFetching }) => ( + isFetching + ? css`pointer-events: none;` + : '' + )} +` + +export const Header = styled(BaseHeader)` + height: auto; + padding-top: 60; + justify-content: center; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + padding-top: 33px; + } + ` + : ''}; +` + +export const HeaderTitle = styled.span` + font-weight: 700; + font-size: 24px; + line-height: 24px; + color: #FFFFFF; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + font-size: 14px; + line-height: 20px; + } + + @media (orientation: landscape) { + font-size: 20px; + } + ` + : ''}; +` + +export const Body = styled.div` + padding: 25px 94px 0 78px; + display: flex; + flex-direction: column; + font-weight: normal; + font-size: 20px; + line-height: 27px; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + padding: 13px 25px 0; + flex-direction: column; + } + + @media (orientation: landscape){ + padding: 22px 23px 0 29px; + } + ` + : ''}; +` + +export const Footer = styled.div` + width: 100%; + display: flex; + justify-content: center; + padding: 1.89rem; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + flex-direction: column; + padding: 20px 25px 20px; + } + ` + : ''}; +` + +export const ApplyButton = styled(ButtonSolid)` + width: 270px; + border-radius: 5px; + font-weight: 600; + margin-right: 24px; + width: 134px; + height: 50px; + font-size: 20px; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + width: 100%; + min-height: 42px; + margin-bottom: 20px; + } + + @media (orientation: landscape){ + width: 290px; + min-height: 42px; + } + ` + : ''}; +` + +export const SendConfirmationButton = styled(ButtonOutline)` + width: 100%; + height: 50px; + border-radius: 5px; + font-weight: 500; + font-size: 20px; + ${isMobileDevice + ? css` + @media ${devices.mobile}{ + width: 100%; + } + + @media (orientation: landscape){ + width: 290px; + } + ` + : ''}; +` +export const Text = styled.span` + margin-bottom: 20px; +` diff --git a/src/features/AuthServiceApp/components/Registration/hooks.tsx b/src/features/AuthServiceApp/components/Registration/hooks.tsx new file mode 100644 index 00000000..945b0b18 --- /dev/null +++ b/src/features/AuthServiceApp/components/Registration/hooks.tsx @@ -0,0 +1,72 @@ +import { SyntheticEvent, useState } from 'react' + +import { useAuthFields } from 'features/AuthServiceApp/hooks/useAuthFields' + +import { registerCheck } from '../../requests/register' + +export const useRegistrationForm = () => { + const [authError, setAuthError] = useState('') + const [termsAccepted, setTermsAccepted] = useState(false) + const [isFetching, setIsFetching] = useState(false) + const [isModalOpen, setIsModalOpen] = useState(false) + const { + email, + error: formError, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + password, + } = useAuthFields() + + const isSubmitDisabled = ( + !email + || !password + || !termsAccepted + || Boolean(formError) + || isFetching + ) + + const handleSubmit = async (event: SyntheticEvent) => { + event?.preventDefault() + setIsFetching(true) + try { + await registerCheck({ + email, + password, + }) + setAuthError('') + setIsFetching(false) + setIsModalOpen(true) + } catch (err) { + setAuthError(String(err)) + setIsFetching(false) + } + } + + const handleModalClose = () => { + setIsModalOpen(false) + } + + const onTermsChange = () => { + setTermsAccepted(!termsAccepted) + } + + return { + authError, + email, + formError, + handleModalClose, + handleSubmit, + isFetching, + isModalOpen, + isSubmitDisabled, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + onTermsChange, + password, + termsAccepted, + } +} diff --git a/src/features/AuthServiceApp/components/Registration/index.tsx b/src/features/AuthServiceApp/components/Registration/index.tsx new file mode 100644 index 00000000..36ed3bc0 --- /dev/null +++ b/src/features/AuthServiceApp/components/Registration/index.tsx @@ -0,0 +1,125 @@ +import { useHistory } from 'react-router' + +import { T9n } from 'features/T9n' +import { Checkbox } from 'features/Common/Checkbox' +import { ArrowLoader } from 'features/ArrowLoader' +import { RegisterPopup } from 'features/AuthServiceApp/components/RegisterPopup' + +import { LanguageSelect } from '../LanguageSelect' +import { PasswordInput } from '../PasswordInput' +import { Input } from '../Input' +import { Logo } from '../Logo' +import { useRegistrationForm } from './hooks' +import { + BlockTitle, + CenterBlock, + InputGroup, + ButtonsBlock, + Form, + ButtonSolid, + Error, + LanguageSelectWrapper, +} from '../../styled' +import { + Label, + Link, + ButtonOutline, + CheckboxWrapper, +} from './styled' + +const Registration = () => { + const history = useHistory() + const { + authError, + email, + formError, + handleModalClose, + handleSubmit, + isFetching, + isModalOpen, + isSubmitDisabled, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + onTermsChange, + password, + termsAccepted, + } = useRegistrationForm() + + return ( + + +
+ + + + + + + + {formError + ? + : } + + + + + + + + + + )} + /> + + + + + { + isFetching + ? + : + } + + + + + + + + + + +
+ ) +} + +export default Registration diff --git a/src/features/AuthServiceApp/components/Registration/styled.tsx b/src/features/AuthServiceApp/components/Registration/styled.tsx new file mode 100644 index 00000000..87a5d153 --- /dev/null +++ b/src/features/AuthServiceApp/components/Registration/styled.tsx @@ -0,0 +1,43 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' + +import { ButtonOutline as ButtonOutlineBase } from 'features/Common/Button' + +export const ButtonOutline = styled(ButtonOutlineBase)` + width: 100%; + height: 50px; + margin-top: 15px; + border-radius: 5px; + font-weight: normal; + font-size: 20px; + ${isMobileDevice + ? css` + display: block; + height: 44px; + margin-top: 12px; + font-weight: 600; + font-size: 17px; + border-radius: 10px; + ` + : ''}; +` + +export const CheckboxWrapper = styled.div` + width: 100%; + display: flex; + margin-top: 4px; + margin-bottom: 24px; +` + +export const Label = styled.span` + font-weight: normal; + font-size: 14px; + line-height: 21px; +` + +export const Link = styled.a` + color: ${({ theme }) => theme.colors.text100}; + text-decoration: underline; + margin-left: 6px; +` diff --git a/src/features/AuthServiceApp/config/clients/facr.tsx b/src/features/AuthServiceApp/config/clients/facr.tsx new file mode 100644 index 00000000..449cafc3 --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/facr.tsx @@ -0,0 +1,40 @@ +import styled, { css } from 'styled-components/macro' + +import { facr as platformFacr } from 'config/clients/facr' + +import type { ClientConfig } from './types' + +const Background = styled.div` + position: relative; + width: 100%; + min-height: 100vh; + display: flex; + justify-content: center; + background-color: #00257A; +` + +export const facr: ClientConfig = { + ...platformFacr, + background: Background, + name: 'facr', + styles: { + input: css` + background-color: transparent; + :not(:last-child) { + border-color: ${({ theme }) => theme.colors.text100}; + } + `, + inputGroup: css` + border: 1px solid ${({ theme }) => theme.colors.text100}; + `, + logo: css` + background-image: url(/images/facr_auth_logo.png); + height: 163px; + width: 213px; + `, + submitButton: css` + background-color: ${({ theme }) => theme.colors.text100}; + color: #00257A; + `, + }, +} diff --git a/src/features/AuthServiceApp/config/clients/index.tsx b/src/features/AuthServiceApp/config/clients/index.tsx new file mode 100644 index 00000000..bddcd5b7 --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/index.tsx @@ -0,0 +1,13 @@ +import { ClientIds } from 'config/clients/types' + +import { facr } from './facr' +import { instat } from './instat' + +const clients = { + [ClientIds.Facr]: facr, + [ClientIds.Instat]: instat, +} +const params = new URLSearchParams(window.location.search) +const clientId = params.get('client_id') as ClientIds + +export const client = clients[clientId] || instat diff --git a/src/features/AuthServiceApp/config/clients/instat.tsx b/src/features/AuthServiceApp/config/clients/instat.tsx new file mode 100644 index 00000000..5542a676 --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/instat.tsx @@ -0,0 +1,20 @@ +import { css } from 'styled-components' + +import { instat as platformInstat } from 'config/clients/instat' + +import { Background } from 'features/Background' + +import type { ClientConfig } from './types' + +export const instat: ClientConfig = { + ...platformInstat, + background: Background, + name: 'instat', + styles: { + logo: css` + background-image: url(/images/logo.svg); + height: 54px; + width: 234px; + `, + }, +} diff --git a/src/features/AuthServiceApp/config/clients/types.tsx b/src/features/AuthServiceApp/config/clients/types.tsx new file mode 100644 index 00000000..3fcb2e17 --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/types.tsx @@ -0,0 +1,20 @@ +import type { ReactNode, FC } from 'react' +import { css } from 'styled-components/macro' + +import type { Languages } from 'config/languages' + +type StyledCss = ReturnType + +export type ClientConfig = { + background: FC<{ children: ReactNode }>, + defaultLanguage?: Languages, + description: string, + name: string, + styles: { + input?: StyledCss, + inputGroup?: StyledCss, + logo: StyledCss, + submitButton?: StyledCss, + }, + title: string, +} diff --git a/src/features/AuthServiceApp/config/lexics.tsx b/src/features/AuthServiceApp/config/lexics.tsx new file mode 100644 index 00000000..96551e40 --- /dev/null +++ b/src/features/AuthServiceApp/config/lexics.tsx @@ -0,0 +1,32 @@ +export const lexics = { + check_email: 15907, + check_password: 15842, + confirm_2_hours: 15906, + confirm_email: 15432, + error_email_already_in_use: 11156, + error_empty_email: 2498, + error_empty_password: 2499, + error_failed_to_send_email: 15902, + error_invalid_email_format: 12908, + error_invalid_email_or_password: 15774, + error_invalid_platform: 15925, + error_missing_required_argument: 15921, + error_simple_password: 12940, + error_unsupported_response_type: 15922, + error_user_already_created: 15926, + error_user_not_found: 1417, + form_email: 12912, + form_password: 751, + go_back: 1907, + i_accept: 15737, + login: 13404, + register: 13328, + registration_successful: 12945, + send_confirm: 15905, + send_new_email: 15830, + sign_up: 1305, + step_title_login: 13404, + step_title_registration: 1306, + terms_and_conditions: 15738, + to_email: 15904, +} diff --git a/src/features/AuthServiceApp/config/pages.tsx b/src/features/AuthServiceApp/config/pages.tsx new file mode 100644 index 00000000..54f56915 --- /dev/null +++ b/src/features/AuthServiceApp/config/pages.tsx @@ -0,0 +1,4 @@ +export const PAGES = { + login: '/authorize', + registration: '/registration', +} diff --git a/src/features/AuthServiceApp/config/routes.tsx b/src/features/AuthServiceApp/config/routes.tsx new file mode 100644 index 00000000..0391ab3b --- /dev/null +++ b/src/features/AuthServiceApp/config/routes.tsx @@ -0,0 +1,13 @@ +import { ENV } from 'config/env' + +const APIS = { + preproduction: '', + production: 'https://auth.instat.tv', + staging: 'https://test-auth.instat.tv', +} + +export const API_ROOT = APIS[ENV] + +export const getApiUrl = (path: string) => ( + `${API_ROOT}${path}${window.location.search}` +) diff --git a/src/features/AuthServiceApp/helpers/isValidEmail/__tests__/index.tsx b/src/features/AuthServiceApp/helpers/isValidEmail/__tests__/index.tsx new file mode 100644 index 00000000..b0cbb44e --- /dev/null +++ b/src/features/AuthServiceApp/helpers/isValidEmail/__tests__/index.tsx @@ -0,0 +1,22 @@ +import { isValidEmail } from '..' + +it('invalid emails', () => { + expect(isValidEmail('a')).toBeFalsy() + expect(isValidEmail('1')).toBeFalsy() + expect(isValidEmail('a@')).toBeFalsy() + expect(isValidEmail('a@m')).toBeFalsy() + expect(isValidEmail('a@m.')).toBeFalsy() + expect(isValidEmail('a@mail')).toBeFalsy() + expect(isValidEmail('a@mail.')).toBeFalsy() + expect(isValidEmail('abcd.mail.com')).toBeFalsy() +}) + +it('valid emails', () => { + expect(isValidEmail('a@mail.com')).toBeTruthy() + expect(isValidEmail('A@MAIL.COM')).toBeTruthy() + expect(isValidEmail('123@mail.com')).toBeTruthy() + expect(isValidEmail('A123@mail.com')).toBeTruthy() + expect(isValidEmail('a.b@mail.com')).toBeTruthy() + expect(isValidEmail('a-b-c@mail.com')).toBeTruthy() + expect(isValidEmail('a-b@a-b.com')).toBeTruthy() +}) diff --git a/src/features/AuthServiceApp/helpers/isValidEmail/index.tsx b/src/features/AuthServiceApp/helpers/isValidEmail/index.tsx new file mode 100644 index 00000000..7d23de6b --- /dev/null +++ b/src/features/AuthServiceApp/helpers/isValidEmail/index.tsx @@ -0,0 +1,9 @@ +import size from 'lodash/size' + +export const emailRegex = '[a-z0-9!#$%&/\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&/\'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?' + +const emailRegExp = new RegExp(emailRegex) + +export const isValidEmail = (email: string) => ( + emailRegExp.test(email.toLowerCase()) && size(email) <= 100 +) diff --git a/src/helpers/isValidPassword/__tests__/index.tsx b/src/features/AuthServiceApp/helpers/isValidPassword/__tests__/index.tsx similarity index 100% rename from src/helpers/isValidPassword/__tests__/index.tsx rename to src/features/AuthServiceApp/helpers/isValidPassword/__tests__/index.tsx diff --git a/src/helpers/isValidPassword/index.tsx b/src/features/AuthServiceApp/helpers/isValidPassword/index.tsx similarity index 100% rename from src/helpers/isValidPassword/index.tsx rename to src/features/AuthServiceApp/helpers/isValidPassword/index.tsx diff --git a/src/features/AuthServiceApp/hooks/useAuthFields.tsx b/src/features/AuthServiceApp/hooks/useAuthFields.tsx new file mode 100644 index 00000000..3bc5b3f1 --- /dev/null +++ b/src/features/AuthServiceApp/hooks/useAuthFields.tsx @@ -0,0 +1,58 @@ +import type { + ChangeEvent, + FocusEvent, +} from 'react' +import { useState } from 'react' + +import { isValidEmail } from 'features/AuthServiceApp/helpers/isValidEmail' +import { isValidPassword } from 'features/AuthServiceApp/helpers/isValidPassword' + +export const useAuthFields = () => { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + + const checkPassword = (pass: string) => { + const regexp = /^(?=[^.,'"-])[\w\d@$!%*#?&;*]{8,}$/g + return regexp.test(pass) + } + + const onEmailChange = ({ target: { value } }: ChangeEvent) => { + setError('') + setEmail(value) + } + + const onPasswordChange = ({ target: { value } }: ChangeEvent) => { + setError('') + setPassword(value) + if (!checkPassword(value)) { + setError('check_password') + } + } + + const onEmailBlur = ({ target: { value } }: FocusEvent) => { + if (!value) { + setError('error_empty_email') + } else if (!isValidEmail(value)) { + setError('error_invalid_email_format') + } + } + + const onPasswordBlur = ({ target: { value } }: FocusEvent) => { + if (!value) { + setError('error_empty_password') + } else if (!isValidPassword(value)) { + setError('error_simple_password') + } + } + + return { + email, + error, + onEmailBlur, + onEmailChange, + onPasswordBlur, + onPasswordChange, + password, + } +} diff --git a/src/features/AuthServiceApp/index.tsx b/src/features/AuthServiceApp/index.tsx new file mode 100644 index 00000000..bb07e029 --- /dev/null +++ b/src/features/AuthServiceApp/index.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter } from 'react-router-dom' + +import { getLanguageUrlParam } from 'helpers/languageUrlParam' +import { + setClientTitleAndDescription, + setClientIcons, +} from 'helpers/setClientHeads' + +import { GlobalStyles } from 'features/GlobalStyles' +import { LexicsStore } from 'features/LexicsStore' +import { Theme } from 'features/Theme' + +import { App } from './components/App' +import { client } from './config/clients' + +setClientTitleAndDescription(client.title, client.description) +setClientIcons(client.name) + +const Background = client.background +const initialLanguage = getLanguageUrlParam() || client.defaultLanguage + +const AuthServiceApp = () => ( + + + + + + + + + + +) + +export default AuthServiceApp diff --git a/src/features/AuthServiceApp/requests/auth.tsx b/src/features/AuthServiceApp/requests/auth.tsx new file mode 100644 index 00000000..b7fa63ee --- /dev/null +++ b/src/features/AuthServiceApp/requests/auth.tsx @@ -0,0 +1,36 @@ +import { getApiUrl } from '../config/routes' + +const errorLexics = { + 1: 'error_invalid_email_or_password', + 4: 'error_user_not_found', +} + +type FailedResponse = { + error: { + code: keyof typeof errorLexics, + message?: string, + }, + ok: false, +} + +type SuccessResponse = { + ok: true, +} + +export const loginCheck = async (email: string, password: string) => { + const url = getApiUrl('/authorize-check') + const init: RequestInit = { + body: new URLSearchParams({ + email, + password, + }), + method: 'POST', + } + const response = await fetch(url, init) + + const body: SuccessResponse | FailedResponse = await response.json() + + if (body.ok) return Promise.resolve() + + return Promise.reject(errorLexics[body.error.code]) +} diff --git a/src/features/AuthServiceApp/requests/register.tsx b/src/features/AuthServiceApp/requests/register.tsx new file mode 100644 index 00000000..6fb41a5e --- /dev/null +++ b/src/features/AuthServiceApp/requests/register.tsx @@ -0,0 +1,49 @@ +import { getApiUrl } from 'features/AuthServiceApp/config/routes' + +const errorLexics = { + 1: 'error_invalid_email_or_password', + 2: 'error_missing_required_argument', + 3: 'error_unsupported_response_type', + 7: 'error_invalid_platform', + 8: 'error_failed_to_send_email', + 9: 'error_user_already_created', +} + +type FailedResponse = { + error: { + code: keyof typeof errorLexics, + message?: string, + }, + ok: false, +} + +type SuccessResponse = { + ok: true, +} + +type RegisterProps = { + email: string, + password: string, +} + +export const registerCheck = async ({ + email, + password, +} : RegisterProps) => { + const url = getApiUrl('/registration') + + const init: RequestInit = { + body: new URLSearchParams({ + email, + password, + }), + method: 'POST', + } + const response = await fetch(url, init) + + const body: SuccessResponse | FailedResponse = await response.json() + + if (body.ok) return Promise.resolve() + + return Promise.reject(errorLexics[body.error.code]) +} diff --git a/src/features/AuthServiceApp/styled.tsx b/src/features/AuthServiceApp/styled.tsx new file mode 100644 index 00000000..cf5f4a6b --- /dev/null +++ b/src/features/AuthServiceApp/styled.tsx @@ -0,0 +1,125 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config/userAgent' + +import { ButtonSolid as BaseButtonSolid } from 'features/Common/Button' +import { T9n } from 'features/T9n' + +import { client } from 'features/AuthServiceApp/config/clients' + +export const InputGroup = styled.div` + width: 100%; + box-shadow: ${({ theme }) => theme.colors.shadow}; + border-radius: 14px; + overflow: hidden; + + ${client.styles.inputGroup} +` + +export const CenterBlock = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + margin-top: 9.15rem; + ${isMobileDevice + ? css` + margin-top: 107px; + @media screen and (orientation: landscape) { + width: 290px; + margin: auto; + } + ` + : ''}; +` + +export const Form = styled.form` + width: 100%; + max-width: 493px; + margin-top: 1.82rem; + margin-bottom: 6.604rem; + display: flex; + flex-direction: column; + align-items: center; + ${isMobileDevice + ? css` + width: 100%; + margin-top: 78px; + @media screen and (orientation: landscape){ + margin-bottom: 0; + margin-top: 10px; + padding: 0; + } + ` + : ''}; +` + +export const BlockTitle = styled(T9n)` + display: block; + font-style: normal; + font-weight: bold; + font-size: 24px; + height: 24px; + color: ${({ theme: { colors } }) => colors.text100}; + margin-bottom: 30px; + ${isMobileDevice + ? css` + font-size: 20px; + margin-bottom: 12px; + ` + : ''}; +` + +type ButtonsBlockProps = { + marginTop?: number, +} + +export const ButtonsBlock = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: ${({ marginTop = 0 }) => marginTop}px; +` + +type ButtonSolidProps = { + width?: string, +} + +export const ButtonSolid = styled(BaseButtonSolid)` + width: 100%; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + font-weight: 600; + font-size: 20px; + ${isMobileDevice + ? css` + height: 44px; + font-weight: 600; + font-size: 17px; + border-radius: 10px; + ` + : ''}; + + ${client.styles.submitButton} +` + +export const Error = styled.span` + align-self: flex-start; + height: 20px; + margin-left: 2px; + color: ${({ theme }) => theme.colors.error}; + font-style: normal; + font-weight: 500; + font-size: 12px; + line-height: 20px; + letter-spacing: 0.1px; +` + +export const LanguageSelectWrapper = styled.div` + margin-top: 15px; + align-self: flex-end; +` diff --git a/src/features/AuthStore/hooks/useAuth.tsx b/src/features/AuthStore/hooks/useAuth.tsx index 1cc1a114..5eef9829 100644 --- a/src/features/AuthStore/hooks/useAuth.tsx +++ b/src/features/AuthStore/hooks/useAuth.tsx @@ -13,17 +13,20 @@ import isString from 'lodash/isString' import { PAGES } from 'config' +import { addLanguageUrlParam } from 'helpers/languageUrlParam' import { writeToken, removeToken } from 'helpers/token' import { setCookie, removeCookie } from 'helpers/cookie' import { isMatchPage } from 'helpers/isMatchPage' import { useLocalStore, useToggle } from 'hooks' +import { useLexicsStore } from 'features/LexicsStore' import { queryParamStorage } from 'features/QueryParamsStorage' import { getClientSettings } from '../helpers' export const useAuth = () => { + const { lang } = useLexicsStore() const history = useHistory() const { close: markUserLoaded, @@ -33,17 +36,18 @@ export const useAuth = () => { const userManager = useMemo(() => new UserManager(getClientSettings()), []) const login = useCallback(async () => ( - userManager.signinRedirect() - ), [userManager]) + userManager.signinRedirect({ extraQueryParams: { lang } }) + ), [userManager, lang]) const logout = useCallback(() => { userManager.clearStaleState() userManager.createSigninRequest().then(({ url }) => { - userManager.signoutRedirect({ post_logout_redirect_uri: url }) + const urlWithLang = addLanguageUrlParam(lang, url) + userManager.signoutRedirect({ post_logout_redirect_uri: urlWithLang }) }) removeToken() removeCookie('access_token') - }, [userManager]) + }, [userManager, lang]) const storeUser = useCallback((loadedUser: User) => { setUser(loadedUser) diff --git a/src/features/Common/InputGroup/index.tsx b/src/features/Common/InputGroup/index.tsx deleted file mode 100644 index ccbe12c2..00000000 --- a/src/features/Common/InputGroup/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styled, { css } from 'styled-components/macro' -import { isMobileDevice } from 'config/userAgent' - -export const InputGroup = styled.div` - width: 100%; - box-shadow: ${({ theme }) => theme.colors.shadow}; - border-radius: 14px; - overflow: hidden; - ${isMobileDevice - ? css` - border-radius: 8px; - ` - : ''}; -` diff --git a/src/features/Common/index.tsx b/src/features/Common/index.tsx index 679f493b..b7a97789 100644 --- a/src/features/Common/index.tsx +++ b/src/features/Common/index.tsx @@ -1,7 +1,4 @@ export * from './Input' -export * from './NewInput' -export * from './PasswordInput' -export * from './InputGroup' export * from './Button' export * from './Radio' export * from './Checkbox' diff --git a/src/features/GlobalStores/index.tsx b/src/features/GlobalStores/index.tsx index f6ef3bbe..2baa0191 100644 --- a/src/features/GlobalStores/index.tsx +++ b/src/features/GlobalStores/index.tsx @@ -1,16 +1,21 @@ import { ReactNode } from 'react' +import { client } from 'config/clients' +import { getLanguageUrlParam } from 'helpers/languageUrlParam' + import { AuthStore } from 'features/AuthStore' import { LexicsStore } from 'features/LexicsStore' +const initialLanguage = getLanguageUrlParam() || client.defaultLanguage + type Props = { children: ReactNode, } export const GlobalStores = ({ children }: Props) => ( - - + + {children} - - + + ) diff --git a/src/features/GlobalStyles/index.tsx b/src/features/GlobalStyles/index.tsx index 29b57712..95f399ef 100644 --- a/src/features/GlobalStyles/index.tsx +++ b/src/features/GlobalStyles/index.tsx @@ -7,6 +7,7 @@ export const GlobalStyles = createGlobalStyle` html { font-size: calc(2px + 1vw); + overflow-y: hidden; } body { diff --git a/src/features/LexicsStore/hooks/index.tsx b/src/features/LexicsStore/hooks/index.tsx index 6bf462f9..7321f509 100644 --- a/src/features/LexicsStore/hooks/index.tsx +++ b/src/features/LexicsStore/hooks/index.tsx @@ -4,6 +4,8 @@ import isEmpty from 'lodash/isEmpty' import { getLexics } from 'requests' +import type { Languages } from 'config/languages' + import { getLexicIds, mapTranslationsToLocalKeys, @@ -14,8 +16,8 @@ import { useLang } from './useLang' import { useLexicsConfig } from './useLexicsConfig' import { useTranslations } from './useTranslations' -export const useLexics = () => { - const { changeLang, lang } = useLang() +export const useLexics = (initialLanguage?: Languages) => { + const { changeLang, lang } = useLang(initialLanguage) const { addLexicsConfig, lexicsConfig } = useLexicsConfig() const { addTranslations, translate } = useTranslations() diff --git a/src/features/LexicsStore/hooks/useLang.tsx b/src/features/LexicsStore/hooks/useLang.tsx index 7a415b11..0024d611 100644 --- a/src/features/LexicsStore/hooks/useLang.tsx +++ b/src/features/LexicsStore/hooks/useLang.tsx @@ -3,16 +3,15 @@ import { useCallback } from 'react' import { useLocalStore } from 'hooks' import type { Languages } from 'config/languages' -import { client } from 'config/clients' import { isSupportedLang } from '../helpers/isSupportedLang' const LANG_KEY = 'lang' -const DEFAULT_LANG = client.defaultLanguage || 'en' +const DEFAULT_LANG = 'en' -export const useLang = () => { +export const useLang = (initialLanguage: Languages = DEFAULT_LANG) => { const [lang, setLang] = useLocalStore({ - defaultValue: DEFAULT_LANG, + initialValue: initialLanguage, key: LANG_KEY, validator: isSupportedLang, }) diff --git a/src/features/LexicsStore/index.tsx b/src/features/LexicsStore/index.tsx index 8089d8c4..dcb21372 100644 --- a/src/features/LexicsStore/index.tsx +++ b/src/features/LexicsStore/index.tsx @@ -5,16 +5,21 @@ import { useEffect, } from 'react' +import type { Languages } from 'config/languages' + import type { LexicsConfig, LexicsId } from './types' import { useLexics } from './hooks' type Context = ReturnType -type Props = { children: ReactNode } +type Props = { + children: ReactNode, + initialLanguage?: Languages, +} const LexicsContext = createContext({} as Context) -export const LexicsStore = ({ children }: Props) => { - const lexics = useLexics() +export const LexicsStore = ({ children, initialLanguage }: Props) => { + const lexics = useLexics(initialLanguage) return {children} } diff --git a/src/helpers/languageUrlParam/index.tsx b/src/helpers/languageUrlParam/index.tsx new file mode 100644 index 00000000..c5b352e4 --- /dev/null +++ b/src/helpers/languageUrlParam/index.tsx @@ -0,0 +1,14 @@ +import type { Languages } from 'config/languages' +import { history } from 'config/history' + +const KEY = 'lang' + +export const getLanguageUrlParam = () => ( + new URLSearchParams(history.location.search).get(KEY) as Languages +) + +export const addLanguageUrlParam = (lang: Languages, url: string) => { + const urlObject = new URL(url) + urlObject.searchParams.set(KEY, lang) + return urlObject.toString() +} diff --git a/src/helpers/setClientHeads/index.tsx b/src/helpers/setClientHeads/index.tsx new file mode 100644 index 00000000..5375f6a7 --- /dev/null +++ b/src/helpers/setClientHeads/index.tsx @@ -0,0 +1,38 @@ +export const setClientTitleAndDescription = (title: string, desc: string) => { + document.title = title + + const description = document.querySelector('meta[name=description]') as HTMLMetaElement + description.content = desc +} + +export const setClientIcons = (clientName: string) => { + const faviconLink = document.createElement('link') + faviconLink.rel = 'icon' + faviconLink.href = `/clients/${clientName}/favicon/favicon.ico` + + const faviconAppleLink = document.createElement('link') + faviconLink.rel = 'apple-touch-icon' + faviconLink.type = 'image/png' + faviconLink.href = `/clients/${clientName}/favicon/apple-touch-icon.png` + + const favicon32Link = document.createElement('link') + favicon32Link.rel = 'icon' + favicon32Link.type = 'image/png' + favicon32Link.href = `/clients/${clientName}/favicon/favicon-32x32.png` + + const favicon16Link = document.createElement('link') + favicon16Link.rel = 'icon' + favicon16Link.type = 'image/png' + favicon16Link.href = `/clients/${clientName}/favicon/favicon-16x16.png` + + const manifest = document.createElement('link') + manifest.rel = 'icon' + manifest.href = `/clients/${clientName}/favicon/manifest.json` + + const { head } = window.document + head.appendChild(faviconLink) + head.appendChild(faviconAppleLink) + head.appendChild(favicon32Link) + head.appendChild(favicon16Link) + head.appendChild(manifest) +} diff --git a/src/hooks/useStorage/index.tsx b/src/hooks/useStorage/index.tsx index ef21751c..a984348b 100644 --- a/src/hooks/useStorage/index.tsx +++ b/src/hooks/useStorage/index.tsx @@ -9,7 +9,8 @@ const defaultSerializer = (key: string, value: any) => value type Args = { clearOnUnmount?: boolean, - defaultValue: T, + defaultValue?: T, + initialValue?: T, key: string, serialize?: (key: string, value: T) => string, @@ -32,6 +33,7 @@ const createHook = (storage: Storage) => ( ({ clearOnUnmount, defaultValue, + initialValue, key, serialize = defaultSerializer, validator = defaultValidator, @@ -42,7 +44,7 @@ const createHook = (storage: Storage) => ( return isValid ? storeValue : defaultValue } - const [state, setState] = useState(getInitialState) + const [state, setState] = useState(initialValue || getInitialState) useEffect(() => { const storeValue = readStorageInitialValue(storage, key) diff --git a/src/index.tsx b/src/index.tsx index c40fa88c..7f94bd65 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,21 @@ -import { StrictMode } from 'react' +import { + lazy, + Suspense, + StrictMode, +} from 'react' import ReactDOM from 'react-dom' -import { App } from 'features/App' - import * as serviceWorker from './serviceWorker' +export const App = process.env.REACT_APP_TYPE === 'auth-service' + ? lazy(() => import('features/AuthServiceApp')) + : lazy(() => import('features/App')) + ReactDOM.render( - + + + , document.getElementById('root'), ) diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index c329807a..cc9c849a 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -5,5 +5,6 @@ declare namespace NodeJS { export interface ProcessEnv { REACT_APP_CLIENT: 'instat' | 'facr', REACT_APP_ENV: 'production' | 'preproduction' | 'staging', + REACT_APP_TYPE: 'auth-service' | 'ott', } }