diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 58527080..74bd6a94 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -1,824 +1,287 @@ - - - Welcome to Manifold Markets - - - - - - - + + + + + + - - - + + - + + + - - - -
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
- -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- Hi {{name}}, thanks for joining Manifold - Markets!

We can't wait to see what questions you - will ask! -

-

- As a gift M$1000 has been credited to your - account - the equivalent of 10 USD. - -

-

- Click the buttons to see what you can do with - it! -

-
-
-
- -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- If you have any questions or feedback we'd - love to hear from you in our Discord server! -

-

-

- Looking forward to seeing you, -

-

- David from Manifold -

-
-

-
-
-
- -
-
- -
- - - - - - -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-

- This e-mail has been sent to {{name}}, - click here to unsubscribe. -

-
-
-
-
- -
-
- + } + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

+ Hi {{name}},

+
+
+
+

+ Welcome! Manifold Markets is a play-money prediction market platform where you can bet on + anything, from elections to Elon Musk to scientific papers to the NBA.

+
+
+
+

+ +

+
+
+

+
+ + + + +
+ + + + +
+ + Explore markets + +
+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
- - + +
+ + + +
+ +
+ + + + { - searchParameters.current = params - performQuery(true) - }} + onSearchParametersChanged={onSearchParametersChanged} /> void - querySortOptions?: { defaultFilter?: filter } & QuerySortOptions + useQuerySortLocalStorage?: boolean + useQuerySortUrlParams?: boolean user?: User | null }) { const { className, + defaultSort, + defaultFilter, additionalFilter, hideOrderSelector, onSearchParametersChanged, - querySortOptions, + useQuerySortLocalStorage, + useQuerySortUrlParams, user, } = props - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) + const savedSort = useQuerySortLocalStorage ? getSavedSort() : null + const initialSort = savedSort ?? defaultSort ?? 'score' + const querySortOpts = { useUrl: !!useQuerySortUrlParams } + const [sort, setSort] = useSort(initialSort, querySortOpts) + const [query, setQuery] = useQuery('', querySortOpts) + const [filter, setFilter] = useState(defaultFilter ?? 'open') + const [pillFilter, setPillFilter] = useState(undefined) + + useEffect(() => { + if (useQuerySortLocalStorage) { + setSavedSort(sort) + } + }, [sort]) const follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -208,12 +243,6 @@ function ContractSearchControls(props: { const pillGroups: { name: string; slug: string }[] = memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS - const [filter, setFilter] = useState( - querySortOptions?.defaultFilter ?? 'open' - ) - - const [pillFilter, setPillFilter] = useState(undefined) - const additionalFilters = [ additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 52f4e40b..a129fc3d 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -78,7 +78,7 @@ export function ContractCard(props: { className )} > - + {onClick ? ( )} - +

))} - - + + + + {showQuickBet ? ( diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 4a9d40af..71998b9d 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -34,6 +34,7 @@ import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' +import clsx from 'clsx' export type ShowTime = 'resolve-date' | 'close-date' @@ -83,9 +84,8 @@ export function MiscDetails(props: { {!hideGroupLink && groupLinks && groupLinks.length > 0 && ( - {groupLinks[0].name} )} @@ -93,18 +93,24 @@ export function MiscDetails(props: { ) } -export function AvatarDetails(props: { contract: Contract }) { - const { contract } = props +export function AvatarDetails(props: { + contract: Contract + className?: string + short?: boolean +}) { + const { contract, short, className } = props const { creatorName, creatorUsername } = contract return ( - + - + ) } diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index f62c3c85..05c66d56 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -106,11 +106,8 @@ export function CreatorContractsList(props: { return ( { - if (window.navigator.share) { - window.navigator.share({ - url: shareUrl, - title: contract.question, - }) - } else { - copyToClipboard(shareUrl) - toast.success('Link copied!', { - icon: linkIcon, - }) - } + copyToClipboard(shareUrl) + toast.success('Link copied!', { + icon: linkIcon, + }) track('copy share link') }} > @@ -73,7 +66,7 @@ export function ShareModal(props: { className="self-start" tweetText={getTweetText(contract, shareUrl)} /> - + diff --git a/web/components/editor/market-modal.tsx b/web/components/editor/market-modal.tsx index 0486b9e9..85b7a978 100644 --- a/web/components/editor/market-modal.tsx +++ b/web/components/editor/market-modal.tsx @@ -69,7 +69,6 @@ export function MarketModal(props: { 'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1' } cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} - querySortOptions={{ disableQueryString: true }} highlightOptions={{ contractIds: contracts.map((c) => c.id), highlightClassName: diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index 8678299b..cfbe78f0 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -1,12 +1,12 @@ -import React, { Fragment } from 'react' +import React from 'react' import { CodeIcon } from '@heroicons/react/outline' -import { Menu, Transition } from '@headlessui/react' +import { Menu } from '@headlessui/react' +import toast from 'react-hot-toast' import { Contract } from 'common/contract' import { contractPath } from 'web/lib/firebase/contracts' import { DOMAIN } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' -import { ToastClipboard } from 'web/components/toast-clipboard' import { track } from 'web/lib/service/analytics' export function embedCode(contract: Contract) { @@ -16,11 +16,10 @@ export function embedCode(contract: Contract) { return `` } -export function ShareEmbedButton(props: { - contract: Contract - toastClassName?: string -}) { - const { contract, toastClassName } = props +export function ShareEmbedButton(props: { contract: Contract }) { + const { contract } = props + + const codeIcon =

{ copyToClipboard(embedCode(contract)) + toast.success('Embed code copied!', { + icon: codeIcon, + }) track('copy embed code') }} > @@ -39,25 +41,9 @@ export function ShareEmbedButton(props: { color: '#9ca3af', // text-gray-400 }} > - ) } diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 2069ef72..f412e38b 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -34,16 +34,25 @@ export function UserLink(props: { username: string showUsername?: boolean className?: string - justFirstName?: boolean + short?: boolean }) { - const { name, username, showUsername, className, justFirstName } = props - + const { name, username, showUsername, className, short } = props + const firstName = name.split(' ')[0] + const maxLength = 10 + const shortName = + firstName.length >= 3 + ? firstName.length < maxLength + ? firstName + : firstName.substring(0, maxLength - 3) + '...' + : name.length > maxLength + ? name.substring(0, maxLength) + '...' + : name return ( - {justFirstName ? name.split(' ')[0] : name} + {short ? shortName : name} {showUsername && ` (@${username})`} ) diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index e917e4af..0a2834d0 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,9 +1,5 @@ -import { debounce } from 'lodash' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' -import { DEFAULT_SORT } from 'web/components/contract-search' - -const MARKETS_SORT = 'markets_sort' +import { useState } from 'react' +import { NextRouter, useRouter } from 'next/router' export type Sort = | 'newest' @@ -15,92 +11,55 @@ export type Sort = | 'last-updated' | 'score' -export function getSavedSort() { - // TODO: this obviously doesn't work with SSR, common sense would suggest - // that we should save things like this in cookies so the server has them - if (typeof window !== 'undefined') { - return localStorage.getItem(MARKETS_SORT) as Sort | null - } else { - return null +type UpdatedQueryParams = { [k: string]: string } +type QuerySortOpts = { useUrl: boolean } + +function withURLParams(location: Location, params: UpdatedQueryParams) { + const newParams = new URLSearchParams(location.search) + for (const [k, v] of Object.entries(params)) { + if (!v) { + newParams.delete(k) + } else { + newParams.set(k, v) + } } + const newUrl = new URL(location.href) + newUrl.search = newParams.toString() + return newUrl } -export interface QuerySortOptions { - defaultSort?: Sort - shouldLoadFromStorage?: boolean - /** Use normal react state instead of url query string */ - disableQueryString?: boolean +function updateURL(params: UpdatedQueryParams) { + // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 + const url = withURLParams(window.location, params).toString() + const updatedState = { ...window.history.state, as: url, url } + window.history.replaceState(updatedState, '', url) } -export function useQueryAndSortParams({ - defaultSort = DEFAULT_SORT, - shouldLoadFromStorage = true, - disableQueryString, -}: QuerySortOptions = {}) { +function getStringURLParam(router: NextRouter, k: string) { + const v = router.query[k] + return typeof v === 'string' ? v : null +} + +export function useQuery(defaultQuery: string, opts?: QuerySortOpts) { + const useUrl = opts?.useUrl ?? false const router = useRouter() - - const { s: sort, q: query } = router.query as { - q?: string - s?: Sort - } - - const setSort = (sort: Sort | undefined) => { - router.replace({ query: { ...router.query, s: sort } }, undefined, { - shallow: true, - }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') - } - } - - const [queryState, setQueryState] = useState(query) - - useEffect(() => { - setQueryState(query) - }, [query]) - - // Debounce router query update. - const pushQuery = useMemo( - () => - debounce((query: string | undefined) => { - const queryObj = { ...router.query, q: query } - if (!query) delete queryObj.q - router.replace({ query: queryObj }, undefined, { - shallow: true, - }) - }, 100), - [router] - ) - - const setQuery = (query: string | undefined) => { - setQueryState(query) - if (!disableQueryString) { - pushQuery(query) - } - } - - useEffect(() => { - // If there's no sort option, then set the one from localstorage - if (router.isReady && !sort && shouldLoadFromStorage) { - const localSort = localStorage.getItem(MARKETS_SORT) as Sort - if (localSort && localSort !== defaultSort) { - // Use replace to not break navigating back. - router.replace( - { query: { ...router.query, s: localSort } }, - undefined, - { shallow: true } - ) - } - } - }) - - // use normal state if querydisableQueryString - const [sortState, setSortState] = useState(defaultSort) - - return { - sort: disableQueryString ? sortState : sort ?? defaultSort, - query: queryState ?? '', - setSort: disableQueryString ? setSortState : setSort, - setQuery, + const initialQuery = useUrl ? getStringURLParam(router, 'q') : null + const [query, setQuery] = useState(initialQuery ?? defaultQuery) + if (!useUrl) { + return [query, setQuery] as const + } else { + return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const + } +} + +export function useSort(defaultSort: Sort, opts?: QuerySortOpts) { + const useUrl = opts?.useUrl ?? false + const router = useRouter() + const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null + const [sort, setSort] = useState(initialSort ?? defaultSort) + if (!useUrl) { + return [sort, setSort] as const + } else { + return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const } } diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index f56c82d1..ec480269 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -3,16 +3,11 @@ import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' -import { - QuerySortOptions, - Sort, - useQueryAndSortParams, -} from 'web/hooks/use-sort-and-query-params' +import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' const MAX_CONTRACTS_RENDERED = 100 export default function ContractSearchFirestore(props: { - querySortOptions?: QuerySortOptions additionalFilter?: { creatorId?: string tag?: string @@ -21,10 +16,9 @@ export default function ContractSearchFirestore(props: { } }) { const contracts = useContracts() - const { querySortOptions, additionalFilter } = props - - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) + const { additionalFilter } = props + const [query, setQuery] = useQuery('', { useUrl: true }) + const [sort, setSort] = useSort('score', { useUrl: true }) let matches = (contracts ?? []).filter((c) => searchInAny( diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index cd4b7344..c5255974 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -31,7 +31,6 @@ import { CreateQuestionButton } from 'web/components/create-question-button' import React, { useState } from 'react' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' @@ -196,11 +195,8 @@ export default function GroupPage(props: { const questionsTab = ( ) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 839a08f3..1fd163ea 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -4,8 +4,7 @@ import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' -import { getSavedSort } from 'web/hooks/use-sort-and-query-params' -import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search' +import { ContractSearch } from 'web/components/contract-search' import { Contract } from 'common/contract' import { User } from 'common/user' import { ContractPageContent } from './[username]/[contractSlug]' @@ -35,10 +34,8 @@ const Home = (props: { auth: { user: User } }) => {
{ // Show contract without navigating to contract page. setContract(c) @@ -104,13 +101,19 @@ const useContractPage = () => { window.history.pushState = function () { // eslint-disable-next-line prefer-rest-params - pushState.apply(history, arguments as any) + const args = [...(arguments as any)] as any + // Discard NextJS router state. + args[0] = null + pushState.apply(history, args) updateContract() } window.history.replaceState = function () { // eslint-disable-next-line prefer-rest-params - replaceState.apply(history, arguments as any) + const args = [...(arguments as any)] as any + // Discard NextJS router state. + args[0] = null + replaceState.apply(history, args) updateContract() } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index c875dbf2..7d06c481 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -40,6 +40,7 @@ import { safeLocalStorage } from 'web/lib/util/local' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' +import { SEO } from 'web/components/SEO' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' @@ -68,6 +69,8 @@ export default function Notifications(props: {
+ <SEO title="Notifications" description="Manifold user notifications" /> + <div> <Tabs currentPageForAnalytics={'notifications'} @@ -437,7 +440,7 @@ function IncomeNotificationItem(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'mr-1 flex-shrink-0'} - justFirstName={true} + short={true} /> ))} {getReasonForShowingIncomeNotification(false)} {' on'} @@ -606,7 +609,7 @@ function NotificationItem(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'mr-0 flex-shrink-0'} - justFirstName={true} + short={true} /> <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> @@ -678,7 +681,7 @@ function NotificationItem(props: { name={sourceUserName || ''} username={sourceUserUsername || ''} className={'relative mr-1 flex-shrink-0'} - justFirstName={true} + short={true} /> )} {getReasonForShowingNotification( diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index c1dce29e..f2554f49 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -15,11 +15,8 @@ export default function TagPage() { <Title text={`#${tag}`} /> <ContractSearch user={user} - querySortOptions={{ - defaultSort: 'newest', - defaultFilter: 'all', - shouldLoadFromStorage: true, - }} + defaultSort="newest" + defaultFilter="all" additionalFilter={{ tag }} /> </Page>
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

This e-mail has been sent to {{name}}, click here to unsubscribe.

+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 7501309a..8fb5179d 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -30,7 +30,15 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(['YES', 'NO']), - limitProb: z.number().gte(0.001).lte(0.999).optional(), + limitProb: z + .number() + .gte(0.001) + .lte(0.999) + .refine( + (p) => Math.round(p * 100) === p * 100, + 'limitProb must be in increments of 0.01 (i.e. whole percentage points)' + ) + .optional(), }) const freeResponseSchema = z.object({ diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 19b6066e..6ca06cbb 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,6 +1,6 @@ import Router from 'next/router' import clsx from 'clsx' -import { MouseEvent } from 'react' +import { MouseEvent, useState } from 'react' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' export function Avatar(props: { @@ -10,7 +10,8 @@ export function Avatar(props: { size?: number | 'xs' | 'sm' className?: string }) { - const { username, avatarUrl, noLink, size, className } = props + const { username, noLink, size, className } = props + const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const onClick = @@ -35,6 +36,11 @@ export function Avatar(props: { src={avatarUrl} onClick={onClick} alt={username} + onError={() => { + // If the image doesn't load, clear the avatarUrl to show the default + // Mostly for localhost, when getting a 403 from googleusercontent + setAvatarUrl('') + }} /> ) : ( void @@ -79,10 +89,13 @@ export function ContractSearch(props: { hideQuickBet?: boolean } headerClassName?: string + useQuerySortLocalStorage?: boolean + useQuerySortUrlParams?: boolean }) { const { user, - querySortOptions, + defaultSort, + defaultFilter, additionalFilter, onContractClick, overrideGridClassName, @@ -90,6 +103,8 @@ export function ContractSearch(props: { cardHideOptions, highlightOptions, headerClassName, + useQuerySortLocalStorage, + useQuerySortUrlParams, } = props const [numPages, setNumPages] = useState(1) @@ -124,6 +139,7 @@ export function ContractSearch(props: { setNumPages(results.nbPages) if (freshQuery) { setPages([newPage]) + window.scrollTo(0, 0) } else { setPages((pages) => [...pages, newPage]) } @@ -132,31 +148,33 @@ export function ContractSearch(props: { } } + const onSearchParametersChanged = useRef( + debounce((params) => { + searchParameters.current = params + performQuery(true) + }, 100) + ).current + const contracts = pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { - return ( - - ) + return } return (