From f75531770ec2bdd70c2ae20b7e53d8e288fb798c Mon Sep 17 00:00:00 2001 From: Ido Shamun Date: Tue, 18 Aug 2020 15:56:41 +0300 Subject: [PATCH] feat: profile modal Opens a profile modal when clicking on the profile image. In addition, added a switch to opt-in/out from marketing emails --- .stylelintrc | 13 +- __tests__/CommentActionButtons.tsx | 10 +- __tests__/MainComment.tsx | 8 +- __tests__/MainLayout.tsx | 10 +- __tests__/NewCommentModal.tsx | 2 + __tests__/PostPage.tsx | 10 +- ...nfirmAccountModal.tsx => ProfileModal.tsx} | 25 ++-- __tests__/SubComment.tsx | 8 +- components/AuthContext.ts | 2 + components/LoginModal.tsx | 39 ++---- components/MainLayout.tsx | 5 +- ...nfirmAccountModal.tsx => ProfileModal.tsx} | 98 ++++++++++++--- components/Switch.tsx | 117 ++++++++++++++++++ components/utilities.tsx | 23 +++- lib/constants.ts | 2 + lib/user.ts | 8 ++ pages/_app.tsx | 37 +++--- pages/posts/[id].tsx | 10 +- 18 files changed, 345 insertions(+), 82 deletions(-) rename __tests__/{ConfirmAccountModal.tsx => ProfileModal.tsx} (76%) rename components/{ConfirmAccountModal.tsx => ProfileModal.tsx} (68%) create mode 100644 components/Switch.tsx create mode 100644 lib/constants.ts diff --git a/.stylelintrc b/.stylelintrc index 6d8cca69e4..75adc14e70 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -5,5 +5,16 @@ "extends": [ "stylelint-config-recommended", "stylelint-config-styled-components" - ] + ], + "rules": { + "selector-type-no-unknown": [ + true, + { + "ignoreTypes": [ + "/-styled-mixin/", + "$dummyValue" + ] + } + ] + } } diff --git a/__tests__/CommentActionButtons.tsx b/__tests__/CommentActionButtons.tsx index 1548b9f47b..3308af6380 100644 --- a/__tests__/CommentActionButtons.tsx +++ b/__tests__/CommentActionButtons.tsx @@ -60,7 +60,15 @@ const renderComponent = ( return render( - + , diff --git a/__tests__/MainComment.tsx b/__tests__/MainComment.tsx index 20399dc600..6b5a61b99b 100644 --- a/__tests__/MainComment.tsx +++ b/__tests__/MainComment.tsx @@ -50,7 +50,13 @@ const renderLayout = ( return render( diff --git a/__tests__/MainLayout.tsx b/__tests__/MainLayout.tsx index 3448cc5d82..8fdac9fa21 100644 --- a/__tests__/MainLayout.tsx +++ b/__tests__/MainLayout.tsx @@ -12,7 +12,15 @@ beforeEach(() => { const renderLayout = (user: LoggedUser = null): RenderResult => { return render( - + , ); diff --git a/__tests__/NewCommentModal.tsx b/__tests__/NewCommentModal.tsx index 2e849556b5..a2642ebb0c 100644 --- a/__tests__/NewCommentModal.tsx +++ b/__tests__/NewCommentModal.tsx @@ -49,6 +49,8 @@ const renderComponent = ( user: { ...defaultUser, ...user }, shouldShowLogin: false, showLogin: jest.fn(), + showProfile: jest.fn(), + logout: jest.fn(), }} > diff --git a/__tests__/PostPage.tsx b/__tests__/PostPage.tsx index 7dd88e9d14..019a8bdec8 100644 --- a/__tests__/PostPage.tsx +++ b/__tests__/PostPage.tsx @@ -105,7 +105,15 @@ const renderPost = ( return render( - + , diff --git a/__tests__/ConfirmAccountModal.tsx b/__tests__/ProfileModal.tsx similarity index 76% rename from __tests__/ConfirmAccountModal.tsx rename to __tests__/ProfileModal.tsx index 0e15d7002c..403222f42d 100644 --- a/__tests__/ConfirmAccountModal.tsx +++ b/__tests__/ProfileModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, RenderResult, screen, waitFor } from '@testing-library/react'; import { mocked } from 'ts-jest/utils'; -import ConfirmAccountModal, { Props } from '../components/ConfirmAccountModal'; +import ProfileModal, { Props } from '../components/ProfileModal'; import AuthContext from '../components/AuthContext'; import { LoggedUser, updateProfile } from '../lib/user'; @@ -11,10 +11,10 @@ jest.mock('../lib/user', () => ({ })); const profiledUpdated = jest.fn(); +const logout = jest.fn(); beforeEach(() => { - mocked(updateProfile).mockReset(); - profiledUpdated.mockReset(); + jest.resetAllMocks(); }); const defaultUser = { @@ -35,6 +35,7 @@ const renderComponent = ( isOpen: true, profiledUpdated, ariaHideApp: false, + confirmMode: false, }; return render( @@ -43,29 +44,32 @@ const renderComponent = ( user: { ...defaultUser, ...user }, shouldShowLogin: false, showLogin: jest.fn(), + showProfile: jest.fn(), + logout, }} > - + , ); }; it('should enable submit when form is valid', () => { renderComponent(); - const el = screen.getByText('Confirm'); + const el = screen.getByText('Save changes'); expect(el.getAttribute('disabled')).toBeFalsy(); }); it('should submit information on button click', async () => { renderComponent(); mocked(updateProfile).mockResolvedValue(defaultUser); - screen.getByText('Confirm').click(); + screen.getByText('Save changes').click(); await waitFor(() => expect(profiledUpdated).toBeCalledWith(defaultUser)); expect(updateProfile).toBeCalledWith({ name: 'Ido Shamun', email: 'ido@acme.com', company: null, title: null, + acceptedMarketing: false, }); }); @@ -76,15 +80,22 @@ it('should show server error', async () => { code: 1, message: '', }); - screen.getByText('Confirm').click(); + screen.getByText('Save changes').click(); await waitFor(() => expect(updateProfile).toBeCalledWith({ name: 'Ido Shamun', email: 'ido@acme.com', company: null, title: null, + acceptedMarketing: false, }), ); expect(profiledUpdated).toBeCalledTimes(0); expect(screen.getByRole('alert')).toBeInTheDocument(); }); + +it('should logout on button click', async () => { + renderComponent(); + screen.getByText('Logout').click(); + await waitFor(() => expect(logout).toBeCalledTimes(1)); +}); diff --git a/__tests__/SubComment.tsx b/__tests__/SubComment.tsx index bf21de672b..339df6f247 100644 --- a/__tests__/SubComment.tsx +++ b/__tests__/SubComment.tsx @@ -50,7 +50,13 @@ const renderLayout = ( return render( diff --git a/components/AuthContext.ts b/components/AuthContext.ts index ece747b4e9..c81024f474 100644 --- a/components/AuthContext.ts +++ b/components/AuthContext.ts @@ -5,6 +5,8 @@ interface AuthContextData { user: LoggedUser; shouldShowLogin: boolean; showLogin: () => void; + showProfile: () => void; + logout: () => Promise; } const AuthContext = React.createContext(null); diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 69f20707cb..beee54c735 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -5,9 +5,11 @@ import DailyDevLogo from './DailyDevLogo'; import { InvertButton, TextButton } from './Buttons'; import XIcon from '../icons/x.svg'; import GitHubIcon from '../icons/github.svg'; -import { typoJr, typoMicro2Base } from '../styles/typography'; +import { typoJr } from '../styles/typography'; import { CodeChallenge, generateChallenge } from '../lib/auth'; import { StyledModal, ModalCloseButton, Props } from './StyledModal'; +import { privacyPolicy, termsOfService } from '../lib/constants'; +import { LegalNotice } from './utilities'; const MyModal = styled(StyledModal)` .Modal { @@ -17,6 +19,11 @@ const MyModal = styled(StyledModal)` width: 9.25rem; } } + + ${LegalNotice} { + max-width: 17.25rem; + margin-top: ${size8}; + } `; const Buttons = styled.div` @@ -44,24 +51,6 @@ const Content = styled.div` ${typoJr} `; -const LegalNotice = styled.div` - max-width: 17.25rem; - margin-top: ${size8}; - color: var(--theme-disabled); - text-align: center; - font-weight: bold; - ${typoMicro2Base}; - - a { - display: inline-block; - text-decoration: underline; - color: inherit; - @supports (display: contents) { - display: contents; - } - } -`; - export default function LoginModal(props: Props): ReactElement { // eslint-disable-next-line react/prop-types const { onRequestClose } = props; @@ -109,19 +98,11 @@ export default function LoginModal(props: Props): ReactElement { By signing up I accept the{' '} - + Terms of Service {' '} and the{' '} - + Privacy Policy . diff --git a/components/MainLayout.tsx b/components/MainLayout.tsx index 3af3a7dbb7..9b47e6595b 100644 --- a/components/MainLayout.tsx +++ b/components/MainLayout.tsx @@ -51,6 +51,7 @@ const ProfileImage = styled.button` background: none; border: none; border-radius: 100%; + cursor: pointer; ${focusOutline} `; @@ -69,7 +70,7 @@ const HomeLink = styled.a` `; export default function MainLayout({ children }: Props): ReactElement { - const { user, showLogin } = useContext(AuthContext); + const { user, showLogin, showProfile } = useContext(AuthContext); return ( @@ -79,7 +80,7 @@ export default function MainLayout({ children }: Props): ReactElement { {user ? ( - + void; } -export default function ConfirmAccountModal(props: Props): ReactElement { +export default function ProfileModal(props: Props): ReactElement { // eslint-disable-next-line react/prop-types - const { onRequestClose } = props; - const { user } = useContext(AuthContext); + const { onRequestClose, confirmMode } = props; + const { user, logout } = useContext(AuthContext); const formRef = useRef(null); const [disableSubmit, setDisableSubmit] = useState(false); @@ -120,9 +151,13 @@ export default function ConfirmAccountModal(props: Props): ReactElement { if (val.name === '') { return acc; } - return Object.assign(acc, { + if (val.type === 'checkbox') { + return { ...acc, [val.name]: val.checked }; + } + return { + ...acc, [val.name]: val.value.length ? val.value : null, - }); + }; }, { name: '', email: '' }, ); @@ -146,8 +181,12 @@ export default function ConfirmAccountModal(props: Props): ReactElement {
- Account details - Please confirm your details below + {confirmMode ? 'Account details' : ' Your profile'} + + {confirmMode + ? 'Please confirm your details below' + : ' Edit your profile details'} + {user?.providers[0] === 'google' ? ( @@ -198,14 +237,39 @@ export default function ConfirmAccountModal(props: Props): ReactElement { value={user.title} validityChanged={updateDisableSubmit} /> - - Confirm - + Subscribe to the Weekly Recap + + + + {confirmMode ? 'Confirm' : 'Save changes'} + + {!confirmMode && ( + + Logout + + )} + + {!confirmMode && ( + + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + + )} ); } diff --git a/components/Switch.tsx b/components/Switch.tsx new file mode 100644 index 0000000000..d86538e159 --- /dev/null +++ b/components/Switch.tsx @@ -0,0 +1,117 @@ +import React, { ReactElement, ReactNode } from 'react'; +import styled from 'styled-components'; +import { size3, size4, size8 } from '../styles/sizes'; +import { colorWater50, colorWater60, colorWater90 } from '../styles/colors'; +import { typoNuggets } from '../styles/typography'; + +const switchHeight = size4; + +// TODO: track height doesn't follow the sizing guideline +const SwitchTrack = styled.span` + bottom: 0; + width: 100%; + height: 0.625rem; + margin: auto 0; + background: var(--theme-active); + will-change: background-color, opacity; + transition: background-color 0.1s linear, opacity 0.2s linear; +`; + +const SwitchKnob = styled.span` + width: ${switchHeight}; + height: ${switchHeight}; + background: var(--theme-secondary); + will-change: transform, background-color; + transition: background-color 0.1s linear, transform 0.2s linear; +`; + +// TODO: border-radius doesn't follow the sizing guideline +const SwitchContainer = styled.span` + position: relative; + display: block; + width: ${size8}; + height: ${switchHeight}; + + ${SwitchKnob}, ${SwitchTrack} { + position: absolute; + left: 0; + top: 0; + border-radius: 0.188rem; + } +`; + +const Children = styled.span` + margin-left: ${size3}; + color: var(--theme-secondary); + ${typoNuggets} +`; + +// TODO: add support for light theme for the water colors +const Container = styled.label` + display: flex; + align-items: center; + cursor: pointer; + + &:hover ${SwitchKnob} { + background: var(--theme-primary); + } + + &:hover input:checked ~ ${SwitchContainer} ${SwitchKnob} { + background: ${colorWater50}; + } + + &:active { + background: none; + } + + input { + display: none; + + &:checked { + & ~ ${SwitchContainer} ${SwitchTrack} { + background: ${colorWater90}; + } + + & ~ ${SwitchContainer} ${SwitchKnob} { + transform: translateX(100%); + background: ${colorWater60}; + } + + & ~ ${Children} { + color: var(--theme-primary); + } + } + } +`; + +export interface Props { + children?: ReactNode; + className?: string; + inputId: string; + name: string; + checked?: boolean; +} + +export default function Switch({ + className, + inputId, + name, + checked, + children, +}: Props): ReactElement { + return ( + + + + + + + {children && {children}} + + ); +} diff --git a/components/utilities.tsx b/components/utilities.tsx index 4ee1ec6128..76a556f1b0 100644 --- a/components/utilities.tsx +++ b/components/utilities.tsx @@ -2,7 +2,12 @@ import styled from 'styled-components'; import Linkify from 'linkifyjs/react'; import LazyImage from './LazyImage'; import { size10, size2, size3, size4, size6 } from '../styles/sizes'; -import { typoLil1, typoLil2Base, typoSmall } from '../styles/typography'; +import { + typoLil1, + typoLil2Base, + typoMicro2Base, + typoSmall, +} from '../styles/typography'; import Loader from './Loader'; import { colorWater60 } from '../styles/colors'; @@ -53,3 +58,19 @@ export const ButtonLoader = styled(Loader)` bottom: 0; margin: auto; `; + +export const LegalNotice = styled.div` + color: var(--theme-disabled); + text-align: center; + font-weight: bold; + ${typoMicro2Base}; + + a { + display: inline-block; + text-decoration: underline; + color: inherit; + @supports (display: contents) { + display: contents; + } + } +`; diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000000..c0b9c623d3 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,2 @@ +export const termsOfService = 'https://daily.dev/tos'; +export const privacyPolicy = 'https://daily.dev/privacy'; diff --git a/lib/user.ts b/lib/user.ts index 3ff164e498..0a9c084da4 100644 --- a/lib/user.ts +++ b/lib/user.ts @@ -16,6 +16,7 @@ export interface LoggedUser { providers: string[]; company?: string; title?: string; + acceptedMarketing?: boolean; } export interface UserProfile { @@ -60,6 +61,13 @@ export async function authenticate({ }); } +export async function logout(): Promise { + await fetch(`${apiUrl}/v1/users/logout`, { + method: 'POST', + credentials: 'include', + }); +} + export async function updateProfile( profile: UserProfile, ): Promise { diff --git a/pages/_app.tsx b/pages/_app.tsx index 02c4a2358e..653f766026 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -11,15 +11,10 @@ import Seo from '../next-seo'; import { useApollo } from '../lib/apolloClient'; import GlobalStyle from '../components/GlobalStyle'; import AuthContext from '../components/AuthContext'; -import { LoggedUser } from '../lib/user'; +import { LoggedUser, logout as dispatchLogout } from '../lib/user'; -const LoginModal = dynamic(() => import('../components/LoginModal'), { - ssr: false, -}); -const ConfirmAccountModal = dynamic( - () => import('../components/ConfirmAccountModal'), - { ssr: false }, -); +const LoginModal = dynamic(() => import('../components/LoginModal')); +const ProfileModal = dynamic(() => import('../components/ProfileModal')); interface PageProps { user?: LoggedUser; @@ -37,15 +32,24 @@ export default function App({ const apolloClient = useApollo(pageProps.initialApolloState); const [user, setUser] = useState(pageProps.user); const [loginIsOpen, setLoginIsOpen] = useState(false); - const [confirmAccountIsOpen, setConfirmAccountIsOpen] = useState( + const [confirmAccount, setConfirmAccount] = useState( pageProps.user?.providers && !pageProps.user.infoConfirmed, ); + const [profileIsOpen, setProfileIsOpen] = useState(confirmAccount); const closeLogin = () => setLoginIsOpen(false); - const closeConfirmAccount = () => setConfirmAccountIsOpen(false); + const closeConfirmAccount = () => setProfileIsOpen(false); const profileUpdated = (newProfile: LoggedUser) => { setUser({ ...user, ...newProfile }); - setConfirmAccountIsOpen(false); + setProfileIsOpen(false); + if (confirmAccount) { + setConfirmAccount(false); + } + }; + + const logout = async (): Promise => { + await dispatchLogout(); + location.reload(); }; useEffect(() => { @@ -66,6 +70,8 @@ export default function App({ user, shouldShowLogin: loginIsOpen, showLogin: () => setLoginIsOpen(true), + showProfile: () => setProfileIsOpen(true), + logout, }} > @@ -102,10 +108,13 @@ export default function App({ contentLabel="Login Modal" /> {user && ( - diff --git a/pages/posts/[id].tsx b/pages/posts/[id].tsx index 7577c55b3a..60a9618cbe 100644 --- a/pages/posts/[id].tsx +++ b/pages/posts/[id].tsx @@ -61,14 +61,12 @@ import { colorPepper90 } from '../../styles/colors'; import { focusOutline, postPageMaxWidth } from '../../styles/utilities'; import { NextSeoProps } from 'next-seo/lib/types'; -const NewCommentModal = dynamic( - () => import('../../components/NewCommentModal'), - { ssr: false }, +const NewCommentModal = dynamic(() => + import('../../components/NewCommentModal'), ); -const DeleteCommentModal = dynamic( - () => import('../../components/DeleteCommentModal'), - { ssr: false }, +const DeleteCommentModal = dynamic(() => + import('../../components/DeleteCommentModal'), ); const ShareBar = dynamic(() => import('../../components/ShareBar'), {