Merge branch 'main' into likes-market-tips

This commit is contained in:
Ian Philips 2022-08-29 10:21:51 -06:00 committed by GitHub
commit 77d927a4ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
114 changed files with 2683 additions and 2304 deletions

43
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Run linter (remove unused imports)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
# mqp - i generated a personal token to use for these writes -- it's unclear
# why, but the default token didn't work, even when i gave it max permissions
jobs:
lint:
name: Auto-lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.FORMATTER_ACCESS_TOKEN }}
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run lint script
run: yarn lint
- name: Commit any lint changes
if: always()
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Auto-remove unused imports
branch: ${{ github.head_ref }}

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
},
},
],

View File

@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) {
const sortedBets = sortBy(yourBets, 'createdTime')
for (const bet of sortedBets) {
const { outcome, shares, amount } = bet
if (floatingEqual(shares, 0)) continue
if (amount > 0) {
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount

View File

@ -10,7 +10,7 @@ import {
import { PortfolioMetrics, User } from './user'
import { filterDefined } from './util/array'
const LOAN_DAILY_RATE = 0.01
const LOAN_DAILY_RATE = 0.02
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
const netValue = investedValue - loanTotal

View File

@ -8,11 +8,11 @@
},
"sideEffects": false,
"dependencies": {
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"lodash": "4.17.21"
},
"devDependencies": {

12
common/post.ts Normal file
View File

@ -0,0 +1,12 @@
import { JSONContent } from '@tiptap/core'
export type Post = {
id: string
title: string
content: JSONContent
creatorId: string // User id
createdTime: number
slug: string
}
export const MAX_POST_TITLE_LENGTH = 480

View File

@ -44,6 +44,7 @@ export type User = {
currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number
isBannedFromPosting?: boolean
}
export type PrivateUser = {

View File

@ -1,2 +1,3 @@
export const HOUR_MS = 60 * 60 * 1000
export const MINUTE_MS = 60 * 1000
export const HOUR_MS = 60 * MINUTE_MS
export const DAY_MS = 24 * HOUR_MS

View File

@ -40,6 +40,10 @@
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "commentType",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"

View File

@ -180,5 +180,14 @@ service cloud.firestore {
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
}
}
match /posts/{postId} {
allow read;
allow update: if request.auth.uid == resource.data.creatorId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'content']);
allow delete: if request.auth.uid == resource.data.creatorId;
}
}
}

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'],
ignorePatterns: ['dist', 'lib'],
env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_',
},
],
'unused-imports/no-unused-imports': 'error',
},
},
],

View File

@ -26,11 +26,11 @@
"dependencies": {
"@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",

View File

@ -0,0 +1,83 @@
import * as admin from 'firebase-admin'
import { getUser } from './utils'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
content: contentSchema,
})
export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { title, content } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
if (!creator)
throw new APIError(400, 'No user exists with the authenticated user ID.')
console.log('creating post owned by', creator.username, 'titled', title)
const slug = await getSlug(title)
const postRef = firestore.collection('posts').doc()
const post: Post = {
id: postRef.id,
creatorId: creator.id,
slug,
title,
createdTime: Date.now(),
content: content,
}
await postRef.create(post)
return { status: 'success', post }
})
export const getSlug = async (title: string) => {
const proposedSlug = slugify(title)
const preexistingPost = await getPostFromSlug(proposedSlug)
return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug
}
export async function getPostFromSlug(slug: string) {
const firestore = admin.firestore()
const snap = await firestore
.collection('posts')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Post)
}

View File

@ -1,12 +1,11 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
@ -33,41 +32,52 @@
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
@ -75,10 +85,7 @@
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -266,19 +218,15 @@
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -286,37 +234,26 @@
margin: 0;
padding: 5px 0;
font-weight: bold;
"
valign="top"
>
" valign="top">
<div>
<img
src="{{avatarUrl}}"
width="30"
height="30"
style="
<img src="{{avatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
" alt="" />
{{name}}
</div>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -324,40 +261,29 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
" valign="top">
<div style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{answer}}</span
>
">
<span style="white-space: pre-line">{{answer}}</span>
</div>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -375,23 +301,16 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View answer</span
></span
>
">View answer</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
@ -404,9 +323,7 @@
</td>
</tr>
</table>
<div
class="footer"
style="
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -415,28 +332,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -446,14 +355,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
" align="center" valign="top">
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -461,12 +365,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -474,26 +374,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,12 +1,11 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
@ -33,41 +32,52 @@
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
@ -75,10 +85,7 @@
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
" valign="top">
You asked
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
" valign="top">
<a href="{{url}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
">
{{question}}</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
" valign="top">
<h2 class="aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
" align="center">
Market closed
</h2>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -396,116 +318,91 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
" valign="top">
Hi {{name}},
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
A market you created has closed. It's attracted
<span style="font-weight: bold">{{volume}}</span>
in bets — congrats!
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Resolve your market to earn {{creatorFee}} as the
creator commission.
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Thanks,
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Manifold Team
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
">
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{url}}"
target="_blank"
style="
<a href="{{url}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -523,23 +420,16 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
@ -552,9 +442,7 @@
</td>
</tr>
</table>
<div
class="footer"
style="
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -563,28 +451,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -594,14 +474,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
" align="center" valign="top">
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -609,12 +484,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -622,26 +493,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,12 +1,11 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
@ -33,41 +32,52 @@
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
@ -75,10 +85,7 @@
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0;
padding: 0 0 0px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -266,59 +218,42 @@
width: 80%;
margin: 40px auto;
margin-top: 10px;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 5px 0;
"
valign="top"
>
" valign="top">
<div>
<img
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
<img src="{{commentorAvatarUrl}}" width="30" height="30" style="
border-radius: 30px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
"
alt=""
/>
<span style="font-weight: bold"
>{{commentorName}}</span
>
" alt="" />
<span style="font-weight: bold">{{commentorName}}</span>
{{betDescription}}
</div>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -326,40 +261,29 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
<div
style="
" valign="top">
<div style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<span style="white-space: pre-line"
>{{comment}}</span
>
">
<span style="white-space: pre-line">{{comment}}</span>
</div>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
">
<td style="padding: 20px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{marketUrl}}"
target="_blank"
style="
<a href="{{marketUrl}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -377,23 +301,16 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View comment</span
></span
>
">View comment</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
@ -406,9 +323,7 @@
</td>
</tr>
</table>
<div
class="footer"
style="
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -417,28 +332,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -448,14 +355,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
" align="center" valign="top">
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -463,12 +365,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -476,26 +374,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -1,12 +1,11 @@
<!DOCTYPE html>
<html
style="
<html style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
@ -33,41 +32,52 @@
body {
padding: 0 !important;
}
h1 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h2 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h3 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
@ -75,10 +85,7 @@
</style>
</head>
<body
itemscope
itemtype="http://schema.org/EmailMessage"
style="
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<table
class="body-wrap"
style="
" bgcolor="#f6f6f6">
<table class="body-wrap" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
width: 100%;
background-color: #f6f6f6;
margin: 0;
"
bgcolor="#f6f6f6"
>
<tr
style="
" bgcolor="#f6f6f6">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
<td
class="container"
width="600"
style="
" valign="top"></td>
<td class="container" width="600" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important;
clear: both !important;
margin: 0 auto;
"
valign="top"
>
<div
class="content"
style="
" valign="top">
<div class="content" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -147,14 +136,8 @@
display: block;
margin: 0 auto;
padding: 20px;
"
>
<table
class="main"
width="100%"
cellpadding="0"
cellspacing="0"
style="
">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff;
margin: 0;
border: 1px solid #e9e9e9;
"
bgcolor="#fff"
>
<tr
style="
" bgcolor="#fff">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-wrap aligncenter"
style="
">
<td class="content-wrap aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -183,35 +160,23 @@
text-align: center;
margin: 0;
padding: 20px;
"
align="center"
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
" align="center" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
width: 90%;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -220,30 +185,22 @@
margin: 0;
padding: 0 0 40px 0;
text-align: left;
"
valign="top"
>
<img
src="https://manifold.markets/logo-banner.png"
width="300"
style="height: auto"
alt="Manifold Markets"
/>
" valign="top">
<a href="https://manifold.markets" target="_blank">
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
alt="Manifold Markets" />
</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
padding: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -252,24 +209,18 @@
margin: 0;
padding: 0 0 6px 0;
text-align: left;
"
valign="top"
>
" valign="top">
{{creatorName}} asked
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -277,12 +228,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 20px;
"
valign="top"
>
<a
href="{{url}}"
style="
" valign="top">
<a href="{{url}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -295,24 +242,18 @@
color: #4337c9;
display: block;
text-decoration: none;
"
>
{{question}}</a
>
">
{{question}}</a>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="content-block"
style="
">
<td class="content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -320,12 +261,8 @@
vertical-align: top;
margin: 0;
padding: 0 0 0px;
"
valign="top"
>
<h2
class="aligncenter"
style="
" valign="top">
<h2 class="aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
'Lucida Grande', sans-serif;
box-sizing: border-box;
@ -335,25 +272,19 @@
font-weight: 500;
text-align: center;
margin: 10px 0 0;
"
align="center"
>
" align="center">
Resolved {{outcome}}
</h2>
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
class="content-block aligncenter"
style="
">
<td class="content-block aligncenter" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -362,13 +293,8 @@
text-align: center;
margin: 0;
padding: 0;
"
align="center"
valign="top"
>
<table
class="invoice"
style="
" align="center" valign="top">
<table class="invoice" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -376,19 +302,15 @@
text-align: left;
width: 80%;
margin: 40px auto;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
<td
style="
">
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -396,138 +318,105 @@
vertical-align: top;
margin: 0;
padding: 5px 0;
"
valign="top"
>
" valign="top">
Dear {{name}},
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
A market you bet in has been resolved!
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Your investment was
<span style="font-weight: bold"
>M$ {{investment}}</span
>.
<br
style="
<span style="font-weight: bold">{{investment}}</span>.
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Your payout is
<span style="font-weight: bold"
>M$ {{payout}}</span
>.
<br
style="
<span style="font-weight: bold">{{payout}}</span>.
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Thanks,
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
Manifold Team
<br
style="
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
<br
style="
" />
<br style="
font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
/>
" />
</td>
</tr>
<tr
style="
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
font-size: 16px;
margin: 0;
"
>
">
<td style="padding: 10px 0 0 0; margin: 0">
<div align="center">
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
<a
href="{{url}}"
target="_blank"
style="
<a href="{{url}}" target="_blank" style="
box-sizing: border-box;
display: inline-block;
font-family: arial, helvetica, sans-serif;
@ -545,23 +434,16 @@
word-break: break-word;
word-wrap: break-word;
mso-border-alt: none;
"
>
<span
style="
">
<span style="
display: block;
padding: 10px 20px;
line-height: 120%;
"
><span
style="
"><span style="
font-size: 16px;
font-weight: bold;
line-height: 18.8px;
"
>View market</span
></span
>
">View market</span></span>
</a>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
</div>
@ -574,9 +456,7 @@
</td>
</tr>
</table>
<div
class="footer"
style="
<div class="footer" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
@ -585,28 +465,20 @@
color: #999;
margin: 0;
padding: 20px;
"
>
<table
width="100%"
style="
">
<table width="100%" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<tr
style="
">
<tr style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
margin: 0;
"
>
<td
class="aligncenter content-block"
style="
">
<td class="aligncenter content-block" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -616,14 +488,9 @@
text-align: center;
margin: 0;
padding: 0 0 20px;
"
align="center"
valign="top"
>
" align="center" valign="top">
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
<a href="https://discord.gg/eHQBNBqXuh" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -631,12 +498,8 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>our Discord</a
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif;
box-sizing: border-box;
@ -644,26 +507,22 @@
color: #999;
text-decoration: underline;
margin: 0;
"
>unsubscribe</a
>.
">unsubscribe</a>.
</td>
</tr>
</table>
</div>
</div>
</td>
<td
style="
<td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
vertical-align: top;
margin: 0;
"
valign="top"
></td>
" valign="top"></td>
</tr>
</table>
</body>
</html>

View File

@ -53,22 +53,29 @@ export const sendMarketResolutionEmail = async (
const subject = `Resolved ${outcome}: ${contract.question}`
// const creatorPayoutText =
// userId === creator.id
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
// : ''
const creatorPayoutText =
creatorPayout >= 1 && userId === creator.id
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
: ''
const emailType = 'market-resolved'
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
const displayedInvestment =
Number.isNaN(investment) || investment < 0
? formatMoney(0)
: formatMoney(investment)
const displayedPayout = formatMoney(payout)
const templateData: market_resolved_template = {
userId: user.id,
name: user.name,
creatorName: creator.name,
question: contract.question,
outcome,
investment: `${Math.floor(investment)}`,
payout: `${Math.floor(payout)}`,
investment: displayedInvestment,
payout: displayedPayout + creatorPayoutText,
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
unsubscribeUrl,
}

View File

@ -73,6 +73,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -98,6 +99,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
const createPostFunction = toCloudFunction(createpost)
export {
healthFunction as health,
@ -121,4 +123,5 @@ export {
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken,
createPostFunction as createpost,
}

View File

@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { floatingEqual } from '../../common/util/math'
export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (trans) => {
@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => {
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) {
if (floatingEqual(netAmount, 0)) {
return { status: 'success' }
}
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)

View File

@ -0,0 +1,39 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, log, writeAsync } from '../utils'
import { Bet } from '../../../common/bet'
import { groupBy, mapValues, sortBy, uniq } from 'lodash'
initAdmin()
const firestore = admin.firestore()
const getBettorsByContractId = async () => {
const bets = await getValues<Bet>(firestore.collectionGroup('bets'))
log(`Loaded ${bets.length} bets.`)
const betsByContractId = groupBy(bets, 'contractId')
return mapValues(betsByContractId, (bets) =>
uniq(sortBy(bets, 'createdTime').map((bet) => bet.userId))
)
}
const updateUniqueBettors = async () => {
const bettorsByContractId = await getBettorsByContractId()
const updates = Object.entries(bettorsByContractId).map(
([contractId, userIds]) => {
const update = {
uniqueBettorIds: userIds,
uniqueBettorCount: userIds.length,
}
const docRef = firestore.collection('contracts').doc(contractId)
return { doc: docRef, fields: update }
}
)
log(`Updating ${updates.length} contracts.`)
await writeAsync(firestore, updates)
log(`Updated all contracts.`)
}
if (require.main === module) {
updateUniqueBettors()
}

View File

@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`)

View File

@ -55,7 +55,9 @@ export const updateMetricsCore = async () => {
const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts.map((contract) => {
const contractUpdates = contracts
.filter((contract) => contract.id)
.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
return {
doc: firestore.collection('contracts').doc(contract.id),

View File

@ -4,6 +4,7 @@ import { chunk } from 'lodash'
import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group'
import { Post } from 'common/post'
export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args)
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId)
}
export const getPost = (postId: string) => {
return getDoc<Post>('posts', postId)
}
export const getUser = (userId: string) => {
return getDoc<User>('users', userId)
}

View File

@ -8,20 +8,22 @@
"web"
],
"scripts": {
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)",
"lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix"
},
"dependencies": {},
"devDependencies": {
"@types/node": "16.11.11",
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"@types/node": "16.11.11",
"concurrently": "6.5.1",
"eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "^2.0.0",
"nodemon": "2.0.19",
"prettier": "2.5.0",
"typescript": "4.6.4",
"ts-node": "10.9.1",
"nodemon": "2.0.19"
"typescript": "4.6.4"
},
"resolutions": {
"@types/react": "17.0.43"

View File

@ -1,6 +1,6 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['lodash'],
plugins: ['lodash', 'unused-imports'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
@ -22,6 +22,7 @@ module.exports = {
'@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error',
},
env: {
browser: true,

View File

@ -1,38 +0,0 @@
import clsx from 'clsx'
import { useState, ReactNode } from 'react'
export function AdvancedPanel(props: { children: ReactNode }) {
const { children } = props
const [collapsed, setCollapsed] = useState(true)
return (
<div
tabIndex={0}
className={clsx(
'collapse collapse-arrow relative',
collapsed ? 'collapse-close' : 'collapse-open'
)}
>
<div
onClick={() => setCollapsed((collapsed) => !collapsed)}
className="cursor-pointer"
>
<div className="mt-4 mr-6 text-right text-sm text-gray-500">
Advanced
</div>
<div
className="collapse-title absolute h-0 min-h-0 w-0 p-0"
style={{
top: -2,
right: -15,
color: '#6a7280' /* gray-500 */,
}}
/>
</div>
<div className="collapse-content m-0 !bg-transparent !p-0">
{children}
</div>
</div>
)
}

View File

@ -1,4 +1,5 @@
import { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size'
@ -26,8 +27,10 @@ export function DailyCountChart(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}
@ -78,8 +81,10 @@ export function DailyPercentChart(props: {
return (
<div
className="w-full overflow-hidden"
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
className={clsx(
'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
>
<ResponsiveLine
data={data}

View File

@ -24,7 +24,7 @@ import {
} from 'common/calculate-dpm'
import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics'
import { SignUpPrompt } from '../sign-up-prompt'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box'
@ -204,7 +204,7 @@ export function AnswerBetPanel(props: {
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : (
<SignUpPrompt />
<BetSignUpPrompt />
)}
</Col>
)

View File

@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours')
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
? new Date(contract.createdTime)
: hoursAgo.toDate()
const startDate = new Date(contract.createdTime)
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours').toDate()
: latestTime.toDate()
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
xScale={{
type: 'time',
min: startDate,
max: latestTime.toDate(),
max: endDate,
}}
xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
format: (time) =>
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ scheme: 'pastel1' }}
colors={[
'#fca5a5', // red-300
'#a5b4fc', // indigo-300
'#86efac', // green-300
'#fef08a', // yellow-200
'#fdba74', // orange-300
'#c084fc', // purple-400
]}
pointSize={0}
curve="stepAfter"
enableSlices="x"
@ -156,7 +165,11 @@ function formatTime(
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
if (
d.add(1, 'minute').isAfter(Date.now()) &&
d.subtract(1, 'minute').isBefore(Date.now())
)
return 'Now'
let format: string
if (d.isSame(Date.now(), 'day')) {

View File

@ -25,6 +25,7 @@ import { Bet } from 'common/bet'
import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash'
import { Button } from '../button'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -115,6 +116,8 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
if (user?.isBannedFromPosting) return <></>
return (
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2">
@ -201,12 +204,14 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</button>
) : (
text && (
<button
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
<Button
color="green"
size="lg"
className="self-end whitespace-nowrap "
onClick={withTracking(firebaseLogin, 'answer panel sign in')}
>
Sign in
</button>
Add my answer
</Button>
)
)}
</Col>

View File

@ -19,6 +19,15 @@ import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
type AuthUser = undefined | null | UserAndPrivateUser
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage =
typeof window !== 'undefined'
? window.localStorage
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token')
@ -46,7 +55,9 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser])
useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => {
return onIdTokenChanged(
auth,
async (fbUser) => {
if (fbUser) {
setTokenCookies({
id: await fbUser.getIdToken(),
@ -68,7 +79,11 @@ export function AuthProvider(props: {
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
}
})
},
(e) => {
console.error(e)
}
)
}, [setAuthUser])
const uid = authUser?.user.id

View File

@ -9,7 +9,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
import { Button } from 'web/components/button'
import { firebaseLogin } from 'web/lib/firebase/users'
import { BetSignUpPrompt } from './sign-up-prompt'
/** Button that opens BetPanel in a new modal */
export default function BetButton(props: {
@ -32,15 +32,17 @@ export default function BetButton(props: {
return (
<>
<Col className={clsx('items-center', className)}>
{user ? (
<Button
size={'lg'}
size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
onClick={() => {
!user ? firebaseLogin() : setOpen(true)
}}
onClick={() => setOpen(true)}
>
{user ? 'Bet' : 'Sign up to Bet'}
Bet
</Button>
) : (
<BetSignUpPrompt />
)}
{user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>

View File

@ -12,7 +12,7 @@ import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { SignUpPrompt } from './sign-up-prompt'
import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid'
@ -112,7 +112,7 @@ export function BetInline(props: {
: 'Submit'}
</Button>
)}
<SignUpPrompt size="xs" />
<BetSignUpPrompt size="xs" />
<button
onClick={() => {
setProbAfter(undefined)

View File

@ -31,7 +31,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { SignUpPrompt } from './sign-up-prompt'
import { BetSignUpPrompt } from './sign-up-prompt'
import { isIOS } from 'web/lib/util/device'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
@ -86,7 +86,7 @@ export function BetPanel(props: {
unfilledBets={unfilledBets}
/>
<SignUpPrompt />
<BetSignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col>
@ -146,7 +146,7 @@ export function SimpleBetPanel(props: {
onBuySuccess={onBetSuccess}
/>
<SignUpPrompt />
<BetSignUpPrompt />
{!user && <PlayMoneyDisclaimer />}
</Col>
@ -560,7 +560,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4">
<Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div>
<ProbabilityOrNumericInput
contract={contract}
@ -571,7 +571,7 @@ function LimitOrderPanel(props: {
</Col>
<Col className="gap-2">
<div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div>
<ProbabilityOrNumericInput
contract={contract}

View File

@ -1,14 +1,5 @@
import Link from 'next/link'
import {
Dictionary,
keyBy,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
uniq,
} from 'lodash'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
@ -28,7 +19,6 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
getContractFromId,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api'
@ -55,10 +45,10 @@ import { SellSharesModal } from './sell-modal'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { floatingEqual } from 'common/util/math'
import { filterDefined } from 'common/util/array'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
import { UserLink } from 'web/components/user-link'
import { useUserBetContracts } from 'web/hooks/use-contracts'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -72,26 +62,22 @@ export function BetsList(props: { user: User }) {
const signedInUser = useUser()
const isYourBets = user.id === signedInUser?.id
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
const userBets = useUserBets(user.id, { includeRedemptions: true })
const [contractsById, setContractsById] = useState<
Dictionary<Contract> | undefined
>()
const userBets = useUserBets(user.id)
// Hide bets before 06-01-2022 if this isn't your own profile
// NOTE: This means public profits also begin on 06-01-2022 as well.
const bets = useMemo(
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
() =>
userBets?.filter(
(bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0)
),
[userBets, hideBetsBefore]
)
useEffect(() => {
if (bets) {
const contractIds = uniq(bets.map((b) => b.contractId))
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
setContractsById(keyBy(filterDefined(contracts), 'id'))
})
}
}, [bets])
const contractList = useUserBetContracts(user.id)
const contractsById = useMemo(() => {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const [sort, setSort] = useState<BetSort>('newest')
const [filter, setFilter] = useState<BetFilter>('open')
@ -405,7 +391,8 @@ export function BetsSummary(props: {
const isClosed = closeTime && Date.now() > closeTime
const bets = props.bets.filter((b) => !b.isAnte)
const { hasShares } = getContractBetMetrics(contract, bets)
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const excludeSalesAndAntes = bets.filter(
(b) => !b.isAnte && !b.isSold && !b.sale
@ -416,8 +403,6 @@ export function BetsSummary(props: {
const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'NO')
)
const { invested, profitPercent, payout, profit, totalShares } =
getContractBetMetrics(contract, bets)
const [showSellModal, setShowSellModal] = useState(false)
const user = useUser()
@ -520,7 +505,7 @@ export function BetsSummary(props: {
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Current value
Expected value
</div>
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
</Col>

View File

@ -37,8 +37,8 @@ export function Button(props: {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base',
'2xl': 'px-6 py-3 text-xl',
xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl font-semibold',
}[size]
return (
@ -52,9 +52,9 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
color === 'gradient' &&
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
className

View File

@ -2,7 +2,7 @@ import { User } from 'common/user'
import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react'
import { SignUpPrompt } from 'web/components/sign-up-prompt'
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
@ -27,7 +27,7 @@ export function AcceptChallengeButton(props: {
setErrorText('')
}, [open])
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
const iAcceptChallenge = () => {
setLoading(true)

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'
import { Comment, ContractComment } from 'common/comment'
import { ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments'
import { getUserCommentsQuery } from 'web/lib/firebase/comments'
import { usePagination } from 'web/hooks/use-pagination'
import { SiteLink } from './site-link'
import { Row } from './layout/row'
import { Avatar } from './avatar'
@ -10,11 +9,15 @@ import { RelativeTimestamp } from './relative-timestamp'
import { User } from 'common/user'
import { Col } from './layout/col'
import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator'
import { UserLink } from 'web/components/user-link'
import { PaginationNextPrev } from 'web/components/pagination'
const COMMENTS_PER_PAGE = 50
type ContractKey = {
contractId: string
contractSlug: string
contractQuestion: string
}
function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't
@ -24,67 +27,83 @@ function contractPath(slug: string) {
export function UserCommentsList(props: { user: User }) {
const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
useEffect(() => {
getUsersComments(user.id).then((cs) => {
// we don't show comments in groups here atm, just comments on contracts
setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
const pageComments = groupConsecutive(getItems(), (c) => {
return {
contractId: c.contractId,
contractQuestion: c.contractQuestion,
contractSlug: c.contractSlug,
}
})
}, [user.id])
if (comments == null) {
if (isLoading) {
return <LoadingIndicator />
}
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
return { question: c.contractQuestion, slug: c.contractSlug }
})
if (pageComments.length === 0) {
if (isStart && isEnd) {
return <p>This user hasn't made any comments yet.</p>
} else {
// this can happen if their comment count is a multiple of page size
return <p>No more comments to display.</p>
}
}
return (
<Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => {
return <ProfileCommentGroup key={i} groupKey={key} items={items} />
})}
<nav
className="border-t border-gray-200 px-4 py-3 sm:px-6"
aria-label="Pagination"
>
<PaginationNextPrev
prev={!isStart ? 'Previous' : null}
next={!isEnd ? 'Next' : null}
onClickPrev={getPrev}
onClickNext={getNext}
scrollToTop={true}
/>
</nav>
</Col>
)
}
function ProfileCommentGroup(props: {
groupKey: ContractKey
items: ContractComment[]
}) {
const { groupKey, items } = props
const { contractSlug, contractQuestion } = groupKey
const path = contractPath(contractSlug)
return (
<div key={start + i} className="border-b p-5">
<div className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
href={path}
>
{key.question}
{contractQuestion}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
{items.map((c) => (
<ProfileComment key={c.id} comment={c} />
))}
</Col>
</div>
)
})}
<Pagination
page={page}
itemsPerPage={COMMENTS_PER_PAGE}
totalItems={comments.length}
setPage={setPage}
/>
</Col>
)
}
function ProfileComment(props: { comment: Comment; className?: string }) {
const { comment, className } = props
function ProfileComment(props: { comment: ContractComment }) {
const { comment } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
// TODO: find and attach relevant bets by comment betId at some point
return (
<Row className={className}>
<Row className="relative flex items-start space-x-3">
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500">

View File

@ -1,9 +0,0 @@
import { SparklesIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<SparklesIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -32,23 +32,28 @@ import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip'
import { useWindowSize } from 'web/hooks/use-window-size'
export function ContractCard(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
className?: string
questionClass?: string
onClick?: () => void
hideQuickBet?: boolean
hideGroupLink?: boolean
trackingPostfix?: string
}) {
const {
showHotVolume,
showTime,
className,
questionClass,
onClick,
hideQuickBet,
hideGroupLink,
trackingPostfix,
} = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
@ -59,7 +64,11 @@ export function ContractCard(props: {
const marketClosed =
(contract.closeTime || Infinity) < Date.now() || !!resolution
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const showQuickBet =
!isMobile &&
user &&
!marketClosed &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
@ -68,45 +77,20 @@ export function ContractCard(props: {
return (
<Row
className={clsx(
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
'group relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
className
)}
>
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6">
{onClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card', {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback('click market card', {
slug: contract.slug,
contractId: contract.id,
})}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
<Col className="relative flex-1 gap-3 py-4 pb-12 pl-6">
<AvatarDetails
contract={contract}
className={'hidden md:inline-flex'}
/>
<p
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
className={clsx(
'break-anywhere font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2',
questionClass
)}
>
{question}
</p>
@ -124,7 +108,7 @@ export function ContractCard(props: {
))}
</Col>
{showQuickBet ? (
<QuickBet contract={contract} user={user} />
<QuickBet contract={contract} user={user} className="z-10" />
) : (
<>
{outcomeType === 'BINARY' && (
@ -165,11 +149,7 @@ export function ContractCard(props: {
showQuickBet ? 'w-[85%]' : 'w-full'
)}
>
<AvatarDetails
contract={contract}
short={true}
className={'block md:hidden'}
/>
<AvatarDetails contract={contract} short={true} className="md:hidden" />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
@ -177,6 +157,38 @@ export function ContractCard(props: {
hideGroupLink={hideGroupLink}
/>
</Row>
{/* Add click layer */}
{onClick ? (
<a
className="absolute top-0 left-0 right-0 bottom-0"
href={contractPath(contract)}
onClick={(e) => {
// Let the browser handle the link click (opens in new tab).
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
track('click market card' + (trackingPostfix ?? ''), {
slug: contract.slug,
contractId: contract.id,
})
onClick()
}}
/>
) : (
<Link href={contractPath(contract)}>
<a
onClick={trackCallback(
'click market card' + (trackingPostfix ?? ''),
{
slug: contract.slug,
contractId: contract.id,
}
)}
className="absolute top-0 left-0 right-0 bottom-0"
/>
</Link>
)}
</Row>
)
}

View File

@ -128,6 +128,7 @@ function EditQuestion(props: {
function joinContent(oldContent: ContentType, newContent: string) {
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
editor.commands.focus('end')
insertContent(editor, newContent)
return editor.getJSON()
}

View File

@ -5,11 +5,14 @@ import {
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import Router from 'next/router'
import clsx from 'clsx'
import { Editor } from '@tiptap/react'
import dayjs from 'dayjs'
import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format'
import { Contract, updateContract } from 'web/lib/firebase/contracts'
import dayjs from 'dayjs'
import { DateTimeTooltip } from '../datetime-tooltip'
import { fromNow } from 'web/lib/util/time'
import { Avatar } from '../avatar'
@ -20,7 +23,6 @@ import NewContractBadge from '../new-contract-badge'
import { UserFollowButton } from '../follow-button'
import { DAY_MS } from 'common/util/time'
import { useUser } from 'web/hooks/use-user'
import { Editor } from '@tiptap/react'
import { exhibitExts } from 'common/util/parse'
import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal'
@ -29,11 +31,10 @@ 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'
import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user'
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge'
import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
export type ShowTime = 'resolve-date' | 'close-date'
@ -187,14 +188,29 @@ export function ContractDetails(props: {
) : !groupToDisplay && !user ? (
<div />
) : (
<Row>
<Button
size={'xs'}
className={'max-w-[200px]'}
className={'max-w-[200px] pr-2'}
color={'gray-white'}
onClick={() => setOpen(!open)}
onClick={() =>
groupToDisplay
? Router.push(groupPath(groupToDisplay.slug))
: setOpen(!open)
}
>
{groupInfo}
</Button>
{user && (
<Button
size={'xs'}
color={'gray-white'}
onClick={() => setOpen(!open)}
>
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
</Button>
)}
</Row>
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'md'}>
@ -218,7 +234,7 @@ export function ContractDetails(props: {
<ClockIcon className="h-5 w-5" />
<DateTimeTooltip
text="Market resolved:"
time={dayjs(contract.resolutionTime)}
time={contract.resolutionTime}
>
{resolvedDate}
</DateTimeTooltip>
@ -262,14 +278,22 @@ function EditableCloseDate(props: {
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState(
closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm')
closeTime && dayJsCloseTime.format('YYYY-MM-DD')
)
const [closeHoursMinutes, setCloseHoursMinutes] = useState(
closeTime && dayJsCloseTime.format('HH:mm')
)
const newCloseTime = closeDate
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
: undefined
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
const onSave = () => {
const newCloseTime = dayjs(closeDate).valueOf()
if (!newCloseTime) return
if (newCloseTime === closeTime) setIsEditingCloseTime(false)
else if (newCloseTime > Date.now()) {
const content = contract.description
@ -294,20 +318,28 @@ function EditableCloseDate(props: {
return (
<>
{isEditingCloseTime ? (
<div className="form-control mr-1 items-start">
<Row className="mr-1 items-start">
<input
type="datetime-local"
type="date"
className="input input-bordered"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
value={closeDate}
/>
</div>
<input
type="time"
className="input input-bordered ml-2"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseHoursMinutes(e.target.value)}
min="00:00"
value={closeHoursMinutes}
/>
</Row>
) : (
<DateTimeTooltip
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
time={dayJsCloseTime}
time={closeTime}
>
{isSameYear
? dayJsCloseTime.format('MMM D')
@ -327,7 +359,7 @@ function EditableCloseDate(props: {
color={'gray-white'}
onClick={() => setIsEditingCloseTime(true)}
>
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
<PencilIcon className="!container mr-0.5 mb-0.5 inline h-4 w-4" />
</Button>
))}
</>

View File

@ -17,6 +17,7 @@ import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
export const contractDetailsButtonClassName =
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
@ -31,7 +32,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const isDev = useDev()
const isAdmin = useAdmin()
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract
@ -50,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'Multiple choice'
: 'Numeric'
const onFeaturedToggle = async (enabled: boolean) => {
if (
enabled &&
(contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank)
) {
await updateContract(id, { featuredOnHomeRank: 1 })
setFeatured(true)
} else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await updateContract(id, { featuredOnHomeRank: deleteField() })
setFeatured(false)
}
}
return (
<>
<button
@ -134,7 +150,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && (
<tr>
<td>[DEV] Firestore</td>
<td>[ADMIN] Firestore</td>
<td>
<SiteLink href={firestoreConsolePath(id)}>
Console link
@ -144,43 +160,28 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
)}
{isAdmin && (
<tr>
<td>Set featured</td>
<td>[ADMIN] Featured</td>
<td>
<select
className="select select-bordered"
value={featured ? 'true' : 'false'}
onChange={(e) => {
const newVal = e.target.value === 'true'
if (
newVal &&
(contract.featuredOnHomeRank === 0 ||
!contract?.featuredOnHomeRank)
)
<ShortToggle
enabled={featured}
setEnabled={setFeatured}
onChange={onFeaturedToggle}
/>
</td>
</tr>
)}
{isAdmin && (
<tr>
<td>[ADMIN] Unlisted</td>
<td>
<ShortToggle
enabled={contract.visibility === 'unlisted'}
setEnabled={(b) =>
updateContract(id, {
featuredOnHomeRank: 1,
visibility: b ? 'unlisted' : 'public',
})
.then(() => {
setFeatured(true)
})
.catch(console.error)
else if (
!newVal &&
(contract?.featuredOnHomeRank ?? 0) > 0
)
updateContract(id, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
featuredOnHomeRank: deleteField(),
})
.then(() => {
setFeatured(false)
})
.catch(console.error)
}}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
}
/>
</td>
</tr>
)}

View File

@ -75,8 +75,6 @@ export const ContractOverview = (props: {
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<Row className={'items-center justify-center'}>
<LikeMarketButton contract={contract} user={user} />
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract as CPMMBinaryContract} />
@ -88,12 +86,9 @@ export const ContractOverview = (props: {
</Col>
)}
</Row>
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
<Row className={'items-center justify-center'}>
<LikeMarketButton contract={contract} user={user} />
{tradingAllowed(contract) && (
<Col>
<BetButton contract={contract} />
@ -105,7 +100,6 @@ export const ContractOverview = (props: {
</Col>
)}
</Row>
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&

View File

@ -16,6 +16,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
}) {
const { contract, height } = props
const { resolutionTime, closeTime, outcomeType } = contract
const now = Date.now()
const isBinary = outcomeType === 'BINARY'
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
@ -23,10 +24,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const startProb = getInitialProbability(contract)
const times = [
contract.createdTime,
...bets.map((bet) => bet.createdTime),
].map((time) => new Date(time))
const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
const f: (p: number) => number = isBinary
? (p) => p
@ -36,17 +34,17 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
const isClosed = !!closeTime && Date.now() > closeTime
const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs(
resolutionTime && isClosed
? Math.min(resolutionTime, closeTime)
: isClosed
? closeTime
: resolutionTime ?? Date.now()
: resolutionTime ?? now
)
// Add a fake datapoint so the line continues to the right
times.push(latestTime.toDate())
times.push(latestTime.valueOf())
probs.push(probs[probs.length - 1])
const quartiles = [0, 25, 50, 75, 100]
@ -58,15 +56,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
const hoursAgo = latestTime.subtract(1, 'hours')
const startDate = dayjs(times[0]).isBefore(hoursAgo)
? times[0]
: hoursAgo.toDate()
const startDate = dayjs(times[0])
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours')
: latestTime
const includeMinute = endDate.diff(startDate, 'hours') < 2
// Minimum number of points for the graph to have. For smooth tooltip movement
// On first load, width is undefined, skip adding extra points to let page load faster
// If we aren't actually loading any data yet, skip adding extra points to let page load faster
// This fn runs again once DOM is finished loading
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
@ -74,20 +73,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const s = isBinary ? 100 : 1
for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: s * probs[i] }
const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
)
const p = probs[i]
const d0 = times[i]
const d1 = times[i + 1]
const msDiff = d1 - d0
const numPoints = Math.floor(msDiff / timeStep)
points.push({ x: new Date(times[i]), y: s * p })
if (numPoints > 1) {
const thisTimeStep: number =
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / numPoints
const thisTimeStep: number = msDiff / numPoints
for (let n = 1; n < numPoints; n++) {
points[points.length] = {
x: dayjs(times[i])
.add(thisTimeStep * n, 'ms')
.toDate(),
y: s * probs[i],
}
points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
}
}
}
@ -96,8 +91,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
]
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
const formatter = isBinary
? formatPercent
@ -132,15 +127,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
}}
xScale={{
type: 'time',
min: startDate,
max: latestTime.toDate(),
min: startDate.toDate(),
max: endDate.toDate(),
}}
xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
format: (time) =>
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}}
colors={{ datum: 'color' }}
curve="stepAfter"
@ -176,19 +172,20 @@ function formatPercent(y: DatumValue) {
}
function formatTime(
now: number,
time: number,
includeYear: boolean,
includeHour: boolean,
includeMinute: boolean
) {
const d = dayjs(time)
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
return 'Now'
let format: string
if (d.isSame(Date.now(), 'day')) {
if (d.isSame(now, 'day')) {
format = '[Today]'
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) {
} else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]'
} else {
format = 'MMM D'

View File

@ -26,6 +26,7 @@ export function ContractsGrid(props: {
hideGroupLink?: boolean
}
highlightOptions?: ContractHighlightOptions
trackingPostfix?: string
}) {
const {
contracts,
@ -34,6 +35,7 @@ export function ContractsGrid(props: {
onContractClick,
cardHideOptions,
highlightOptions,
trackingPostfix,
} = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {}
@ -79,6 +81,7 @@ export function ContractsGrid(props: {
}
hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
className={clsx(
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
contractIds?.includes(contract.id) && highlightClassName

View File

@ -0,0 +1,9 @@
import { BadgeCheckIcon } from '@heroicons/react/solid'
export function FeaturedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-0.5 text-sm font-medium text-green-800">
<BadgeCheckIcon className="h-4 w-4" aria-hidden="true" /> Featured
</span>
)
}

View File

@ -41,8 +41,9 @@ const BET_SIZE = 10
export function QuickBet(props: {
contract: BinaryContract | PseudoNumericContract
user: User
className?: string
}) {
const { contract, user } = props
const { contract, user, className } = props
const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1'
@ -139,6 +140,7 @@ export function QuickBet(props: {
return (
<Col
className={clsx(
className,
'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
// Use this for colored QuickBet panes
// `bg-opacity-10 bg-${color}`

View File

@ -1,11 +1,9 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { firebaseLogin, User } from 'web/lib/firebase/users'
import React from 'react'
import Link from 'next/link'
import clsx from 'clsx'
export const createButtonStyle =
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
import { User } from 'web/lib/firebase/users'
import { Button } from './button'
export const CreateQuestionButton = (props: {
user: User | null | undefined
@ -13,32 +11,17 @@ export const CreateQuestionButton = (props: {
className?: string
query?: string
}) => {
const gradient =
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
const { user, overrideText, className, query } = props
const router = useRouter()
if (!user || user?.isBannedFromPosting) return <></>
return (
<div className={clsx('flex justify-center', className)}>
{user ? (
<Link href={`/create${query ? query : ''}`} passHref>
<button className={clsx(gradient, createButtonStyle)}>
{overrideText ? overrideText : 'Create a market'}
</button>
<Button color="gradient" size="xl" className="mt-4">
{overrideText ?? 'Create a market'}
</Button>
</Link>
) : (
<button
onClick={async () => {
// login, and then reload the page, to hit any SSR redirect (e.g.
// redirecting from / to /home for logged in users)
await firebaseLogin()
router.replace(router.asPath)
}}
className={clsx(gradient, createButtonStyle)}
>
Sign in
</button>
)}
</div>
)
}

View File

@ -1,15 +1,12 @@
import dayjs, { Dayjs } from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import advanced from 'dayjs/plugin/advancedFormat'
import { Tooltip } from './tooltip'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(advanced)
const FORMATTER = new Intl.DateTimeFormat('default', {
dateStyle: 'medium',
timeStyle: 'long',
})
export function DateTimeTooltip(props: {
time: Dayjs
time: number
text?: string
className?: string
children?: React.ReactNode
@ -17,7 +14,7 @@ export function DateTimeTooltip(props: {
}) {
const { className, time, text, noTap } = props
const formattedTime = time.format('MMM DD, YYYY hh:mm a z')
const formattedTime = FORMATTER.format(time)
const toolTip = text ? `${text} ${formattedTime}` : formattedTime
return (

View File

@ -40,6 +40,11 @@ const embedPatterns: EmbedPattern[] = [
rewrite: (id) =>
`<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`,
},
{
regex: /^(https?:\/\/www\.figma\.com\/(?:file|proto)\/[^\/]+\/[^\/]+)/,
rewrite: (url) =>
`<iframe src="https://www.figma.com/embed?embed_host=manifold&url=${url}"></iframe>`,
},
// Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match
{
// Twitch: https://www.twitch.tv/videos/1445087149

View File

@ -48,8 +48,17 @@ export function MarketModal(props: {
{contracts.length > 1 && 's'}
</Button>
)}
<Button onClick={() => setContracts([])} color="gray">
Cancel
<Button
onClick={() => {
if (contracts.length > 0) {
setContracts([])
} else {
setOpen(false)
}
}}
color="gray"
>
{contracts.length > 0 ? 'Reset' : 'Cancel'}
</Button>
</Row>
)}

View File

@ -6,8 +6,6 @@ import Link from 'next/link'
import { fromNow } from 'web/lib/util/time'
import { ToastClipboard } from 'web/components/toast-clipboard'
import { LinkIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import dayjs from 'dayjs'
export function CopyLinkDateTimeComponent(props: {
prefix: string
@ -18,7 +16,6 @@ export function CopyLinkDateTimeComponent(props: {
}) {
const { prefix, slug, elementId, createdTime, className } = props
const [showToast, setShowToast] = useState(false)
const time = dayjs(createdTime)
function copyLinkToComment(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
@ -31,26 +28,19 @@ export function CopyLinkDateTimeComponent(props: {
setTimeout(() => setShowToast(false), 2000)
}
return (
<div className={clsx('inline', className)}>
<DateTimeTooltip time={time} noTap>
<DateTimeTooltip className={className} time={createdTime} noTap>
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
<a
onClick={(event) => copyLinkToComment(event)}
className={'mx-1 cursor-pointer'}
onClick={copyLinkToComment}
className={
'mx-1 whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100'
}
>
<span className="whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100 ">
{fromNow(createdTime)}
{showToast && (
<ToastClipboard className={'left-24 sm:-left-16'} />
)}
<LinkIcon
className="ml-1 mb-0.5 inline-block text-gray-400"
height={13}
/>
</span>
{showToast && <ToastClipboard />}
<LinkIcon className="ml-1 mb-0.5 inline" height={13} />
</a>
</Link>
</DateTimeTooltip>
</div>
)
}

View File

@ -6,7 +6,6 @@ import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
@ -154,79 +153,3 @@ export function BetStatusText(props: {
</div>
)
}
function BetGroupSpan(props: {
contract: Contract
bets: Bet[]
outcome?: string
}) {
const { contract, bets, outcome } = props
const numberTraders = uniqBy(bets, (b) => b.userId).length
const [buys, sells] = partition(bets, (bet) => bet.amount >= 0)
const buyTotal = sumBy(buys, (b) => b.amount)
const sellTotal = sumBy(sells, (b) => -b.amount)
return (
<span>
{numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '}
<JoinSpans>
{buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>}
{sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>}
</JoinSpans>
{outcome && (
<>
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
contract={contract}
truncate="short"
/>
</>
)}{' '}
</span>
)
}
export function FeedBetGroup(props: {
contract: Contract
bets: Bet[]
hideOutcome: boolean
}) {
const { contract, bets, hideOutcome } = props
const betGroups = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betGroups)
// Use the time of the last bet for the entire group
const createdTime = bets[bets.length - 1].createdTime
return (
<>
<div>
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
</div>
</div>
<div className={clsx('min-w-0 flex-1', outcomes.length === 1 && 'mt-1')}>
<div className="text-sm text-gray-500">
{outcomes.map((outcome, index) => (
<Fragment key={outcome}>
<BetGroupSpan
contract={contract}
outcome={hideOutcome ? undefined : outcome}
bets={betGroups[outcome]}
/>
{index !== outcomes.length - 1 && <br />}
</Fragment>
))}
<RelativeTimestamp time={createdTime} />
</div>
</div>
</>
)
}

View File

@ -382,6 +382,8 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return (
<>
<Row className={'mb-2 gap-1 sm:gap-2'}>
@ -535,7 +537,7 @@ export function CommentInputTextArea(props: {
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Sign in to comment
Add my comment
</button>
)}
</Row>

View File

@ -33,7 +33,7 @@ import {
import { FeedBet } from 'web/components/feed/feed-bets'
import { CPMMBinaryContract, NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity'
import { SignUpPrompt } from '../sign-up-prompt'
import { BetSignUpPrompt } from '../sign-up-prompt'
import { User } from 'common/user'
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
import { contractMetrics } from 'common/contract-details'
@ -70,7 +70,7 @@ export function FeedItems(props: {
{!user ? (
<Col className="mt-4 max-w-sm items-center xl:hidden">
<SignUpPrompt />
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</Col>
) : (

View File

@ -10,7 +10,11 @@ export function FileUploadButton(props: {
const ref = useRef<HTMLInputElement>(null)
return (
<>
<button className={className} onClick={() => ref.current?.click()}>
<button
type={'button'}
className={className}
onClick={() => ref.current?.click()}
>
{children}
</button>
<input

View File

@ -76,6 +76,8 @@ export function CreateGroupButton(props: {
}
}
if (user.isBannedFromPosting) return <></>
return (
<ConfirmationButton
openModalBtn={{

View File

@ -18,7 +18,7 @@ import { sum } from 'lodash'
import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, useTextEditor } from 'web/components/editor'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { useUnseenNotifications } from 'web/hooks/use-notifications'
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
@ -277,14 +277,18 @@ function GroupChatNotificationsIcon(props: {
hidden: boolean
}) {
const { privateUser, group, shouldSetAsSeen, hidden } = props
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
privateUser,
{
customHref: `/group/${group.slug}`,
}
const notificationsForThisGroup = useUnseenNotifications(
privateUser
// Disabled tracking by customHref for now.
// {
// customHref: `/group/${group.slug}`,
// }
)
useEffect(() => {
preferredNotificationsForThisGroup.forEach((notification) => {
if (!notificationsForThisGroup) return
notificationsForThisGroup.forEach((notification) => {
if (
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
// old style chat notif that simply ended with the group slug
@ -293,13 +297,14 @@ function GroupChatNotificationsIcon(props: {
setNotificationsAsSeen([notification])
}
})
}, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen])
}, [group.slug, notificationsForThisGroup, shouldSetAsSeen])
return (
<div
className={
!hidden &&
preferredNotificationsForThisGroup.length > 0 &&
notificationsForThisGroup &&
notificationsForThisGroup.length > 0 &&
!shouldSetAsSeen
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
: 'hidden'

View File

@ -4,8 +4,8 @@ import { Tooltip } from './tooltip'
export function InfoTooltip(props: { text: string }) {
const { text } = props
return (
<Tooltip text={text}>
<InformationCircleIcon className="h-5 w-5 text-gray-500" />
<Tooltip className="inline-block" text={text}>
<InformationCircleIcon className="-mb-1 h-5 w-5 text-gray-500" />
</Tooltip>
)
}

View File

@ -40,7 +40,7 @@ export function Leaderboard(props: {
{users.map((user, index) => (
<tr key={user.id}>
<td>{index + 1}</td>
<td style={{ maxWidth: 190 }}>
<td className="max-w-[190px]">
<SiteLink className="relative" href={`/${user.username}`}>
<Row className="items-center gap-4">
<Avatar avatarUrl={user.avatarUrl} size={8} />

View File

@ -38,10 +38,7 @@ export function Linkify(props: { text: string; gray?: boolean }) {
)
})
return (
<span
className="break-words"
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
>
<span className="break-anywhere">
{text.split(regex).map((part, i) => (
<Fragment key={i}>
{part}

View File

@ -5,8 +5,6 @@ import {
DotsHorizontalIcon,
CashIcon,
HeartIcon,
UserGroupIcon,
TrendingUpIcon,
ChatIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
@ -18,17 +16,14 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useState } from 'react'
import React from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group'
import { Spacer } from '../layout/spacer'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out
@ -46,11 +41,12 @@ function getNavigation() {
icon: NotificationsIcon,
},
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
: [
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
]),
]
}
@ -67,14 +63,14 @@ function getMoreNavigation(user?: User | null) {
}
if (!user) {
// Signed out "More"
return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Tournaments', href: '/tournaments' },
{ name: 'Charity', href: '/charity' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
@ -82,16 +78,15 @@ function getMoreNavigation(user?: User | null) {
)
}
// Signed in "More"
return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
{
@ -120,12 +115,12 @@ const signedOutMobileNavigation = [
icon: BookOpenIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
]
const signedInMobileNavigation = [
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -147,11 +142,9 @@ function getMoreMobileNav() {
return buildArray<Item>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -232,29 +225,23 @@ export default function Sidebar(props: { className?: string }) {
? signedOutMobileNavigation
: signedInMobileNavigation
const memberItems = (
useMemberGroups(user?.id, undefined, {
by: 'mostRecentContractAddedTime',
}) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}`,
}))
return (
<nav aria-label="Sidebar" className={className}>
<nav
aria-label="Sidebar"
className={clsx('flex max-h-[100vh] flex-col', className)}
>
<ManifoldLogo className="py-6" twoLine />
<CreateQuestionButton user={user} />
<Spacer h={4} />
{!user && <SignInButton className="mb-4" />}
{user && (
<div className="w-full" style={{ minHeight: 80 }}>
<div className="min-h-[80px] w-full">
<ProfileSummary user={user} />
</div>
)}
{/* Mobile navigation */}
<div className="space-y-1 lg:hidden">
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
{mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
@ -265,15 +252,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
)}
{/* Spacer if there are any groups */}
{memberItems.length > 0 && (
<hr className="!my-4 mr-2 border-gray-300" />
)}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div>
{/* Desktop navigation */}
<div className="hidden space-y-1 lg:block">
<div className="hidden min-h-0 shrink flex-col gap-1 lg:flex">
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
@ -282,65 +264,8 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
{/* Spacer if there are any groups */}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
{user && <CreateQuestionButton user={user} />}
</div>
</nav>
)
}
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
const { currentPage, memberItems } = props
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
// const preferredNotifications = useUnseenPreferredNotifications(
// privateUser,
// {
// customHref: '/group/',
// },
// memberItems.length > 0 ? memberItems.length : undefined
// )
// const notifIsForThisItem = useMemo(
// () => (itemHref: string) =>
// preferredNotifications.some(
// (n) =>
// !n.isSeen &&
// (n.isSeenOnHref === itemHref ||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
// ),
// [preferredNotifications]
// )
return (
<>
<SidebarItem
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
currentPage={currentPage}
/>
<div
className="flex-1 space-y-0.5 overflow-auto"
style={{ height: remainingHeight }}
ref={setContainerRef}
>
{memberItems.map((item) => (
<a
href={item.href}
key={item.name}
onClick={trackCallback('click sidebar group', { name: item.name })}
className={clsx(
'cursor-pointer truncate',
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
)}
>
{item.name}
</a>
))}
</div>
</>
)
}

View File

@ -4,7 +4,7 @@ import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react'
import { usePrivateUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router'
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
import { useUnseenGroupedNotification } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { PrivateUser } from 'common/user'
@ -30,7 +30,7 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
else setSeen(false)
}, [router.pathname])
const notifications = useUnseenPreferredNotificationGroups(privateUser)
const notifications = useUnseenGroupedNotification(privateUser)
if (!notifications || notifications.length === 0 || seen) {
return <div />
}

View File

@ -18,7 +18,7 @@ import { BucketInput } from './bucket-input'
import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import { SignUpPrompt } from './sign-up-prompt'
import { BetSignUpPrompt } from './sign-up-prompt'
import { track } from 'web/lib/service/analytics'
export function NumericBetPanel(props: {
@ -34,7 +34,7 @@ export function NumericBetPanel(props: {
<NumericBuyPanel contract={contract} user={user} />
<SignUpPrompt />
<BetSignUpPrompt />
</Col>
)
}

View File

@ -164,10 +164,7 @@ export function AnswerLabel(props: {
return (
<Tooltip text={truncated === text ? false : text}>
<span
style={{ wordBreak: 'break-word' }}
className={clsx('whitespace-pre-line break-words', className)}
>
<span className={clsx('break-anywhere whitespace-pre-line', className)}>
{truncated}
</span>
</Tooltip>

View File

@ -6,13 +6,11 @@ import { Toaster } from 'react-hot-toast'
export function Page(props: {
rightSidebar?: ReactNode
suspend?: boolean
className?: string
rightSidebarClassName?: string
children?: ReactNode
}) {
const { children, rightSidebar, suspend, className, rightSidebarClassName } =
props
const { children, rightSidebar, className, rightSidebarClassName } = props
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
return (
@ -23,10 +21,9 @@ export function Page(props: {
bottomBarPadding,
'mx-auto w-full lg:grid lg:grid-cols-12 lg:gap-x-2 xl:max-w-7xl xl:gap-x-8'
)}
style={suspend ? visuallyHiddenStyle : undefined}
>
<Toaster />
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" />
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" />
<main
className={clsx(
'lg:col-span-8 lg:pt-6',
@ -46,22 +43,7 @@ export function Page(props: {
</div>
</aside>
</div>
<BottomNavBar />
</>
)
}
const visuallyHiddenStyle = {
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
margin: -1,
overflow: 'hidden',
padding: 0,
position: 'absolute',
width: 1,
whiteSpace: 'nowrap',
userSelect: 'none',
visibility: 'hidden',
} as const

View File

@ -1,6 +1,40 @@
import { ReactNode } from 'react'
import clsx from 'clsx'
import { Spacer } from './layout/spacer'
import { Row } from './layout/row'
export function PaginationNextPrev(props: {
className?: string
prev?: ReactNode
next?: ReactNode
onClickPrev: () => void
onClickNext: () => void
scrollToTop?: boolean
}) {
const { className, prev, next, onClickPrev, onClickNext, scrollToTop } = props
return (
<Row className={clsx(className, 'flex-1 justify-between sm:justify-end')}>
{prev != null && (
<a
href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={onClickPrev}
>
{prev ?? 'Previous'}
</a>
)}
{next != null && (
<a
href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={onClickNext}
>
{next ?? 'Next'}
</a>
)}
</Row>
)
}
export function Pagination(props: {
page: number
itemsPerPage: number
@ -44,24 +78,13 @@ export function Pagination(props: {
of <span className="font-medium">{totalItems}</span> results
</p>
</div>
<div className="flex flex-1 justify-between sm:justify-end">
{page > 0 && (
<a
href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page > 0 && setPage(page - 1)}
>
{prevTitle ?? 'Previous'}
</a>
)}
<a
href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page < maxPage && setPage(page + 1)}
>
{nextTitle ?? 'Next'}
</a>
</div>
<PaginationNextPrev
prev={page > 0 ? prevTitle ?? 'Previous' : null}
next={page < maxPage ? nextTitle ?? 'Next' : null}
onClickPrev={() => setPage(page - 1)}
onClickNext={() => setPage(page + 1)}
scrollToTop={scrollToTop}
/>
</nav>
)
}

View File

@ -1,7 +1,6 @@
import { ResponsiveLine } from '@nivo/line'
import { PortfolioMetrics } from 'common/user'
import { formatMoney } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
import { last } from 'lodash'
import { memo } from 'react'
import { useWindowSize } from 'web/hooks/use-window-size'
@ -10,28 +9,12 @@ import { formatTime } from 'web/lib/util/time'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[]
height?: number
period?: string
includeTime?: boolean
}) {
const { portfolioHistory, height, period } = props
const { portfolioHistory, height, includeTime } = props
const { width } = useWindowSize()
const portfolioHistoryFiltered = portfolioHistory.filter((p) => {
switch (period) {
case 'daily':
return p.timestamp > Date.now() - 1 * DAY_MS
case 'weekly':
return p.timestamp > Date.now() - 7 * DAY_MS
case 'monthly':
return p.timestamp > Date.now() - 30 * DAY_MS
case 'allTime':
return true
default:
return true
}
})
const points = portfolioHistoryFiltered.map((p) => {
const points = portfolioHistory.map((p) => {
return {
x: new Date(p.timestamp),
y: p.balance + p.investmentValue,
@ -41,7 +24,6 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
const numXTickValues = !width || width < 800 ? 2 : 5
const numYTickValues = 4
const endDate = last(points)?.x
const includeTime = period === 'daily'
return (
<div
className="w-full overflow-hidden"
@ -66,7 +48,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
colors={{ datum: 'color' }}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, includeTime),
format: (time) => formatTime(+time, !!includeTime),
}}
pointBorderColor="#fff"
pointSize={points.length > 100 ? 0 : 6}

View File

@ -1,56 +1,39 @@
import { PortfolioMetrics } from 'common/user'
import { formatMoney } from 'common/util/format'
import { last } from 'lodash'
import { memo, useEffect, useState } from 'react'
import { Period, getPortfolioHistory } from 'web/lib/firebase/users'
import { memo, useRef, useState } from 'react'
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo(
function PortfolioValueSection(props: {
userId: string
disableSelector?: boolean
}) {
const { disableSelector, userId } = props
function PortfolioValueSection(props: { userId: string }) {
const { userId } = props
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
const [portfolioHistory, setUsersPortfolioHistory] = useState<
PortfolioMetrics[]
>([])
useEffect(() => {
getPortfolioHistory(userId).then(setUsersPortfolioHistory)
}, [userId])
const lastPortfolioMetrics = last(portfolioHistory)
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
// Remember the last defined portfolio history.
const portfolioRef = useRef(portfolioHistory)
if (portfolioHistory) portfolioRef.current = portfolioHistory
const currPortfolioHistory = portfolioRef.current
const lastPortfolioMetrics = last(currPortfolioHistory)
if (!currPortfolioHistory || !lastPortfolioMetrics) {
return <></>
}
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
// instead of "All time"
const allTimeLabel =
lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z')
? 'Since June'
: 'All time'
const { balance, investmentValue } = lastPortfolioMetrics
const totalValue = balance + investmentValue
return (
<div>
<>
<Row className="gap-8">
<div className="mb-4 w-full">
<Col
className={disableSelector ? 'items-center justify-center' : ''}
>
<Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">
{formatMoney(
lastPortfolioMetrics.balance +
lastPortfolioMetrics.investmentValue
)}
</div>
<div className="text-lg">{formatMoney(totalValue)}</div>
</Col>
</div>
{!disableSelector && (
<select
className="select select-bordered self-start"
value={portfolioPeriod}
@ -58,18 +41,16 @@ export const PortfolioValueSection = memo(
setPortfolioPeriod(e.target.value as Period)
}}
>
<option value="allTime">{allTimeLabel}</option>
<option value="allTime">All time</option>
<option value="weekly">Last 7d</option>
{/* Note: 'daily' seems to be broken? */}
{/* <option value="daily">Last 24h</option> */}
<option value="daily">Last 24h</option>
</select>
)}
</Row>
<PortfolioValueGraph
portfolioHistory={portfolioHistory}
period={portfolioPeriod}
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
/>
</div>
</>
)
}
)

View File

@ -15,7 +15,7 @@ export function LoansModal(props: {
<Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}>
Every day at midnight PT, get 1% of your total bet amount back as a
Every day at midnight PT, get 2% of your total bet amount back as a
loan.
</span>
<span className={'text-indigo-700'}>
@ -34,12 +34,12 @@ export function LoansModal(props: {
</span>
<span className={'text-indigo-700'}> What is an example?</span>
<span className={'ml-2'}>
For example, if you bet M$1000 on "Will I become a millionare?" on
Monday, you will get M$10 back on Tuesday.
For example, if you bet M$1000 on "Will I become a millionare?"
today, you will get M$20 back tomorrow.
</span>
<span className={'ml-2'}>
Previous loans count against your total bet amount. So on Wednesday,
you would get back 1% of M$990 = M$9.9.
Previous loans count against your total bet amount. So on the next
day, you would get back 2% of M$(1000 - 20) = M$19.6.
</span>
</Col>
</Col>

View File

@ -8,7 +8,7 @@ export function RelativeTimestamp(props: { time: number }) {
return (
<DateTimeTooltip
className="ml-1 whitespace-nowrap text-gray-400"
time={dayJsTime}
time={time}
>
{dayJsTime.fromNow()}
</DateTimeTooltip>

View File

@ -0,0 +1,46 @@
import { LinkIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Title } from './title'
import { Button } from './button'
import { TweetButton } from './tweet-button'
import { Row } from './layout/row'
export function SharePostModal(props: {
shareUrl: string
isOpen: boolean
setOpen: (open: boolean) => void
}) {
const { isOpen, setOpen, shareUrl } = props
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
return (
<Modal open={isOpen} setOpen={setOpen} size="md">
<Col className="gap-4 rounded bg-white p-4">
<Title className="!mt-0 !mb-2" text="Share this post" />
<Button
size="2xl"
color="gradient"
className={'mb-2 flex max-w-xs self-center'}
onClick={() => {
copyToClipboard(shareUrl)
toast.success('Link copied!', {
icon: linkIcon,
})
track('copy share post link')
}}
>
{linkIcon} Copy link
</Button>
<Row className="z-0 justify-start gap-4 self-center">
<TweetButton className="self-start" tweetText={shareUrl} />
</Row>
</Col>
</Modal>
)
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import { useRouter } from 'next/router'
import { firebaseLogin } from 'web/lib/firebase/users'
import { Button } from './button'
export const SignInButton = (props: { className?: string }) => {
const router = useRouter()
return (
<Button
size="lg"
color="gray"
onClick={async () => {
// login, and then reload the page, to hit any SSR redirect (e.g.
// redirecting from / to /home for logged in users)
await firebaseLogin()
router.replace(router.asPath)
}}
className={props.className}
>
Sign in
</Button>
)
}

View File

@ -4,7 +4,7 @@ import { firebaseLogin } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
import { Button, SizeType } from './button'
export function SignUpPrompt(props: {
export function BetSignUpPrompt(props: {
label?: string
className?: string
size?: SizeType

View File

@ -3,7 +3,7 @@ import { ReactNode } from 'react'
import Link from 'next/link'
export const linkClass =
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2'
'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2'
export const SiteLink = (props: {
href: string
@ -19,7 +19,6 @@ export const SiteLink = (props: {
className={clsx(linkClass, className)}
href={href}
target={href.startsWith('http') ? '_blank' : undefined}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
onClick={(e) => {
e.stopPropagation()
if (onClick) onClick()

View File

@ -10,7 +10,7 @@ export function ToastClipboard(props: { className?: string }) {
className={clsx(
'border-base-300 absolute items-center' +
'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' +
'h-15 w-[15rem] p-2 pr-3 text-gray-500',
'h-15 z-10 w-[15rem] p-2 pr-3 text-gray-500',
className
)}
>

View File

@ -11,7 +11,6 @@ import {
useRole,
} from '@floating-ui/react-dom-interactions'
import { Transition } from '@headlessui/react'
import clsx from 'clsx'
import { ReactNode, useRef, useState } from 'react'
// See https://floating-ui.com/docs/react-dom
@ -58,14 +57,10 @@ export function Tooltip(props: {
}[placement.split('-')[0]] as string
return text ? (
<div className="contents">
<div
className={clsx('inline-block', className)}
ref={reference}
{...getReferenceProps()}
>
<>
<span className={className} ref={reference} {...getReferenceProps()}>
{children}
</div>
</span>
{/* conditionally render tooltip and fade in/out */}
<Transition
show={open}
@ -95,7 +90,7 @@ export function Tooltip(props: {
}}
/>
</Transition>
</div>
</>
) : (
<>{children}</>
)

View File

@ -119,10 +119,7 @@ export function UserPage(props: { user: User }) {
<Col className="mx-4 -mt-6">
<Row className={'flex-wrap justify-between gap-y-2'}>
<Col>
<span
className="text-2xl font-bold"
style={{ wordBreak: 'break-word' }}
>
<span className="break-anywhere text-2xl font-bold">
{user.name}
</span>
<span className="text-gray-500">@{user.username}</span>

View File

@ -5,13 +5,19 @@ import clsx from 'clsx'
export default function ShortToggle(props: {
enabled: boolean
setEnabled: (enabled: boolean) => void
onChange?: (enabled: boolean) => void
}) {
const { enabled, setEnabled } = props
return (
<Switch
checked={enabled}
onChange={setEnabled}
onChange={(e: boolean) => {
setEnabled(e)
if (props.onChange) {
props.onChange(e)
}
}}
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="sr-only">Use setting</span>

View File

@ -1,3 +1,4 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { isEqual } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import {
@ -8,6 +9,7 @@ import {
listenForHotContracts,
listenForInactiveContracts,
listenForNewContracts,
getUserBetContractsQuery,
} from 'web/lib/firebase/contracts'
export const useContracts = () => {
@ -89,3 +91,15 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
? contracts.map((c) => contractDict.current[c.id])
: undefined
}
export const useUserBetContracts = (userId: string) => {
const result = useFirestoreQueryData(
['contracts', 'bets', userId],
getUserBetContractsQuery(userId),
{ subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
// https://github.com/invertase/react-query-firebase/issues/25
{ refetchOnMount: 'always' }
)
return result.data
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
import { listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()

View File

@ -1,13 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification'
import {
getNotificationsQuery,
listenForNotifications,
} from 'web/lib/firebase/notifications'
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export type NotificationGroup = {
notifications: Notification[]
@ -17,49 +13,49 @@ export type NotificationGroup = {
type: 'income' | 'normal'
}
// For some reason react-query subscriptions don't actually listen for notifications
// Use useUnseenPreferredNotificationGroups to listen for new notifications
export function usePreferredGroupedNotifications(
privateUser: PrivateUser,
cachedNotifications?: Notification[]
) {
function useNotifications(privateUser: PrivateUser) {
const result = useFirestoreQueryData(
['notifications-all', privateUser.id],
getNotificationsQuery(privateUser.id)
getNotificationsQuery(privateUser.id),
{ subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
// https://github.com/invertase/react-query-firebase/issues/25
{ refetchOnMount: 'always' }
)
const notifications = useMemo(() => {
if (result.isLoading) return cachedNotifications ?? []
if (!result.data) return cachedNotifications ?? []
if (!result.data) return undefined
const notifications = result.data as Notification[]
return getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref)
}, [
cachedNotifications,
privateUser.notificationPreferences,
result.data,
result.isLoading,
])
}, [privateUser.notificationPreferences, result.data])
return notifications
}
export function useUnseenNotifications(privateUser: PrivateUser) {
const notifications = useNotifications(privateUser)
return useMemo(
() => notifications && notifications.filter((n) => !n.isSeen),
[notifications]
)
}
export function useGroupedNotifications(privateUser: PrivateUser) {
const notifications = useNotifications(privateUser)
return useMemo(() => {
if (notifications) return groupNotifications(notifications)
}, [notifications])
}
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {
const notifications = useUnseenPreferredNotifications(privateUser, {})
const [notificationGroups, setNotificationGroups] = useState<
NotificationGroup[] | undefined
>(undefined)
useEffect(() => {
if (!notifications) return
const groupedNotifications = groupNotifications(notifications)
setNotificationGroups(groupedNotifications)
export function useUnseenGroupedNotification(privateUser: PrivateUser) {
const notifications = useUnseenNotifications(privateUser)
return useMemo(() => {
if (notifications) return groupNotifications(notifications)
}, [notifications])
return notificationGroups
}
export function groupNotifications(notifications: Notification[]) {
@ -120,36 +116,6 @@ export function groupNotifications(notifications: Notification[]) {
return notificationGroups
}
export function useUnseenPreferredNotifications(
privateUser: PrivateUser,
options: { customHref?: string },
limit: number = NOTIFICATIONS_PER_PAGE
) {
const { customHref } = options
const [notifications, setNotifications] = useState<Notification[]>([])
const [userAppropriateNotifications, setUserAppropriateNotifications] =
useState<Notification[]>([])
useEffect(() => {
return listenForNotifications(privateUser.id, setNotifications, {
unseenOnly: true,
limit,
})
}, [limit, privateUser.id])
useEffect(() => {
const notificationsToShow = getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
).filter((n) =>
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
)
setUserAppropriateNotifications(notificationsToShow)
}, [notifications, customHref, privateUser.notificationPreferences])
return userAppropriateNotifications
}
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',

109
web/hooks/use-pagination.ts Normal file
View File

@ -0,0 +1,109 @@
// adapted from https://github.com/premshree/use-pagination-firestore
import { useEffect, useReducer } from 'react'
import {
Query,
QuerySnapshot,
QueryDocumentSnapshot,
queryEqual,
limit,
onSnapshot,
query,
startAfter,
} from 'firebase/firestore'
interface State<T> {
baseQ: Query<T>
docs: QueryDocumentSnapshot<T>[]
pageStart: number
pageEnd: number
pageSize: number
isLoading: boolean
isComplete: boolean
}
type ActionBase<K, V = void> = V extends void ? { type: K } : { type: K } & V
type Action<T> =
| ActionBase<'INIT', { opts: PaginationOptions<T> }>
| ActionBase<'LOAD', { snapshot: QuerySnapshot<T> }>
| ActionBase<'PREV'>
| ActionBase<'NEXT'>
const getReducer =
<T>() =>
(state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'INIT': {
return getInitialState(action.opts)
}
case 'LOAD': {
const docs = state.docs.concat(action.snapshot.docs)
const isComplete = action.snapshot.docs.length < state.pageSize
return { ...state, docs, isComplete, isLoading: false }
}
case 'PREV': {
const { pageStart, pageSize } = state
const prevStart = pageStart - pageSize
const isLoading = false
return { ...state, isLoading, pageStart: prevStart, pageEnd: pageStart }
}
case 'NEXT': {
const { docs, pageEnd, isComplete, pageSize } = state
const nextEnd = pageEnd + pageSize
const isLoading = !isComplete && docs.length < nextEnd
return { ...state, isLoading, pageStart: pageEnd, pageEnd: nextEnd }
}
default:
throw new Error('Invalid action.')
}
}
export type PaginationOptions<T> = { q: Query<T>; pageSize: number }
const getInitialState = <T>(opts: PaginationOptions<T>): State<T> => {
return {
baseQ: opts.q,
docs: [],
pageStart: 0,
pageEnd: opts.pageSize,
pageSize: opts.pageSize,
isLoading: true,
isComplete: false,
}
}
export const usePagination = <T>(opts: PaginationOptions<T>) => {
const [state, dispatch] = useReducer(getReducer<T>(), opts, getInitialState)
useEffect(() => {
// save callers the effort of ref-izing their opts by checking for
// deep equality over here
if (queryEqual(opts.q, state.baseQ) && opts.pageSize === state.pageSize) {
return
}
dispatch({ type: 'INIT', opts })
}, [opts, state.baseQ, state.pageSize])
useEffect(() => {
if (state.isLoading) {
const lastDoc = state.docs[state.docs.length - 1]
const nextQ = lastDoc
? query(state.baseQ, startAfter(lastDoc), limit(state.pageSize))
: query(state.baseQ, limit(state.pageSize))
return onSnapshot(nextQ, (snapshot) => {
dispatch({ type: 'LOAD', snapshot })
})
}
}, [state.isLoading, state.baseQ, state.docs, state.pageSize])
return {
isLoading: state.isLoading,
isStart: state.pageStart === 0,
isEnd: state.isComplete && state.pageEnd >= state.docs.length,
getPrev: () => dispatch({ type: 'PREV' }),
getNext: () => dispatch({ type: 'NEXT' }),
getItems: () =>
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()),
}
}

View File

@ -0,0 +1,32 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DAY_MS, HOUR_MS } from 'common/util/time'
import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users'
export const usePortfolioHistory = (userId: string, period: Period) => {
const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS
const cutoff = periodToCutoff(nowRounded, period).valueOf()
const result = useFirestoreQueryData(
['portfolio-history', userId, cutoff],
getPortfolioHistoryQuery(userId, cutoff),
{ subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
// https://github.com/invertase/react-query-firebase/issues/25
{ refetchOnMount: 'always' }
)
return result.data
}
const periodToCutoff = (now: number, period: Period) => {
switch (period) {
case 'daily':
return now - 1 * DAY_MS
case 'weekly':
return now - 7 * DAY_MS
case 'monthly':
return now - 30 * DAY_MS
case 'allTime':
default:
return new Date(0)
}
}

11
web/hooks/use-prefetch.ts Normal file
View File

@ -0,0 +1,11 @@
import { useUserBetContracts } from './use-contracts'
import { usePortfolioHistory } from './use-portfolio-history'
import { useUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) {
const maybeUserId = userId ?? ''
useUserBets(maybeUserId)
useUserBetContracts(maybeUserId)
usePortfolioHistory(maybeUserId, 'weekly')
}

View File

@ -1,22 +1,21 @@
import { uniq } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { useEffect, useState } from 'react'
import {
Bet,
listenForUserBets,
getUserBetsQuery,
listenForUserContractBets,
} from 'web/lib/firebase/bets'
export const useUserBets = (
userId: string | undefined,
options: { includeRedemptions: boolean }
) => {
const [bets, setBets] = useState<Bet[] | undefined>(undefined)
useEffect(() => {
if (userId) return listenForUserBets(userId, setBets, options)
}, [userId])
return bets
export const useUserBets = (userId: string) => {
const result = useFirestoreQueryData(
['bets', userId],
getUserBetsQuery(userId),
{ subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
// https://github.com/invertase/react-query-firebase/issues/25
{ refetchOnMount: 'always' }
)
return result.data
}
export const useUserContractBets = (
@ -33,36 +32,6 @@ export const useUserContractBets = (
return bets
}
export const useUserBetContracts = (
userId: string | undefined,
options: { includeRedemptions: boolean }
) => {
const [contractIds, setContractIds] = useState<string[] | undefined>()
useEffect(() => {
if (userId) {
const key = `user-bet-contractIds-${userId}`
const userBetContractJson = localStorage.getItem(key)
if (userBetContractJson) {
setContractIds(JSON.parse(userBetContractJson))
}
return listenForUserBets(
userId,
(bets) => {
const contractIds = uniq(bets.map((bet) => bet.contractId))
setContractIds(contractIds)
localStorage.setItem(key, JSON.stringify(contractIds))
},
options
)
}
}, [userId])
return contractIds
}
export const useGetUserBetContractIds = (userId: string | undefined) => {
const [contractIds, setContractIds] = useState<string[] | undefined>()

View File

@ -1,5 +1,6 @@
import { auth } from './users'
import { APIError, getFunctionUrl } from 'common/api'
import { JSONContent } from '@tiptap/core'
export { APIError } from 'common/api'
export async function call(url: string, method: string, params: any) {
@ -88,3 +89,7 @@ export function acceptChallenge(params: any) {
export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
}
export function createPost(params: { title: string; content: JSONContent }) {
return call(getFunctionUrl('createpost'), 'POST', params)
}

View File

@ -11,6 +11,7 @@ import {
getDocs,
getDoc,
DocumentSnapshot,
Query,
} from 'firebase/firestore'
import { uniq } from 'lodash'
@ -131,24 +132,12 @@ export async function getContractsOfUserBets(userId: string) {
return filterDefined(contracts)
}
export function listenForUserBets(
userId: string,
setBets: (bets: Bet[]) => void,
options: { includeRedemptions: boolean }
) {
const { includeRedemptions } = options
const userQuery = query(
export function getUserBetsQuery(userId: string) {
return query(
collectionGroup(db, 'bets'),
where('userId', '==', userId),
orderBy('createdTime', 'desc')
)
return listenForValues<Bet>(userQuery, (bets) => {
setBets(
bets.filter(
(bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte
)
)
})
) as Query<Bet>
}
export function listenForUserContractBets(

View File

@ -1,4 +1,5 @@
import {
Query,
collection,
collectionGroup,
doc,
@ -148,12 +149,10 @@ export function listenForRecentComments(
return listenForValues<Comment>(recentCommentsQuery, setComments)
}
const getUsersCommentsQuery = (userId: string) =>
export const getUserCommentsQuery = (userId: string) =>
query(
collectionGroup(db, 'comments'),
where('userId', '==', userId),
where('commentType', '==', 'contract'),
orderBy('createdTime', 'desc')
)
export async function getUsersComments(userId: string) {
return await getValues<Comment>(getUsersCommentsQuery(userId))
}
) as Query<ContractComment>

View File

@ -6,13 +6,14 @@ import {
getDocs,
limit,
orderBy,
Query,
query,
setDoc,
startAfter,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy, sum } from 'lodash'
import { sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract'
@ -156,6 +157,13 @@ export function listenForUserContracts(
return listenForValues<Contract>(q, setContracts)
}
export function getUserBetContractsQuery(userId: string) {
return query(
contracts,
where('uniqueBettorIds', 'array-contains', userId)
) as Query<Contract>
}
const activeContractsQuery = query(
contracts,
where('isResolved', '==', false),
@ -305,7 +313,7 @@ export const getRandTopCreatorContracts = async (
where('isResolved', '==', false),
where('creatorId', '==', creatorId),
orderBy('popularityScore', 'desc'),
limit(Math.max(count * 2, 15))
limit(count * 2)
)
const data = await getValues<Contract>(creatorContractsQuery)
const open = data
@ -315,6 +323,44 @@ export const getRandTopCreatorContracts = async (
return chooseRandomSubset(open, count)
}
export const getRandTopGroupContracts = async (
groupSlug: string,
count: number,
excluding: string[] = []
) => {
const creatorContractsQuery = query(
contracts,
where('groupSlugs', 'array-contains', groupSlug),
where('isResolved', '==', false),
orderBy('popularityScore', 'desc'),
limit(count * 2)
)
const data = await getValues<Contract>(creatorContractsQuery)
const open = data
.filter((c) => c.closeTime && c.closeTime > Date.now())
.filter((c) => !excluding.includes(c.id))
return chooseRandomSubset(open, count)
}
export const getRecommendedContracts = async (
contract: Contract,
count: number
) => {
const { creatorId, groupSlugs, id } = contract
const [userContracts, groupContracts] = await Promise.all([
getRandTopCreatorContracts(creatorId, count, [id]),
groupSlugs && groupSlugs[0]
? getRandTopGroupContracts(groupSlugs[0], count, [id])
: [],
])
const combined = uniqBy([...userContracts, ...groupContracts], (c) => c.id)
return chooseRandomSubset(combined, count)
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id)

View File

@ -1,7 +1,5 @@
import { collection, limit, orderBy, query, where } from 'firebase/firestore'
import { Notification } from 'common/notification'
import { db } from 'web/lib/firebase/init'
import { listenForValues } from 'web/lib/firebase/utils'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export function getNotificationsQuery(
@ -23,17 +21,3 @@ export function getNotificationsQuery(
limit(NOTIFICATIONS_PER_PAGE * 10)
)
}
export function listenForNotifications(
userId: string,
setNotifications: (notifs: Notification[]) => void,
unseenOnlyOptions?: { unseenOnly: boolean; limit: number }
) {
return listenForValues<Notification>(
getNotificationsQuery(userId, unseenOnlyOptions),
(notifs) => {
notifs.sort((n1, n2) => n2.createdTime - n1.createdTime)
setNotifications(notifs)
}
)
}

34
web/lib/firebase/posts.ts Normal file
View File

@ -0,0 +1,34 @@
import {
deleteDoc,
doc,
getDocs,
query,
updateDoc,
where,
} from 'firebase/firestore'
import { Post } from 'common/post'
import { coll, getValue } from './utils'
export const posts = coll<Post>('posts')
export function postPath(postSlug: string) {
return `/post/${postSlug}`
}
export function updatePost(post: Post, updates: Partial<Post>) {
return updateDoc(doc(posts, post.id), updates)
}
export function deletePost(post: Post) {
return deleteDoc(doc(posts, post.id))
}
export function getPost(postId: string) {
return getValue<Post>(doc(posts, postId))
}
export async function getPostBySlug(slug: string) {
const q = query(posts, where('slug', '==', slug))
const docs = (await getDocs(q)).docs
return docs.length === 0 ? null : docs[0].data()
}

View File

@ -12,6 +12,7 @@ import {
deleteDoc,
collectionGroup,
onSnapshot,
Query,
} from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
@ -253,14 +254,13 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
await deleteDoc(followDoc)
}
export async function getPortfolioHistory(userId: string) {
return getValues<PortfolioMetrics>(
query(
export function getPortfolioHistoryQuery(userId: string, since: number) {
return query(
collectionGroup(db, 'portfolioHistory'),
where('userId', '==', userId),
where('timestamp', '>=', since),
orderBy('timestamp', 'asc')
)
)
) as Query<PortfolioMetrics>
}
export function listenForFollows(

View File

@ -0,0 +1,27 @@
export default function TrophyIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentcolor"
strokeWidth="2"
{...props}
>
<g>
<path
d="m6,5c0,4 1.4,7.8 3.5,8.5l0,2c-1.2,0.7 -1.2,1 -1.6,4l8,0c-0.4,-3 -0.4,-3.3 -1.6,-4l0,-2c2.1,-0.7 3.5,-4.5 3.5,-8.5z"
strokeLinejoin="round"
fill="none"
/>
<path
d="m6.2,8.3c-2.5,-1.6 -3.5,1 -3,2.5c1,1.7 2.6,2.5 4.5,1.8"
fill="none"
/>
<path
d="m17.6,8.3c2.5,-1.6 3.5,1 3,2.5c-1,1.7 -2.6,2.5 -4.5,1.8"
fill="none"
/>
</g>
</svg>
)
}

View File

@ -27,14 +27,14 @@
"@nivo/line": "0.74.0",
"@nivo/tooltip": "0.74.0",
"@react-query-firebase/firestore": "0.4.2",
"@tiptap/core": "2.0.0-beta.181",
"@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-character-count": "2.0.0-beta.31",
"@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/extension-placeholder": "2.0.0-beta.53",
"@tiptap/react": "2.0.0-beta.114",
"@tiptap/starter-kit": "2.0.0-beta.190",
"@tiptap/starter-kit": "2.0.0-beta.191",
"algoliasearch": "4.13.0",
"browser-image-compression": "2.0.0",
"clsx": "1.1.1",

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { ArrowLeftIcon } from '@heroicons/react/outline'
import { useContractWithPreload } from 'web/hooks/use-contract'
@ -11,7 +11,7 @@ import { Spacer } from 'web/components/layout/spacer'
import {
Contract,
getContractFromSlug,
getRandTopCreatorContracts,
getRecommendedContracts,
tradingAllowed,
} from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO'
@ -40,8 +40,9 @@ import {
ContractLeaderboard,
ContractTopTrades,
} from 'web/components/contract/contract-leaderboard'
import { Subtitle } from 'web/components/subtitle'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { Title } from 'web/components/title'
import { usePrefetch } from 'web/hooks/use-prefetch'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -54,9 +55,7 @@ export async function getStaticPropz(props: {
const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [],
contract
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
contract ? getRecommendedContracts(contract, 6) : [],
])
return {
@ -108,7 +107,9 @@ export default function ContractPage(props: {
return <Custom404 />
}
return <ContractPageContent {...{ ...props, contract, user }} />
return (
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
)
}
export function ContractPageSidebar(props: {
@ -154,9 +155,10 @@ export function ContractPageContent(
user?: User | null
}
) {
const { backToHome, comments, user, recommendedContracts } = props
const { backToHome, comments, user } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id)
useTracking('view market', {
slug: contract.slug,
@ -165,6 +167,10 @@ export function ContractPageContent(
})
const bets = useBets(contract.id) ?? props.bets
const nonChallengeBets = useMemo(
() => bets.filter((b) => !b.challengeSlug),
[bets]
)
// Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -182,6 +188,16 @@ export function ContractPageContent(
setShowConfetti(shouldSeeConfetti)
}, [contract, user])
const [recommendedContracts, setRecommendedMarkets] = useState(
props.recommendedContracts
)
useEffect(() => {
if (contract && recommendedContracts.length === 0) {
getRecommendedContracts(contract, 6).then(setRecommendedMarkets)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contract.id, recommendedContracts])
const { isResolved, question, outcomeType } = contract
const allowTrade = tradingAllowed(contract)
@ -220,10 +236,7 @@ export function ContractPageContent(
</button>
)}
<ContractOverview
contract={contract}
bets={bets.filter((b) => !b.challengeSlug)}
/>
<ContractOverview contract={contract} bets={nonChallengeBets} />
{outcomeType === 'NUMERIC' && (
<AlertBox
@ -267,14 +280,17 @@ export function ContractPageContent(
tips={tips}
comments={comments}
/>
</Col>
{recommendedContracts?.length > 0 && (
<Col className="mx-2 gap-2 sm:mx-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
{recommendedContracts.length > 0 && (
<Col className="mt-2 gap-2 px-2 sm:px-0">
<Title className="text-gray-700" text="Recommended" />
<ContractsGrid
contracts={recommendedContracts}
trackingPostfix=" recommended"
/>
</Col>
)}
</Col>
</Page>
)
}

93
web/pages/create-post.tsx Normal file
View File

@ -0,0 +1,93 @@
import { useState } from 'react'
import { Spacer } from 'web/components/layout/spacer'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import Textarea from 'react-expanding-textarea'
import { TextEditor, useTextEditor } from 'web/components/editor'
import { createPost } from 'web/lib/firebase/api'
import clsx from 'clsx'
import Router from 'next/router'
import { MAX_POST_TITLE_LENGTH } from 'common/post'
import { postPath } from 'web/lib/firebase/posts'
export default function CreatePost() {
const [title, setTitle] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const { editor, upload } = useTextEditor({
disabled: isSubmitting,
})
const isValid = editor && title.length > 0 && editor.isEmpty === false
async function savePost(title: string) {
if (!editor) return
const newPost = {
title: title,
content: editor.getJSON(),
}
const result = await createPost(newPost).catch((e) => {
console.log(e)
setError('There was an error creating the post, please try again')
return e
})
if (result.post) {
await Router.push(postPath(result.post.slug))
}
}
return (
<Page>
<div className="mx-auto w-full max-w-2xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Title<span className={'text-red-700'}> *</span>
</span>
</label>
<Textarea
placeholder="e.g. Elon Mania Post"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_POST_TITLE_LENGTH}
value={title}
onChange={(e) => setTitle(e.target.value || '')}
/>
<Spacer h={6} />
<label className="label">
<span className="mb-1">
Content<span className={'text-red-700'}> *</span>
</span>
</label>
<TextEditor editor={editor} upload={upload} />
<Spacer h={6} />
<button
type="submit"
className={clsx(
'btn btn-primary normal-case',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid || upload.isLoading}
onClick={async () => {
setIsSubmitting(true)
await savePost(title)
setIsSubmitting(false)
}}
>
{isSubmitting ? 'Creating...' : 'Create a post'}
</button>
{error !== '' && <div className="text-red-700">{error}</div>}
</div>
</form>
</div>
</div>
</Page>
)
}

View File

@ -33,6 +33,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
import { MINUTE_MS } from 'common/util/time'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
@ -66,6 +67,18 @@ export default function Create(props: { auth: { user: User } }) {
if (!router.isReady) return <div />
if (user.isBannedFromPosting)
return (
<Page>
<div className="mx-auto w-full max-w-2xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a market" />
<p>Sorry, you are currently banned from creating a market.</p>
</div>
</div>
</Page>
)
return (
<Page>
<SEO
@ -209,7 +222,9 @@ export function NewContract(props: {
max: MAX_DESCRIPTION_LENGTH,
placeholder: descriptionPlaceholder,
disabled: isSubmitting,
defaultValue: JSON.parse(params?.description ?? '{}'),
defaultValue: params?.description
? JSON.parse(params.description)
: undefined,
})
const isEditorFilled = editor != null && !editor.isEmpty
@ -427,7 +442,7 @@ export function NewContract(props: {
className="input input-bordered mt-4"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()}
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
disabled={isSubmitting}
value={closeDate}
/>

Some files were not shown because too many files have changed in this diff Show More