diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..fee5edad --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @iron-fish/frontend diff --git a/apiClient/client.ts b/apiClient/client.ts index 1130f4a9..3b9e8dc6 100644 --- a/apiClient/client.ts +++ b/apiClient/client.ts @@ -19,12 +19,9 @@ import { // Environment variables set in Vercel config. const SERVER_API_URL = process.env.API_URL -const SERVER_API_KEY = process.env.API_KEY const BROWSER_API_URL = process.env.NEXT_PUBLIC_API_URL -const BROWSER_API_KEY = process.env.NEXT_PUBLIC_API_KEY -const API_URL = SERVER_API_URL || BROWSER_API_URL -const API_KEY = SERVER_API_KEY || BROWSER_API_KEY +export const API_URL = SERVER_API_URL || BROWSER_API_URL export async function createUser( email: string, @@ -43,13 +40,35 @@ export async function createUser( method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${API_KEY}`, }, body, }) return await res.json() } +type PartialUser = { + email?: string + graffiti?: string + socialChoice?: string + social?: string + countryCode?: string +} + +export async function updateUser(id: number, partial: PartialUser) { + const body = JSON.stringify(partial) + const token = await magic?.user.getIdToken() + const options = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body, + } + const res = await fetch(`${API_URL}/users/${id}`, options) + return await res.json() +} + export async function listLeaderboard({ search, country_code: countryCode, diff --git a/components/About/CallToAction.tsx b/components/About/CallToAction.tsx index 64707836..4b5fd316 100644 --- a/components/About/CallToAction.tsx +++ b/components/About/CallToAction.tsx @@ -77,7 +77,9 @@ export const CallToAction = ({ className="m-auto w-full mt-2 max-w-md mb-2 text-md p-2" colorClassName="text-black bg-transparent hover:bg-black hover:text-white" > - {href ? Claim Points : ctaText} + + Claim Points + )} diff --git a/components/Form/Select.module.css b/components/Form/Select.module.css index f1bd6a81..5c0dce76 100644 --- a/components/Form/Select.module.css +++ b/components/Form/Select.module.css @@ -14,7 +14,7 @@ width: 1rem; height: 2rem; text-align: center; - background-image: url(public/arrow_drop_down_black.png); + background-image: url(/arrow_drop_down_black.png); background-repeat: no-repeat; background-position: center center; background-color: transparent; @@ -22,5 +22,5 @@ } .customSelectWrapper.disabled::after { - background-image: url(public/arrow_drop_down_disabled.png); + background-image: url(/arrow_drop_down_disabled.png); } diff --git a/components/Form/TextField.tsx b/components/Form/TextField.tsx index f1837b6e..ea8a920b 100644 --- a/components/Form/TextField.tsx +++ b/components/Form/TextField.tsx @@ -48,6 +48,10 @@ const RadioOptions = ({ ) +export interface ControlledField extends Field { + controlled?: boolean +} + export const TextField = ({ id, label, @@ -63,9 +67,25 @@ export const TextField = ({ setChoice, isRadioed, disabled, + required = true, whitespace = WHITESPACE.DEFAULT, -}: Field) => { + value, + controlled = false, +}: ControlledField) => { const handleTrim = whitespace !== WHITESPACE.DEFAULT ? { onKeyDown } : {} + const controller = controlled ? { value: value } : {} + const inputProps = { + ...handleTrim, + ...controller, + className: `${disabled ? 'bg-transparent' : ''}`, + defaultValue, + disabled, + onBlur, + onChange, + id, + type: 'text', + placeholder, + } return ( {isRadioed && options.length > 0 && ( )} - + ) } diff --git a/components/user/Tabs/SettingsContent.tsx b/components/user/Tabs/SettingsContent.tsx index 4c9f6292..4a755ba0 100644 --- a/components/user/Tabs/SettingsContent.tsx +++ b/components/user/Tabs/SettingsContent.tsx @@ -1,59 +1,260 @@ -import React from 'react' +import { useState, useCallback } from 'react' +import { useRouter } from 'next/router' -import * as API from 'apiClient' +import Note from 'components/Form/Note' +import { FieldError } from 'components/Form/FieldStatus' import Select from 'components/Form/Select' import TextField from 'components/Form/TextField' +import Button from 'components/Button' +import Loader from 'components/Loader' + import { useField } from 'hooks/useForm' import { FIELDS } from 'pages/signup' +import { scrollUp } from 'utils/scroll' +import { UNSET } from 'utils/forms' +import { useQueriedToast } from 'hooks/useToast' +import { STATUS } from 'hooks/useLogin' + +import * as API from 'apiClient' +import { TabType } from './index' + type Props = { - authedUser: API.ApiUserMetadata + anyBlocksMined: boolean + user: API.ApiUser + authedUser: API.ApiUserMetadata | null + toast: ReturnType + reloadUser: () => Promise + setUserStatus: (x: STATUS) => unknown + setRawMetadata: (x: API.ApiUserMetadata) => unknown + onTabChange: (tab: TabType) => unknown + setFetched: (x: boolean) => unknown + setUser: (x: API.ApiUser) => unknown } -const getSocialChoice = ( - authedUser: API.ApiUserMetadata -): { choice: string; value: string } => { - if (authedUser.discord) { - return { choice: 'discord', value: authedUser.discord } - } else if (authedUser.telegram) { - return { choice: 'telegram', value: authedUser.telegram } - } - return { choice: '', value: '' } +const EDITABLE_FIELDS = { + email: { + ...FIELDS.email, + validation: () => true, + touched: true, + controlled: true, + }, + graffiti: { ...FIELDS.graffiti, controlled: true }, + country: { ...FIELDS.country, useDefault: false, controlled: true }, + discord: { + ...FIELDS.social, + required: false, + options: undefined, + id: 'discord', + label: 'Discord', + placeholder: 'Your Discord username', + isRadioed: false, + validation: () => true, + controlled: true, + }, + telegram: { + ...FIELDS.social, + required: false, + options: undefined, + id: 'telegram', + label: 'Telegram', + placeholder: 'Your Telegram username', + isRadioed: false, + validation: () => true, + controlled: true, + }, } -export default function SettingsContent({ authedUser }: Props) { - const $email = useField(FIELDS.email) - const $graffiti = useField(FIELDS.graffiti) - const $social = useField(FIELDS.social) - const $country = useField(FIELDS.country) +export default function SettingsContent({ + anyBlocksMined, + user, + authedUser, + toast, + reloadUser, + onTabChange, + setFetched, + setUser, + setUserStatus, + setRawMetadata, +}: Props) { + const router = useRouter() + const [$error, $setError] = useState(UNSET) + const [$loading, $setLoading] = useState(false) + const { + email: _email = UNSET, + graffiti: _graffiti = UNSET, + discord: _discord = UNSET, + telegram: _telegram = UNSET, + country_code: _country_code = UNSET, + } = authedUser || {} + + const $graffiti = useField({ + ...EDITABLE_FIELDS.graffiti, + defaultValue: _graffiti, + }) + const $email = useField({ + ...EDITABLE_FIELDS.email, + defaultValue: _email, + }) + const $telegram = useField({ + ...EDITABLE_FIELDS.telegram, + defaultValue: _telegram, + touched: !!_telegram, + }) + const $discord = useField({ + ...EDITABLE_FIELDS.discord, + defaultValue: _discord, + touched: !!_discord, + }) - const { choice: socialChoice, value: socialValue } = - getSocialChoice(authedUser) + const $country = useField({ + ...EDITABLE_FIELDS.country, + defaultValue: _country_code, + value: _country_code, + }) + const testInvalid = useCallback(() => { + const invalid = + !$email?.valid || + !$graffiti?.valid || + !$discord?.valid || + !$telegram?.valid || + !$country?.valid + if (invalid) { + $setError('Please correct the invalid fields below') + scrollUp() + } else { + $setError(UNSET) + } + return invalid + }, [$email, $graffiti, $telegram, $discord, $country]) + + // on save + const update = useCallback(async () => { + if ( + !$email || + !$graffiti || + !$telegram || + !$discord || + !$country || + !authedUser || + testInvalid() + ) { + return + } + $setLoading(true) + const email = $email?.value + const graffiti = $graffiti?.value + const telegram = $telegram?.value + const discord = $discord?.value + const country = $country?.value + + const updates = { + email, + graffiti, + telegram, + discord, + country_code: country, + } + let result + try { + result = await API.updateUser(authedUser.id, updates) + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e) + /* + Unhandled Runtime Error + Error: Magic RPC Error: [-32603] Internal error: User denied account access. + */ + if (e.message.indexOf('-32603') > -1) { + router.push(`/login?toast=${btoa('Please log in again.')}`) + return + } + } + + const canSee = authedUser && user && user.id === authedUser.id + if (!canSee) { + // if you try to go to /users/x/settings but you're not user x + onTabChange('weekly') + toast.setMessage('You are not authorized to go there') + toast.show() + return + } + scrollUp() + if ('error' in result || 'code' in result) { + const error = '' + result.message + $setError(error) + $setLoading(false) + } else { + $setLoading(false) + setUserStatus(STATUS.LOADING) + toast.setMessage('User settings updated') + toast.show() + // this is to prevent the graffiti from popping an error on save + $graffiti.setTouched(false) + const updated = { ...user, ...updates } + const userData = { ...authedUser, ...updates } + setUser(updated) + // $setUserData(userData) + setFetched(false) + setRawMetadata(userData) + return await reloadUser() + } + }, [ + onTabChange, + setRawMetadata, + setUserStatus, + $email, + $graffiti, + $telegram, + $discord, + $country, + authedUser, + testInvalid, + toast, + reloadUser, + $setError, + setFetched, + setUser, + user, + router, + ]) return (
-
User Settings
- {$email && ( - - )} - {$graffiti && ( - - )} - {$social && ( - - )} - {$country && ( - } + + + )} + )}
diff --git a/components/user/Tabs/index.tsx b/components/user/Tabs/index.tsx index 432eb13d..ef57ce80 100644 --- a/components/user/Tabs/index.tsx +++ b/components/user/Tabs/index.tsx @@ -1,10 +1,12 @@ import React from 'react' import * as API from 'apiClient' +import { STATUS } from 'hooks/useLogin' import AllTimeContent from './AllTimeContent' import SettingsContent from './SettingsContent' import WeeklyContent from './WeeklyContent' +import { useQueriedToast } from 'hooks/useToast' export type TabType = 'all' | 'weekly' | 'settings' @@ -16,6 +18,12 @@ type TabsProps = { authedUser: API.ApiUserMetadata | null activeTab: TabType onTabChange: (tab: TabType) => unknown + toast: ReturnType + reloadUser: () => Promise + setFetched: (x: boolean) => unknown + setUser: (x: API.ApiUser) => unknown + setUserStatus: (x: STATUS) => unknown + setRawMetadata: (x: API.ApiUserMetadata) => unknown } export default function Tabs({ @@ -26,7 +34,17 @@ export default function Tabs({ authedUser, activeTab, onTabChange, + toast, + reloadUser, + setFetched, + setUser, + setUserStatus, + setRawMetadata, }: TabsProps) { + // eslint-disable-next-line + const allTimeBlocksMined = allTimeMetrics?.metrics?.blocks_mined?.points ?? 0 + const anyBlocksMined = allTimeBlocksMined > 0 + return (
{/* Tabs */} @@ -43,7 +61,7 @@ export default function Tabs({ > All Time Stats - {authedUser && user.id === authedUser.id && ( + {user && authedUser && authedUser.id === user.id && ( onTabChange('settings')} @@ -63,8 +81,19 @@ export default function Tabs({ {activeTab === 'all' && ( )} - {activeTab === 'settings' && authedUser && ( - + {activeTab === 'settings' && ( + )}
) diff --git a/cypress/integration/pages/about.ts b/cypress/integration/pages/about.ts index 411eb409..c3fb1b32 100644 --- a/cypress/integration/pages/about.ts +++ b/cypress/integration/pages/about.ts @@ -50,7 +50,7 @@ describe('/about', () => { { isImage: false, text: 'Claim Points', - href: 'https://github.com/iron-fish/ironfish/issues', + href: 'https://forms.gle/yrAtzoyKTwLgLTRZA', }, { isImage: false, @@ -75,7 +75,7 @@ describe('/about', () => { { isImage: false, text: 'Claim Points', - href: 'https://github.com/iron-fish/ironfish/pulls', + href: 'https://forms.gle/yrAtzoyKTwLgLTRZA', }, { isImage: false, diff --git a/hooks/useForm.ts b/hooks/useForm.ts index ade6dfb1..9df02218 100644 --- a/hooks/useForm.ts +++ b/hooks/useForm.ts @@ -20,15 +20,22 @@ export interface NameValue { } export interface ProvidedField { - id: string - label: string + controlled?: boolean + defaultErrorText?: string + useDefault?: boolean + defaultLabel?: string defaultValue: string - validation: (v: string) => boolean - placeholder?: string + id: string isRadioed?: boolean + label: string options?: NameValue[] - defaultErrorText?: string + placeholder?: string + radioOption?: string + required?: boolean + touched?: boolean + validation: (v: string) => boolean whitespace?: WHITESPACE + value?: string } export interface Field extends ProvidedField { value: string @@ -49,68 +56,102 @@ export interface Field extends ProvidedField { } export function useField(provided: ProvidedField): Field | null { - const [$value, $setter] = useState(provided.defaultValue) + const { + defaultErrorText = '', + defaultLabel = '', + defaultValue, + id, + isRadioed, + label, + options, + radioOption: defaultRadioOption, + required = true, + touched = false, + validation, + whitespace = WHITESPACE.DEFAULT, + placeholder, + useDefault, + controlled, + } = provided + const [$value, $setter] = useState(defaultValue) // TODO: tried writing this with nullish coalescing but it yelled and I got tired const radioOption = - provided && - provided.options && - provided.options[0] && - provided.options[0].value - const { whitespace = WHITESPACE.DEFAULT } = provided + defaultRadioOption || (options && options[0] && options[0].value) const banSpaces = whitespace === WHITESPACE.BANNED const trimSpaces = banSpaces || whitespace === WHITESPACE.TRIMMED const [$choice, $setChoice] = useState( - provided.isRadioed && radioOption ? radioOption : '' + isRadioed && radioOption ? radioOption : '' ) const [$disabled, $setDisabled] = useState(false) - const [$valid, $setValid] = useState(false) - const [$touched, $setTouched] = useState(false) + const [, $setValid] = useState(false) + const [$touched, $setTouched] = useState(touched) const [$field, $setField] = useState(null) - const [$error, $setError] = useState(provided.defaultErrorText || '') + const [$error, $setError] = useState(defaultErrorText) useEffect(() => { - const { validation, defaultValue } = provided const valid = !$touched || ($touched && validation($value)) $setValid(valid) $setField({ - ...provided, - disabled: $disabled, - setDisabled: $setDisabled, + // raw values from upstream + defaultErrorText, defaultValue, - value: $value, + defaultLabel, + id, + isRadioed, + label, + radioOption: defaultRadioOption, + options, valid, + validation, whitespace, + placeholder, + useDefault, + // dynamic values + choice: $choice, + disabled: $disabled, + errorText: valid ? undefined : $error, + onChange: setStateOnChange($setter, trimSpaces), + setChoice: $setChoice, + setDisabled: $setDisabled, + setError: $setError, + setTouched: $setTouched, setValid: $setValid, setter: $setter, + touched: $touched, + value: $value, + required, + // callback functions onKeyDown: (e: KeyboardEvent) => { if (banSpaces && e.key === ' ') { e.preventDefault() } }, - onChange: setStateOnChange($setter, trimSpaces), onBlur: () => { $setTouched(true) }, - setTouched: $setTouched, - touched: $touched, - errorText: valid ? undefined : $error, - setError: $setError, - choice: $choice, - setChoice: $setChoice, + controlled, }) }, [ + defaultLabel, + defaultErrorText, + defaultRadioOption, + isRadioed, + options, + id, + label, + validation, + required, + defaultValue, + whitespace, + banSpaces, + trimSpaces, $disabled, - $setDisabled, - provided, $value, $touched, - $valid, - $setValid, $error, $choice, - $setChoice, - whitespace, - banSpaces, - trimSpaces, + placeholder, + useDefault, + controlled, ]) return $field } diff --git a/hooks/useLocalLogin.ts b/hooks/useLocalLogin.ts index aac335c2..47284557 100644 --- a/hooks/useLocalLogin.ts +++ b/hooks/useLocalLogin.ts @@ -1,26 +1,34 @@ import { LoginContext, STATUS } from './useLogin' +export const METADATA = { + id: 3, + created_at: '2021-10-30T23:28:59.505Z', + updated_at: '2021-10-30T23:43:28.555Z', + email: 'cooldev@ironfish.network', + graffiti: 'smipplejipple', + total_points: 1100, + country_code: 'USA', + email_notifications: false, + last_login_at: '2021-10-30T23:29:49.101Z', + discord: 'coolcooldev', + telegram: '', +} + export const useLocalLogin = (): LoginContext => ({ + reloadUser: () => Promise.resolve(true), + setRawMetadata: () => Promise.resolve(true), checkLoggedIn: () => true, checkLoading: () => false, checkFailed: () => false, - setError: () => {}, + setError: () => { + // non-empty-function body + }, error: null, status: STATUS.LOADED, - setStatus: () => {}, - metadata: { - id: 111, - created_at: '2021-10-30T23:28:59.505Z', - updated_at: '2021-10-30T23:43:28.555Z', - email: 'cooldev@ironfish.network', - graffiti: 'cooldev', - total_points: 1100, - country_code: 'USA', - email_notifications: false, - last_login_at: '2021-10-30T23:29:49.101Z', - discord: 'coolcooldev', - telegram: '', + setStatus: () => { + // non-empty-function body }, + metadata: METADATA, magicMetadata: { issuer: 'did:ethr:0xFfcD8602De681449Fa70C304096a84e014Fa123C', publicAddress: '0xFfcD8602De681449Fa70C304096a84e014Fa123C', diff --git a/hooks/useLogin.ts b/hooks/useLogin.ts index 8b9b05a3..0fa20eed 100644 --- a/hooks/useLogin.ts +++ b/hooks/useLogin.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useRouter } from 'next/router' import { magic, MagicUserMetadata } from 'utils/magic' import { ApiUserMetadata, ApiError, LocalError } from 'apiClient' @@ -19,13 +19,6 @@ export enum STATUS { FORCED = 'forced', } -// reusable magic login context -// MagicUserMetadata takes the form of: -// {issuer, publicAddress, email} -// https://github.com/magiclabs/magic-js/blob/master/packages/%40magic-sdk/types/src/modules/user-types.ts#L17-L21 - -// const $metadata = useLogin({redirect: '/go-somewhere-if-it-does-not-work'}) - export interface LoginProps { redirect?: string } @@ -39,78 +32,76 @@ export function useLogin(config: LoginProps = {}) { useState(null) const [$metadata, $setMetadata] = useState(null) - useEffect(() => { - const checkLoggedIn = async () => { - try { - if ($status === STATUS.LOADED) return - - if (!magic) { - return - } - - let token - try { - token = await magic.user.getIdToken() - } catch (error) {} - - if (!token) { - if (redirect) { - // if redirect string is provided and we're not logged in, cya! - // if this is kept as a static Router.push, it _does not_ work - $router.push(redirect) - return - } - - // this is a visible error but not a breaking error - $setStatus(STATUS.NOT_FOUND) - $setError(new LocalError('No token available.', NO_MAGIC_TOKEN)) - return - } - const [magicMd, details] = await Promise.all([ - magic.user.getMetadata(), - getUserDetails(token), - ]) - - if ('error' in details || details instanceof LocalError) { - // this is a visible error and a breaking error - $setStatus(STATUS.FAILED) - $setError(details) - return - } - - if (details.statusCode && details.statusCode === 401) { - $setStatus(STATUS.NOT_FOUND) - $setError(new LocalError('No user found.', NO_MAGIC_USER)) - return - } + const reloadUser = useCallback(async () => { + if (!magic || !magic.user) { + return false + } + $setStatus(STATUS.LOADING) + + let token + try { + token = await magic.user.getIdToken() + } catch (error) {} + + if (!token) { + if (redirect) { + // if redirect string is provided and we're not logged in, cya! + // if this is kept as a static Router.push, it _does not_ work + $router.push(redirect) + return false + } - $setStatus(STATUS.LOADED) - $setMetadata(details) - $setMagicMetadata(magicMd) - } catch (err) { - if (err.toString().indexOf('-32603') > -1) { - $setStatus(STATUS.NOT_FOUND) - return - } + // this is a visible error but not a breaking error + $setStatus(STATUS.NOT_FOUND) + $setError(new LocalError('No token available.', NO_MAGIC_TOKEN)) + return false + } + try { + const [magicMd, details] = await Promise.all([ + magic.user.getMetadata(), + getUserDetails(token), + ]) + + if ('error' in details || details instanceof LocalError) { + // this is a visible error and a breaking error + $setStatus(STATUS.FAILED) + $setError(details) + Promise.reject(details) + return false + } - throw err + if (details.statusCode && details.statusCode === 401) { + $setStatus(STATUS.NOT_FOUND) + $setError(new LocalError('No user found.', NO_MAGIC_USER)) + return false } - } - if (!$metadata) { - checkLoggedIn().catch(e => { - if ($status === STATUS.LOADING) { - $setStatus(STATUS.FAILED) - } + $setStatus(STATUS.LOADED) + $setMetadata(details) + $setMagicMetadata(magicMd) + return true + } catch (err) { + if (err.toString().indexOf('-32603') > -1) { + $setStatus(STATUS.NOT_FOUND) + return false + } - // eslint-disable-next-line no-console - console.warn('general error!', e) - }) + throw err } - }, [$metadata, $setMetadata, redirect, $status, $router]) - + }, [$router, redirect]) + useEffect(() => { + const loadAndCheck = async () => { + try { + await reloadUser() + } catch (e) { + $setStatus(STATUS.FAILED) + } + } + loadAndCheck() + }, [reloadUser]) const statusRelevantContext = (x: STATUS) => () => $status === x const loginContext = { + reloadUser, checkLoggedIn: statusRelevantContext(STATUS.LOADED), checkLoading: statusRelevantContext(STATUS.LOADING), checkFailed: statusRelevantContext(STATUS.FAILED), @@ -120,6 +111,7 @@ export function useLogin(config: LoginProps = {}) { metadata: $metadata, status: $status, setStatus: $setStatus, + setRawMetadata: $setMetadata, } return loginContext } diff --git a/hooks/useToast.tsx b/hooks/useToast.tsx index 659945d9..b28557b7 100644 --- a/hooks/useToast.tsx +++ b/hooks/useToast.tsx @@ -30,12 +30,18 @@ const decode = (x: string) => (typeof window !== 'undefined' ? atob(x) : x) export function useQueriedToast(opts: QueriedToastOptions = {}) { const { queryString = 'toast' } = opts - const $toast = useQuery(queryString) || '' + const $rawToast = useQuery(queryString) || '' const toasted = useToast(opts) + const { visible, show } = toasted + const [$message, $setMessage] = useState('') + useEffect(() => { + $setMessage($rawToast.split('=').map(decode).join('')) + }, [$rawToast]) return { - message: $toast.split('=').map(decode).join(''), - visible: toasted.visible, - show: toasted.show, + setMessage: $setMessage, + message: $message, + visible, + show, } } diff --git a/next-env.d.ts b/next-env.d.ts index 9bc3dd46..4f11a03d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,4 @@ /// -/// /// // NOTE: This file should not be edited diff --git a/package-scripts.js b/package-scripts.js index c95cb06f..45118c4e 100644 --- a/package-scripts.js +++ b/package-scripts.js @@ -25,7 +25,7 @@ module.exports = { dx: concurrent.nps('build', 'lint', 'meta.dependencies'), meta: { dependencies: { - build: `depcruise -c .dependency-cruiser.js -T dot components pages apiClient contexts data definitions hooks public styles utils --progress -x node_modules definitions | dot -T svg > dependency-graph.svg`, + build: `depcruise -c .dependency-cruiser.js -T dot components pages apiClient data definitions hooks public styles utils --progress -x node_modules definitions | dot -T svg > dependency-graph.svg`, interactive: `cat dependency-graph.svg | depcruise-wrap-stream-in-html > dependency-graph.html`, script: 'nps meta.dep.build meta.dep.interactive', }, diff --git a/package.json b/package.json index a0ea4b49..18c3235f 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "clsx": "^1.1.1", "iso-3166-1-ts": "^0.2.2", "magic-sdk": "^6.2.1", - "next": "^11.1.3", - "react": "17.0.2", - "react-dom": "17.0.2" + "next": "^12.0.7", + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "devDependencies": { "@types/base-64": "^1.0.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index b1c37063..5e1eec7a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -6,10 +6,10 @@ import { useLogin } from 'hooks/useLogin' import { useLocalLogin } from 'hooks/useLocalLogin' const LOCAL_MODE = process.env.NEXT_PUBLIC_LOCAL_USER || false -const loginHook = LOCAL_MODE ? useLocalLogin : useLogin +const useLoginHook = LOCAL_MODE ? useLocalLogin : useLogin function MyApp({ Component: Page, pageProps }: AppProps) { - const $login = loginHook() + const $login = useLoginHook() const { metadata } = $login return ( <> diff --git a/pages/leaderboard.tsx b/pages/leaderboard.tsx index a2acbb11..bd0afe28 100644 --- a/pages/leaderboard.tsx +++ b/pages/leaderboard.tsx @@ -75,20 +75,18 @@ export default function Leaderboard({ loginContext }: Props) { const [$search, $setSearch] = useState('') const $debouncedSearch = useDebounce($search, 300) const [$searching, $setSearching] = useState(false) + const countryValue = $country?.value + const eventTypeValue = $eventType?.value useEffect(() => { const func = async () => { $setSearching(true) const countrySearch = - $country?.value && $country.value !== 'Global' - ? { country_code: $country.value } - : {} + countryValue !== 'Global' ? { country_code: countryValue } : {} const eventType = - $eventType?.value && $eventType.value !== TOTAL_POINTS - ? { event_type: $eventType.value } - : {} + eventTypeValue !== TOTAL_POINTS ? { event_type: eventTypeValue } : {} const result = await API.listLeaderboard({ search: $debouncedSearch, @@ -103,10 +101,10 @@ export default function Leaderboard({ loginContext }: Props) { $setSearching(false) } - if ($country?.value && $eventType?.value) { + if (countryValue && eventTypeValue) { func() } - }, [$debouncedSearch, $country?.value, $eventType?.value]) + }, [$debouncedSearch, countryValue, eventTypeValue]) const { checkLoggedIn, checkLoading } = loginContext const isLoggedIn = checkLoggedIn() diff --git a/pages/signup.tsx b/pages/signup.tsx index 9b741ca3..76a2347b 100644 --- a/pages/signup.tsx +++ b/pages/signup.tsx @@ -18,7 +18,6 @@ import { UNSET, validateEmail, validateGraffiti, - exists, defaultErrorText, } from 'utils/forms' import { encode as btoa } from 'base-64' @@ -49,7 +48,8 @@ export const FIELDS = { label: '', placeholder: 'Your username', defaultValue: UNSET, - validation: exists, + required: false, + validation: () => true, defaultErrorText, isRadioed: true, whitespace: WHITESPACE.BANNED, @@ -88,11 +88,11 @@ export default function SignUp({ loginContext }: SignUpProps) { duration: 8e3, }) - const [$error, $setError] = useState(UNSET) const $email = useField(FIELDS.email) const $social = useField(FIELDS.social) const $graffiti = useField(FIELDS.graffiti) const $country = useField(FIELDS.country) + const [$error, $setError] = useState(UNSET) const [$signedUp, $setSignedUp] = useState(false) const [$loaded, $setLoaded] = useState(false) useEffect(() => { diff --git a/pages/users/[id].tsx b/pages/users/[id].tsx index d24932c8..fa36e8f2 100644 --- a/pages/users/[id].tsx +++ b/pages/users/[id].tsx @@ -1,6 +1,7 @@ -import React, { useEffect } from 'react' -import { useRouter } from 'next/router' +import { useCallback, useEffect, useState } from 'react' +import Router, { useRouter } from 'next/router' import Head from 'next/head' +import useQuery from 'hooks/useQuery' import Footer from 'components/Footer' import Navbar from 'components/Navbar' @@ -16,10 +17,14 @@ import { encode as btoa } from 'base-64' import * as API from 'apiClient' import { graffitiToColor, numberToOrdinal } from 'utils' import { LoginContext } from 'hooks/useLogin' +import { useQueriedToast, Toast, Alignment } from 'hooks/useToast' // The number of events to display in the Recent Activity list. const EVENTS_LIMIT = 7 +const validTabValue = (x: string) => + x === 'weekly' || x === 'all' || x === 'settings' + interface Props { loginContext: LoginContext } @@ -42,99 +47,127 @@ function displayEventType(type: API.EventType): string { } export default function User({ loginContext }: Props) { + const $toast = useQueriedToast({ + queryString: 'toast', + duration: 8e3, + }) + const router = useRouter() + const { isReady: routerIsReady } = router + const userId = (router?.query?.id || '') as string + const rawTab = useQuery('tab') + const [$activeTab, $setActiveTab] = useState('weekly') - const [$user, $setUser] = React.useState(undefined) - const [$events, $setEvents] = React.useState< - API.ListEventsResponse | undefined - >(undefined) - const [$allTimeMetrics, $setAllTimeMetrics] = React.useState< + const [$user, $setUser] = useState(undefined) + + const [$events, $setEvents] = useState( + undefined + ) + const [$allTimeMetrics, $setAllTimeMetrics] = useState< API.UserMetricsResponse | undefined >(undefined) - const [$weeklyMetrics, $setWeeklyMetrics] = React.useState< + const [$weeklyMetrics, $setWeeklyMetrics] = useState< API.UserMetricsResponse | undefined >(undefined) - const [$metricsConfig, $setMetricsConfig] = React.useState< + const [$metricsConfig, $setMetricsConfig] = useState< API.MetricsConfigResponse | undefined >(undefined) + const [$fetched, $setFetched] = useState(false) + useEffect(() => { + if (rawTab && validTabValue(rawTab)) { + $setActiveTab(rawTab as TabType) + } + }, [rawTab]) useEffect(() => { let isCanceled = false const fetchData = async () => { - if (!router.isReady) { - return - } + try { + if (!routerIsReady || $fetched) { + return + } + const [user, events, allTimeMetrics, weeklyMetrics, metricsConfig] = + await Promise.all([ + API.getUser(userId), + API.listEvents({ + userId, + limit: EVENTS_LIMIT, + }), + API.getUserAllTimeMetrics(userId), + API.getUserWeeklyMetrics(userId), + API.getMetricsConfig(), + ]) - if (!router.query.id || Array.isArray(router.query.id)) { - router.push(`/leaderboard?toast=${btoa('Unable to find that user')}`) - return - } + if (isCanceled) { + return + } - const [user, events, allTimeMetrics, weeklyMetrics, metricsConfig] = - await Promise.all([ - API.getUser(router.query.id), - API.listEvents({ - userId: router.query.id, - limit: EVENTS_LIMIT, - }), - API.getUserAllTimeMetrics(router.query.id), - API.getUserWeeklyMetrics(router.query.id), - API.getMetricsConfig(), - ]) + if ( + 'error' in user || + 'error' in events || + 'error' in allTimeMetrics || + 'error' in weeklyMetrics || + 'error' in metricsConfig + ) { + Router.push( + `/leaderboard?toast=${btoa( + 'An error occurred while fetching user data' + )}` + ) + return + } + $setUser(user) + $setEvents(events) + $setAllTimeMetrics(allTimeMetrics) + $setWeeklyMetrics(weeklyMetrics) + $setMetricsConfig(metricsConfig) + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e) - if (isCanceled) { - return + throw e } - - if ( - 'error' in user || - 'error' in events || - 'error' in allTimeMetrics || - 'error' in weeklyMetrics || - 'error' in metricsConfig - ) { - router.push( - `/leaderboard?toast=${btoa( - 'An error occurred while fetching user data' - )}` - ) - return - } - - $setUser(user) - $setEvents(events) - $setAllTimeMetrics(allTimeMetrics) - $setWeeklyMetrics(weeklyMetrics) - $setMetricsConfig(metricsConfig) } fetchData() return () => { isCanceled = true } - }, [router]) + }, [ + routerIsReady, + userId, + loginContext?.metadata?.id, + loginContext?.metadata?.graffiti, + $fetched, + ]) + + useEffect(() => { + if (!$user) { + return + } + $setFetched(true) + }, [$user]) - const id = ($user && $user.id && $user.id.toString()) || 'unknown' // Recent Activity hooks const { $hasPrevious, $hasNext, fetchPrevious, fetchNext } = - usePaginatedEvents(id, EVENTS_LIMIT, $events, $setEvents) + usePaginatedEvents(userId, EVENTS_LIMIT, $events, $setEvents) // Tab hooks - const [$activeTab, $setActiveTab] = React.useState('weekly') - const onTabChange = React.useCallback((tab: TabType) => { - $setActiveTab(tab) + const onTabChange = useCallback((t: TabType) => { + $setActiveTab(t) }, []) + if ( !$user || !$allTimeMetrics || !$metricsConfig || !$weeklyMetrics || - !$events || - id === 'unknown' + !$events ) { return } + const avatarColor = graffitiToColor($user.graffiti) const ordinalRank = numberToOrdinal($user.rank) @@ -200,6 +233,10 @@ export default function User({ loginContext }: Props) { {/* Tabs */} {/* Recent Activity */} @@ -254,7 +293,11 @@ export default function User({ loginContext }: Props) { )} - +