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

View File

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

View File

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

View File

@ -8,11 +8,11 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "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-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@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" "lodash": "4.17.21"
}, },
"devDependencies": { "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 currentBettingStreak?: number
hasSeenContractFollowModal?: boolean hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number freeMarketsCreated?: number
isBannedFromPosting?: boolean
} }
export type PrivateUser = { 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 export const DAY_MS = 24 * HOUR_MS

View File

@ -40,6 +40,10 @@
"collectionGroup": "comments", "collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",
"fields": [ "fields": [
{
"fieldPath": "commentType",
"order": "ASCENDING"
},
{ {
"fieldPath": "userId", "fieldPath": "userId",
"order": "ASCENDING" "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(); 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 = { module.exports = {
plugins: ['lodash'], plugins: ['lodash', 'unused-imports'],
extends: ['eslint:recommended'], extends: ['eslint:recommended'],
ignorePatterns: ['dist', 'lib'], ignorePatterns: ['dist', 'lib'],
env: { env: {
@ -26,6 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'error',
}, },
}, },
], ],

View File

@ -26,11 +26,11 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@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-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@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", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "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,84 +1,91 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market answer</title>
<style type="text/css"> <head>
img { <meta name="viewport" content="width=device-width" />
max-width: 100%; <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market answer</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
} }
body { h1 {
-webkit-font-smoothing: antialiased; font-weight: 800 !important;
-webkit-text-size-adjust: none; 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; width: 100% !important;
height: 100%;
line-height: 1.6em;
} }
body { .content {
background-color: #f6f6f6; padding: 0 !important;
} }
@media only screen and (max-width: 640px) { .content-wrap {
body { padding: 10px !important;
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;
}
} }
</style>
</head>
<body .invoice {
itemscope width: 100% !important;
itemtype="http://schema.org/EmailMessage" }
style=" }
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; 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; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0; margin: 0;
padding: 0 0 0px 0; padding: 0 0 0px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300" </td>
style="height: auto" </tr>
alt="Manifold Markets" <tr style="
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -266,19 +218,15 @@
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
margin-top: 10px; margin-top: 10px;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -286,37 +234,26 @@
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
font-weight: bold; font-weight: bold;
" " valign="top">
valign="top" <div>
> <img src="{{avatarUrl}}" width="30" height="30" style="
<div>
<img
src="{{avatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px; border-radius: 30px;
overflow: hidden; overflow: hidden;
vertical-align: middle; vertical-align: middle;
margin-right: 4px; margin-right: 4px;
" " alt="" />
alt="" {{name}}
/> </div>
{{name}} </td>
</div> </tr>
</td> <tr style="
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -324,40 +261,29 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top" <div style="
>
<div
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <span style="white-space: pre-line">{{answer}}</span>
<span style="white-space: pre-line" </div>
>{{answer}}</span </td>
> </tr>
</div> <tr style="
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="padding: 20px 0 0 0; margin: 0">
<td style="padding: 20px 0 0 0; margin: 0"> <div align="center">
<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]-->
<!--[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; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -375,38 +301,29 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View answer</span></span>
>View answer</span </a>
></span <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
> </div>
</a> </td>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> </tr>
</div> </table>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
</table> </table>
</td> <div class="footer" style="
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -415,28 +332,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -446,14 +355,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center" Questions? Come ask in
valign="top" <a href="https://discord.gg/eHQBNBqXuh" style="
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -461,12 +365,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -474,26 +374,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a </td>
>. </tr>
</td> </table>
</tr>
</table>
</div>
</div> </div>
</td> </div>
<td </td>
style=" <td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" </tr>
></td> </table>
</tr> </body>
</table>
</body> </html>
</html>

View File

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

View File

@ -1,84 +1,91 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html style="
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market comment</title>
<style type="text/css"> <head>
img { <meta name="viewport" content="width=device-width" />
max-width: 100%; <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Market comment</title>
<style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
} }
body { h1 {
-webkit-font-smoothing: antialiased; font-weight: 800 !important;
-webkit-text-size-adjust: none; 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; width: 100% !important;
height: 100%;
line-height: 1.6em;
} }
body { .content {
background-color: #f6f6f6; padding: 0 !important;
} }
@media only screen and (max-width: 640px) { .content-wrap {
body { padding: 10px !important;
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;
}
} }
</style>
</head>
<body .invoice {
itemscope width: 100% !important;
itemtype="http://schema.org/EmailMessage" }
style=" }
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -89,43 +96,29 @@
line-height: 1.6em; line-height: 1.6em;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <table class="body-wrap" style="
>
<table
class="body-wrap"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
background-color: #f6f6f6; background-color: #f6f6f6;
margin: 0; margin: 0;
" " bgcolor="#f6f6f6">
bgcolor="#f6f6f6" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" <td class="container" width="600" style="
></td>
<td
class="container"
width="600"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -134,12 +127,8 @@
max-width: 600px !important; max-width: 600px !important;
clear: both !important; clear: both !important;
margin: 0 auto; margin: 0 auto;
" " valign="top">
valign="top" <div class="content" style="
>
<div
class="content"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -147,14 +136,8 @@
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding: 20px; 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; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -162,20 +145,14 @@
background-color: #fff; background-color: #fff;
margin: 0; margin: 0;
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
" " bgcolor="#fff">
bgcolor="#fff" <tr style="
>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-wrap aligncenter" style="
<td
class="content-wrap aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -183,35 +160,23 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" " align="center" valign="top">
align="center" <table width="100%" cellpadding="0" cellspacing="0" style="
valign="top"
>
<table
width="100%"
cellpadding="0"
cellspacing="0"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
width: 90%; width: 90%;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="content-block" style="
<td
class="content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -220,29 +185,21 @@
margin: 0; margin: 0;
padding: 0 0 0px 0; padding: 0 0 0px 0;
text-align: left; text-align: left;
" " valign="top">
valign="top" <a href="https://manifold.markets" target="_blank">
> <img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
<img alt="Manifold Markets" />
src="https://manifold.markets/logo-banner.png" </a>
width="300" </td>
style="height: auto" </tr>
alt="Manifold Markets" <tr style="
/>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td class="content-block aligncenter" style="
<td
class="content-block aligncenter"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -251,13 +208,8 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0; padding: 0;
" " align="center" valign="top">
align="center" <table class="invoice" style="
valign="top"
>
<table
class="invoice"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -266,59 +218,42 @@
width: 80%; width: 80%;
margin: 40px auto; margin: 40px auto;
margin-top: 10px; margin-top: 10px;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top" <div>
> <img src="{{commentorAvatarUrl}}" width="30" height="30" style="
<div>
<img
src="{{commentorAvatarUrl}}"
width="30"
height="30"
style="
border-radius: 30px; border-radius: 30px;
overflow: hidden; overflow: hidden;
vertical-align: middle; vertical-align: middle;
margin-right: 4px; margin-right: 4px;
" " alt="" />
alt="" <span style="font-weight: bold">{{commentorName}}</span>
/> {{betDescription}}
<span style="font-weight: bold" </div>
>{{commentorName}}</span </td>
> </tr>
{{betDescription}} <tr style="
</div>
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="
<td
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -326,40 +261,29 @@
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
padding: 5px 0; padding: 5px 0;
" " valign="top">
valign="top" <div style="
>
<div
style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <span style="white-space: pre-line">{{comment}}</span>
<span style="white-space: pre-line" </div>
>{{comment}}</span </td>
> </tr>
</div> <tr style="
</td>
</tr>
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" ">
> <td style="padding: 20px 0 0 0; margin: 0">
<td style="padding: 20px 0 0 0; margin: 0"> <div align="center">
<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]-->
<!--[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; box-sizing: border-box;
display: inline-block; display: inline-block;
font-family: arial, helvetica, sans-serif; font-family: arial, helvetica, sans-serif;
@ -377,38 +301,29 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
mso-border-alt: none; mso-border-alt: none;
" ">
> <span style="
<span
style="
display: block; display: block;
padding: 10px 20px; padding: 10px 20px;
line-height: 120%; line-height: 120%;
" "><span style="
><span
style="
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
line-height: 18.8px; line-height: 18.8px;
" ">View comment</span></span>
>View comment</span </a>
></span <!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
> </div>
</a> </td>
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]--> </tr>
</div> </table>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
</table> </table>
</td> <div class="footer" style="
</tr>
</table>
<div
class="footer"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
@ -417,28 +332,20 @@
color: #999; color: #999;
margin: 0; margin: 0;
padding: 20px; padding: 20px;
" ">
> <table width="100%" style="
<table
width="100%"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <tr style="
<tr
style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
" ">
> <td class="aligncenter content-block" style="
<td
class="aligncenter content-block"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -448,14 +355,9 @@
text-align: center; text-align: center;
margin: 0; margin: 0;
padding: 0 0 20px; padding: 0 0 20px;
" " align="center" valign="top">
align="center" Questions? Come ask in
valign="top" <a href="https://discord.gg/eHQBNBqXuh" style="
>
Questions? Come ask in
<a
href="https://discord.gg/eHQBNBqXuh"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -463,12 +365,8 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">our Discord</a>! Or,
>our Discord</a <a href="{{unsubscribeUrl}}" style="
>! Or,
<a
href="{{unsubscribeUrl}}"
style="
font-family: 'Helvetica Neue', Helvetica, Arial, font-family: 'Helvetica Neue', Helvetica, Arial,
sans-serif; sans-serif;
box-sizing: border-box; box-sizing: border-box;
@ -476,26 +374,22 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
" ">unsubscribe</a>.
>unsubscribe</a </td>
>. </tr>
</td> </table>
</tr>
</table>
</div>
</div> </div>
</td> </div>
<td </td>
style=" <td style="
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
box-sizing: border-box; box-sizing: border-box;
font-size: 14px; font-size: 14px;
vertical-align: top; vertical-align: top;
margin: 0; margin: 0;
" " valign="top"></td>
valign="top" </tr>
></td> </table>
</tr> </body>
</table>
</body> </html>
</html>

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { floatingEqual } from '../../common/util/math'
export const redeemShares = async (userId: string, contractId: string) => { export const redeemShares = async (userId: string, contractId: string) => {
return await firestore.runTransaction(async (trans) => { 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 betsSnap = await trans.get(betsColl.where('userId', '==', userId))
const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
if (netAmount === 0) { if (floatingEqual(netAmount, 0)) {
return { status: 'success' } return { status: 'success' }
} }
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) 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 { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token' import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -55,16 +55,18 @@ export const updateMetricsCore = async () => {
const now = Date.now() const now = Date.now()
const betsByContract = groupBy(bets, (bet) => bet.contractId) const betsByContract = groupBy(bets, (bet) => bet.contractId)
const contractUpdates = contracts.map((contract) => { const contractUpdates = contracts
const contractBets = betsByContract[contract.id] ?? [] .filter((contract) => contract.id)
return { .map((contract) => {
doc: firestore.collection('contracts').doc(contract.id), const contractBets = betsByContract[contract.id] ?? []
fields: { return {
volume24Hours: computeVolume(contractBets, now - DAY_MS), doc: firestore.collection('contracts').doc(contract.id),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), fields: {
}, volume24Hours: computeVolume(contractBets, now - DAY_MS),
} volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
}) },
}
})
await writeAsync(firestore, contractUpdates) await writeAsync(firestore, contractUpdates)
log(`Updated metrics for ${contracts.length} contracts.`) log(`Updated metrics for ${contracts.length} contracts.`)

View File

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

View File

@ -8,20 +8,22 @@
"web" "web"
], ],
"scripts": { "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": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@types/node": "16.11.11",
"@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0", "@typescript-eslint/parser": "5.25.0",
"@types/node": "16.11.11",
"concurrently": "6.5.1", "concurrently": "6.5.1",
"eslint": "8.15.0", "eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0", "eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "^2.0.0",
"nodemon": "2.0.19",
"prettier": "2.5.0", "prettier": "2.5.0",
"typescript": "4.6.4",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"nodemon": "2.0.19" "typescript": "4.6.4"
}, },
"resolutions": { "resolutions": {
"@types/react": "17.0.43" "@types/react": "17.0.43"

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['lodash'], plugins: ['lodash', 'unused-imports'],
extends: [ extends: [
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended', 'plugin:react-hooks/recommended',
@ -22,6 +22,7 @@ module.exports = {
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'error',
}, },
env: { env: {
browser: true, 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 { Point, ResponsiveLine } from '@nivo/line'
import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { zip } from 'lodash' import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
@ -26,8 +27,10 @@ export function DailyCountChart(props: {
return ( return (
<div <div
className="w-full overflow-hidden" className={clsx(
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} 'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}
@ -78,8 +81,10 @@ export function DailyPercentChart(props: {
return ( return (
<div <div
className="w-full overflow-hidden" className={clsx(
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }} 'h-[250px] w-full overflow-hidden',
!small && 'md:h-[400px]'
)}
> >
<ResponsiveLine <ResponsiveLine
data={data} data={data}

View File

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

View File

@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100] const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2 const numXTickValues = isLargeWidth ? 5 : 2
const hoursAgo = latestTime.subtract(5, 'hours') const startDate = new Date(contract.createdTime)
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo) const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
? new Date(contract.createdTime) ? latestTime.add(1, 'hours').toDate()
: hoursAgo.toDate() : latestTime.toDate()
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
xScale={{ xScale={{
type: 'time', type: 'time',
min: startDate, min: startDate,
max: latestTime.toDate(), max: endDate,
}} }}
xFormat={(d) => xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
} }
axisBottom={{ axisBottom={{
tickValues: numXTickValues, 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} pointSize={0}
curve="stepAfter" curve="stepAfter"
enableSlices="x" enableSlices="x"
@ -156,7 +165,11 @@ function formatTime(
) { ) {
const d = dayjs(time) 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 let format: string
if (d.isSame(Date.now(), 'day')) { 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 { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
import { Button } from '../button'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props const { contract } = props
@ -115,6 +116,8 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = (currentReturn * 100).toFixed() + '%' const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Col className="gap-4 rounded"> <Col className="gap-4 rounded">
<Col className="flex-1 gap-2"> <Col className="flex-1 gap-2">
@ -201,12 +204,14 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</button> </button>
) : ( ) : (
text && ( text && (
<button <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" color="green"
size="lg"
className="self-end whitespace-nowrap "
onClick={withTracking(firebaseLogin, 'answer panel sign in')} onClick={withTracking(firebaseLogin, 'answer panel sign in')}
> >
Sign in Add my answer
</button> </Button>
) )
)} )}
</Col> </Col>

View File

@ -19,6 +19,15 @@ import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
type AuthUser = undefined | null | UserAndPrivateUser type AuthUser = undefined | null | UserAndPrivateUser
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' 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 = () => { const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token') let deviceToken = localStorage.getItem('device-token')
@ -46,29 +55,35 @@ export function AuthProvider(props: {
}, [setAuthUser, serverUser]) }, [setAuthUser, serverUser])
useEffect(() => { useEffect(() => {
return onIdTokenChanged(auth, async (fbUser) => { return onIdTokenChanged(
if (fbUser) { auth,
setTokenCookies({ async (fbUser) => {
id: await fbUser.getIdToken(), if (fbUser) {
refresh: fbUser.refreshToken, setTokenCookies({
}) id: await fbUser.getIdToken(),
let current = await getUserAndPrivateUser(fbUser.uid) refresh: fbUser.refreshToken,
if (!current.user || !current.privateUser) { })
const deviceToken = ensureDeviceToken() let current = await getUserAndPrivateUser(fbUser.uid)
current = (await createUser({ deviceToken })) as UserAndPrivateUser if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser
}
setAuthUser(current)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
deleteTokenCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
} }
setAuthUser(current) },
// Persist to local storage, to reduce login blink next time. (e) => {
// Note: Cap on localStorage size is ~5mb console.error(e)
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
deleteTokenCookies()
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
} }
}) )
}, [setAuthUser]) }, [setAuthUser])
const uid = authUser?.user.id 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 { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Button } from 'web/components/button' 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 */ /** Button that opens BetPanel in a new modal */
export default function BetButton(props: { export default function BetButton(props: {
@ -32,15 +32,17 @@ export default function BetButton(props: {
return ( return (
<> <>
<Col className={clsx('items-center', className)}> <Col className={clsx('items-center', className)}>
<Button {user ? (
size={'lg'} <Button
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} size="lg"
onClick={() => { className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
!user ? firebaseLogin() : setOpen(true) onClick={() => setOpen(true)}
}} >
> Bet
{user ? 'Bet' : 'Sign up to Bet'} </Button>
</Button> ) : (
<BetSignUpPrompt />
)}
{user && ( {user && (
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}> <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 { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets' import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user' 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 { getCpmmProbability } from 'common/calculate-cpmm'
import { Col } from './layout/col' import { Col } from './layout/col'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
@ -112,7 +112,7 @@ export function BetInline(props: {
: 'Submit'} : 'Submit'}
</Button> </Button>
)} )}
<SignUpPrompt size="xs" /> <BetSignUpPrompt size="xs" />
<button <button
onClick={() => { onClick={() => {
setProbAfter(undefined) setProbAfter(undefined)

View File

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

View File

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

View File

@ -37,8 +37,8 @@ export function Button(props: {
sm: 'px-3 py-2 text-sm', sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm', md: 'px-4 py-2 text-sm',
lg: 'px-4 py-2 text-base', lg: 'px-4 py-2 text-base',
xl: 'px-6 py-3 text-base', xl: 'px-6 py-2.5 text-base font-semibold',
'2xl': 'px-6 py-3 text-xl', '2xl': 'px-6 py-3 text-xl font-semibold',
}[size] }[size]
return ( return (
@ -52,9 +52,9 @@ export function Button(props: {
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500', color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500', color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600', 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' && 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' && color === 'gray-white' &&
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200', 'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
className className

View File

@ -2,7 +2,7 @@ import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { Challenge } from 'common/challenge' import { Challenge } from 'common/challenge'
import { useEffect, useState } from 'react' 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 { acceptChallenge, APIError } from 'web/lib/firebase/api'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
@ -27,7 +27,7 @@ export function AcceptChallengeButton(props: {
setErrorText('') setErrorText('')
}, [open]) }, [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 = () => { const iAcceptChallenge = () => {
setLoading(true) setLoading(true)

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react' import { ContractComment } from 'common/comment'
import { Comment, ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array' 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 { SiteLink } from './site-link'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Avatar } from './avatar' import { Avatar } from './avatar'
@ -10,11 +9,15 @@ import { RelativeTimestamp } from './relative-timestamp'
import { User } from 'common/user' import { User } from 'common/user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Content } from './editor' import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { UserLink } from 'web/components/user-link' 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) { function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't // 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 }) { export function UserCommentsList(props: { user: User }) {
const { user } = props 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(() => { const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
getUsersComments(user.id).then((cs) => { const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
// we don't show comments in groups here atm, just comments on contracts
setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
})
}, [user.id])
if (comments == null) { const pageComments = groupConsecutive(getItems(), (c) => {
return {
contractId: c.contractId,
contractQuestion: c.contractQuestion,
contractSlug: c.contractSlug,
}
})
if (isLoading) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
const pageComments = groupConsecutive(comments.slice(start, end), (c) => { if (pageComments.length === 0) {
return { question: c.contractQuestion, slug: c.contractSlug } 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 ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => { {pageComments.map(({ key, items }, i) => {
return ( return <ProfileCommentGroup key={i} groupKey={key} items={items} />
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
>
{key.question}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
))}
</Col>
</div>
)
})} })}
<Pagination <nav
page={page} className="border-t border-gray-200 px-4 py-3 sm:px-6"
itemsPerPage={COMMENTS_PER_PAGE} aria-label="Pagination"
totalItems={comments.length} >
setPage={setPage} <PaginationNextPrev
/> prev={!isStart ? 'Previous' : null}
next={!isEnd ? 'Next' : null}
onClickPrev={getPrev}
onClickNext={getNext}
scrollToTop={true}
/>
</nav>
</Col> </Col>
) )
} }
function ProfileComment(props: { comment: Comment; className?: string }) { function ProfileCommentGroup(props: {
const { comment, className } = props groupKey: ContractKey
items: ContractComment[]
}) {
const { groupKey, items } = props
const { contractSlug, contractQuestion } = groupKey
const path = contractPath(contractSlug)
return (
<div className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={path}
>
{contractQuestion}
</SiteLink>
<Col className="gap-6">
{items.map((c) => (
<ProfileComment key={c.id} comment={c} />
))}
</Col>
</div>
)
}
function ProfileComment(props: { comment: ContractComment }) {
const { comment } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment comment
// TODO: find and attach relevant bets by comment betId at some point // TODO: find and attach relevant bets by comment betId at some point
return ( return (
<Row className={className}> <Row className="relative flex items-start space-x-3">
<Avatar username={userUsername} avatarUrl={userAvatarUrl} /> <Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500"> <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 { trackCallback } from 'web/lib/service/analytics'
import { getMappedValue } from 'common/pseudo-numeric' import { getMappedValue } from 'common/pseudo-numeric'
import { Tooltip } from '../tooltip' import { Tooltip } from '../tooltip'
import { useWindowSize } from 'web/hooks/use-window-size'
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
showHotVolume?: boolean showHotVolume?: boolean
showTime?: ShowTime showTime?: ShowTime
className?: string className?: string
questionClass?: string
onClick?: () => void onClick?: () => void
hideQuickBet?: boolean hideQuickBet?: boolean
hideGroupLink?: boolean hideGroupLink?: boolean
trackingPostfix?: string
}) { }) {
const { const {
showHotVolume, showHotVolume,
showTime, showTime,
className, className,
questionClass,
onClick, onClick,
hideQuickBet, hideQuickBet,
hideGroupLink, hideGroupLink,
trackingPostfix,
} = props } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
@ -59,7 +64,11 @@ export function ContractCard(props: {
const marketClosed = const marketClosed =
(contract.closeTime || Infinity) < Date.now() || !!resolution (contract.closeTime || Infinity) < Date.now() || !!resolution
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const showQuickBet = const showQuickBet =
!isMobile &&
user && user &&
!marketClosed && !marketClosed &&
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
@ -68,45 +77,20 @@ export function ContractCard(props: {
return ( return (
<Row <Row
className={clsx( 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 className
)} )}
> >
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6"> <Col className="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>
)}
<AvatarDetails <AvatarDetails
contract={contract} contract={contract}
className={'hidden md:inline-flex'} className={'hidden md:inline-flex'}
/> />
<p <p
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2" className={clsx(
style={{ /* For iOS safari */ wordBreak: 'break-word' }} 'break-anywhere font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2',
questionClass
)}
> >
{question} {question}
</p> </p>
@ -124,7 +108,7 @@ export function ContractCard(props: {
))} ))}
</Col> </Col>
{showQuickBet ? ( {showQuickBet ? (
<QuickBet contract={contract} user={user} /> <QuickBet contract={contract} user={user} className="z-10" />
) : ( ) : (
<> <>
{outcomeType === 'BINARY' && ( {outcomeType === 'BINARY' && (
@ -165,11 +149,7 @@ export function ContractCard(props: {
showQuickBet ? 'w-[85%]' : 'w-full' showQuickBet ? 'w-[85%]' : 'w-full'
)} )}
> >
<AvatarDetails <AvatarDetails contract={contract} short={true} className="md:hidden" />
contract={contract}
short={true}
className={'block md:hidden'}
/>
<MiscDetails <MiscDetails
contract={contract} contract={contract}
showHotVolume={showHotVolume} showHotVolume={showHotVolume}
@ -177,6 +157,38 @@ export function ContractCard(props: {
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
/> />
</Row> </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> </Row>
) )
} }

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import { useAdmin, useDev } from 'web/hooks/use-admin'
import { SiteLink } from '../site-link' import { SiteLink } from '../site-link'
import { firestoreConsolePath } from 'common/envs/constants' import { firestoreConsolePath } from 'common/envs/constants'
import { deleteField } from 'firebase/firestore' import { deleteField } from 'firebase/firestore'
import ShortToggle from '../widgets/short-toggle'
export const contractDetailsButtonClassName = 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' '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 isDev = useDev()
const isAdmin = useAdmin() 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 } = const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
contract contract
@ -50,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
? 'Multiple choice' ? 'Multiple choice'
: 'Numeric' : '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 ( return (
<> <>
<button <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 */} {/* Show a path to Firebase if user is an admin, or we're on localhost */}
{(isAdmin || isDev) && ( {(isAdmin || isDev) && (
<tr> <tr>
<td>[DEV] Firestore</td> <td>[ADMIN] Firestore</td>
<td> <td>
<SiteLink href={firestoreConsolePath(id)}> <SiteLink href={firestoreConsolePath(id)}>
Console link Console link
@ -144,43 +160,28 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
)} )}
{isAdmin && ( {isAdmin && (
<tr> <tr>
<td>Set featured</td> <td>[ADMIN] Featured</td>
<td> <td>
<select <ShortToggle
className="select select-bordered" enabled={featured}
value={featured ? 'true' : 'false'} setEnabled={setFeatured}
onChange={(e) => { onChange={onFeaturedToggle}
const newVal = e.target.value === 'true' />
if ( </td>
newVal && </tr>
(contract.featuredOnHomeRank === 0 || )}
!contract?.featuredOnHomeRank) {isAdmin && (
) <tr>
updateContract(id, { <td>[ADMIN] Unlisted</td>
featuredOnHomeRank: 1, <td>
}) <ShortToggle
.then(() => { enabled={contract.visibility === 'unlisted'}
setFeatured(true) setEnabled={(b) =>
}) updateContract(id, {
.catch(console.error) visibility: b ? 'unlisted' : 'public',
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> </td>
</tr> </tr>
)} )}

View File

@ -75,36 +75,30 @@ export const ContractOverview = (props: {
{isBinary ? ( {isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} /> <BinaryResolutionOrChance contract={contract} />
<Row className={'items-center justify-center'}> {tradingAllowed(contract) && (
<LikeMarketButton contract={contract} user={user} /> <Col>
{tradingAllowed(contract) && ( <BetButton contract={contract as CPMMBinaryContract} />
<Col> {!user && (
<BetButton contract={contract as CPMMBinaryContract} /> <div className="mt-1 text-center text-sm text-gray-500">
{!user && ( (with play money!)
<div className="mt-1 text-center text-sm text-gray-500"> </div>
(with play money!) )}
</div> </Col>
)} )}
</Col>
)}
</Row>
</Row> </Row>
) : isPseudoNumeric ? ( ) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden"> <Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} /> <PseudoNumericResolutionOrExpectation contract={contract} />
<Row className={'items-center justify-center'}> {tradingAllowed(contract) && (
<LikeMarketButton contract={contract} user={user} /> <Col>
{tradingAllowed(contract) && ( <BetButton contract={contract} />
<Col> {!user && (
<BetButton contract={contract} /> <div className="mt-1 text-center text-sm text-gray-500">
{!user && ( (with play money!)
<div className="mt-1 text-center text-sm text-gray-500"> </div>
(with play money!) )}
</div> </Col>
)} )}
</Col>
)}
</Row>
</Row> </Row>
) : ( ) : (
(outcomeType === 'FREE_RESPONSE' || (outcomeType === 'FREE_RESPONSE' ||

View File

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

View File

@ -26,6 +26,7 @@ export function ContractsGrid(props: {
hideGroupLink?: boolean hideGroupLink?: boolean
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
trackingPostfix?: string
}) { }) {
const { const {
contracts, contracts,
@ -34,6 +35,7 @@ export function ContractsGrid(props: {
onContractClick, onContractClick,
cardHideOptions, cardHideOptions,
highlightOptions, highlightOptions,
trackingPostfix,
} = props } = props
const { hideQuickBet, hideGroupLink } = cardHideOptions || {} const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
const { contractIds, highlightClassName } = highlightOptions || {} const { contractIds, highlightClassName } = highlightOptions || {}
@ -79,6 +81,7 @@ export function ContractsGrid(props: {
} }
hideQuickBet={hideQuickBet} hideQuickBet={hideQuickBet}
hideGroupLink={hideGroupLink} hideGroupLink={hideGroupLink}
trackingPostfix={trackingPostfix}
className={clsx( className={clsx(
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
contractIds?.includes(contract.id) && highlightClassName 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: { export function QuickBet(props: {
contract: BinaryContract | PseudoNumericContract contract: BinaryContract | PseudoNumericContract
user: User user: User
className?: string
}) { }) {
const { contract, user } = props const { contract, user, className } = props
const { mechanism, outcomeType } = contract const { mechanism, outcomeType } = contract
const isCpmm = mechanism === 'cpmm-1' const isCpmm = mechanism === 'cpmm-1'
@ -139,6 +140,7 @@ export function QuickBet(props: {
return ( return (
<Col <Col
className={clsx( className={clsx(
className,
'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle' 'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
// Use this for colored QuickBet panes // Use this for colored QuickBet panes
// `bg-opacity-10 bg-${color}` // `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 React from 'react'
import Link from 'next/link'
import clsx from 'clsx'
export const createButtonStyle = import { User } from 'web/lib/firebase/users'
'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 { Button } from './button'
export const CreateQuestionButton = (props: { export const CreateQuestionButton = (props: {
user: User | null | undefined user: User | null | undefined
@ -13,32 +11,17 @@ export const CreateQuestionButton = (props: {
className?: string className?: string
query?: 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 { user, overrideText, className, query } = props
const router = useRouter()
if (!user || user?.isBannedFromPosting) return <></>
return ( return (
<div className={clsx('flex justify-center', className)}> <div className={clsx('flex justify-center', className)}>
{user ? ( <Link href={`/create${query ? query : ''}`} passHref>
<Link href={`/create${query ? query : ''}`} passHref> <Button color="gradient" size="xl" className="mt-4">
<button className={clsx(gradient, createButtonStyle)}> {overrideText ?? 'Create a market'}
{overrideText ? overrideText : 'Create a market'} </Button>
</button> </Link>
</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> </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' import { Tooltip } from './tooltip'
dayjs.extend(utc) const FORMATTER = new Intl.DateTimeFormat('default', {
dayjs.extend(timezone) dateStyle: 'medium',
dayjs.extend(advanced) timeStyle: 'long',
})
export function DateTimeTooltip(props: { export function DateTimeTooltip(props: {
time: Dayjs time: number
text?: string text?: string
className?: string className?: string
children?: React.ReactNode children?: React.ReactNode
@ -17,7 +14,7 @@ export function DateTimeTooltip(props: {
}) { }) {
const { className, time, text, noTap } = 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 const toolTip = text ? `${text} ${formattedTime}` : formattedTime
return ( return (

View File

@ -40,6 +40,11 @@ const embedPatterns: EmbedPattern[] = [
rewrite: (id) => rewrite: (id) =>
`<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`, `<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 is a bit annoying, since it requires the `&parent=DOMAIN` to match
{ {
// Twitch: https://www.twitch.tv/videos/1445087149 // Twitch: https://www.twitch.tv/videos/1445087149

View File

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

View File

@ -6,8 +6,6 @@ import Link from 'next/link'
import { fromNow } from 'web/lib/util/time' import { fromNow } from 'web/lib/util/time'
import { ToastClipboard } from 'web/components/toast-clipboard' import { ToastClipboard } from 'web/components/toast-clipboard'
import { LinkIcon } from '@heroicons/react/outline' import { LinkIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import dayjs from 'dayjs'
export function CopyLinkDateTimeComponent(props: { export function CopyLinkDateTimeComponent(props: {
prefix: string prefix: string
@ -18,7 +16,6 @@ export function CopyLinkDateTimeComponent(props: {
}) { }) {
const { prefix, slug, elementId, createdTime, className } = props const { prefix, slug, elementId, createdTime, className } = props
const [showToast, setShowToast] = useState(false) const [showToast, setShowToast] = useState(false)
const time = dayjs(createdTime)
function copyLinkToComment( function copyLinkToComment(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent> event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
@ -31,26 +28,19 @@ export function CopyLinkDateTimeComponent(props: {
setTimeout(() => setShowToast(false), 2000) setTimeout(() => setShowToast(false), 2000)
} }
return ( return (
<div className={clsx('inline', className)}> <DateTimeTooltip className={className} time={createdTime} noTap>
<DateTimeTooltip time={time} noTap> <Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}> <a
<a onClick={copyLinkToComment}
onClick={(event) => copyLinkToComment(event)} className={
className={'mx-1 cursor-pointer'} '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)} {fromNow(createdTime)}
{showToast && ( {showToast && <ToastClipboard />}
<ToastClipboard className={'left-24 sm:-left-16'} /> <LinkIcon className="ml-1 mb-0.5 inline" height={13} />
)} </a>
<LinkIcon </Link>
className="ml-1 mb-0.5 inline-block text-gray-400" </DateTimeTooltip>
height={13}
/>
</span>
</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 { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx' import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney, formatPercent } from 'common/util/format' import { formatMoney, formatPercent } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
@ -154,79 +153,3 @@ export function BetStatusText(props: {
</div> </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' const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return ( return (
<> <>
<Row className={'mb-2 gap-1 sm:gap-2'}> <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'} className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)} onClick={() => submitComment(presetId)}
> >
Sign in to comment Add my comment
</button> </button>
)} )}
</Row> </Row>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,6 @@ import {
DotsHorizontalIcon, DotsHorizontalIcon,
CashIcon, CashIcon,
HeartIcon, HeartIcon,
UserGroupIcon,
TrendingUpIcon,
ChatIcon, ChatIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
@ -18,17 +16,14 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu' import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu' import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon' 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 { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button' 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 { 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 { CHALLENGES_ENABLED } from 'common/challenge'
import { buildArray } from 'common/util/array' import { buildArray } from 'common/util/array'
import TrophyIcon from 'web/lib/icons/trophy-icon'
import { SignInButton } from '../sign-in-button'
const logout = async () => { const logout = async () => {
// log out, and then reload the page, in case SSR wants to boot them out // log out, and then reload the page, in case SSR wants to boot them out
@ -46,11 +41,12 @@ function getNavigation() {
icon: NotificationsIcon, icon: NotificationsIcon,
}, },
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
...(IS_PRIVATE_MANIFOLD ...(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) { if (!user) {
// Signed out "More"
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[ [
{ name: 'Tournaments', href: '/tournaments' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
@ -82,16 +78,15 @@ function getMoreNavigation(user?: User | null) {
) )
} }
// Signed in "More"
return buildArray( return buildArray(
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Groups', href: '/groups' },
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[ [
{ name: 'Referrals', href: '/referrals' }, { name: 'Referrals', href: '/referrals' },
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Help & About', href: 'https://help.manifold.markets/' }, { name: 'Help & About', href: 'https://help.manifold.markets/' },
{ {
@ -120,12 +115,12 @@ const signedOutMobileNavigation = [
icon: BookOpenIcon, icon: BookOpenIcon,
}, },
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { 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 }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
] ]
const signedInMobileNavigation = [ const signedInMobileNavigation = [
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon }, { name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
...(IS_PRIVATE_MANIFOLD ...(IS_PRIVATE_MANIFOLD
? [] ? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]), : [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
@ -147,11 +142,9 @@ function getMoreMobileNav() {
return buildArray<Item>( return buildArray<Item>(
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' }, CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
[ [
{ name: 'Groups', href: '/groups' },
{ name: 'Referrals', href: '/referrals' }, { name: 'Referrals', href: '/referrals' },
{ { name: 'Leaderboards', href: '/leaderboards' },
name: 'Salem tournament',
href: 'https://salemcenter.manifold.markets/',
},
{ name: 'Charity', href: '/charity' }, { name: 'Charity', href: '/charity' },
{ name: 'Send M$', href: '/links' }, { name: 'Send M$', href: '/links' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
@ -232,29 +225,23 @@ export default function Sidebar(props: { className?: string }) {
? signedOutMobileNavigation ? signedOutMobileNavigation
: signedInMobileNavigation : signedInMobileNavigation
const memberItems = (
useMemberGroups(user?.id, undefined, {
by: 'mostRecentContractAddedTime',
}) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}`,
}))
return ( 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 /> <ManifoldLogo className="py-6" twoLine />
<CreateQuestionButton user={user} /> {!user && <SignInButton className="mb-4" />}
<Spacer h={4} />
{user && ( {user && (
<div className="w-full" style={{ minHeight: 80 }}> <div className="min-h-[80px] w-full">
<ProfileSummary user={user} /> <ProfileSummary user={user} />
</div> </div>
)} )}
{/* Mobile navigation */} {/* 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) => ( {mobileNavigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} /> <SidebarItem key={item.href} item={item} currentPage={currentPage} />
))} ))}
@ -265,15 +252,10 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} 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> </div>
{/* Desktop navigation */} {/* 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) => ( {navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} /> <SidebarItem key={item.href} item={item} currentPage={currentPage} />
))} ))}
@ -282,65 +264,8 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
{/* Spacer if there are any groups */} {user && <CreateQuestionButton user={user} />}
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
</div> </div>
</nav> </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 { useEffect, useState } from 'react'
import { usePrivateUser } from 'web/hooks/use-user' import { usePrivateUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router' 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 { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
@ -30,7 +30,7 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
else setSeen(false) else setSeen(false)
}, [router.pathname]) }, [router.pathname])
const notifications = useUnseenPreferredNotificationGroups(privateUser) const notifications = useUnseenGroupedNotification(privateUser)
if (!notifications || notifications.length === 0 || seen) { if (!notifications || notifications.length === 0 || seen) {
return <div /> return <div />
} }

View File

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

View File

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

View File

@ -6,13 +6,11 @@ import { Toaster } from 'react-hot-toast'
export function Page(props: { export function Page(props: {
rightSidebar?: ReactNode rightSidebar?: ReactNode
suspend?: boolean
className?: string className?: string
rightSidebarClassName?: string rightSidebarClassName?: string
children?: ReactNode children?: ReactNode
}) { }) {
const { children, rightSidebar, suspend, className, rightSidebarClassName } = const { children, rightSidebar, className, rightSidebarClassName } = props
props
const bottomBarPadding = 'pb-[58px] lg:pb-0 ' const bottomBarPadding = 'pb-[58px] lg:pb-0 '
return ( return (
@ -23,10 +21,9 @@ export function Page(props: {
bottomBarPadding, bottomBarPadding,
'mx-auto w-full lg:grid lg:grid-cols-12 lg:gap-x-2 xl:max-w-7xl xl:gap-x-8' '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 /> <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 <main
className={clsx( className={clsx(
'lg:col-span-8 lg:pt-6', 'lg:col-span-8 lg:pt-6',
@ -46,22 +43,7 @@ export function Page(props: {
</div> </div>
</aside> </aside>
</div> </div>
<BottomNavBar /> <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 clsx from 'clsx'
import { Spacer } from './layout/spacer' 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: { export function Pagination(props: {
page: number page: number
itemsPerPage: number itemsPerPage: number
@ -44,24 +78,13 @@ export function Pagination(props: {
of <span className="font-medium">{totalItems}</span> results of <span className="font-medium">{totalItems}</span> results
</p> </p>
</div> </div>
<div className="flex flex-1 justify-between sm:justify-end"> <PaginationNextPrev
{page > 0 && ( prev={page > 0 ? prevTitle ?? 'Previous' : null}
<a next={page < maxPage ? nextTitle ?? 'Next' : null}
href={scrollToTop ? '#' : undefined} onClickPrev={() => setPage(page - 1)}
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" onClickNext={() => setPage(page + 1)}
onClick={() => page > 0 && setPage(page - 1)} scrollToTop={scrollToTop}
> />
{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>
</nav> </nav>
) )
} }

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ export function RelativeTimestamp(props: { time: number }) {
return ( return (
<DateTimeTooltip <DateTimeTooltip
className="ml-1 whitespace-nowrap text-gray-400" className="ml-1 whitespace-nowrap text-gray-400"
time={dayJsTime} time={time}
> >
{dayJsTime.fromNow()} {dayJsTime.fromNow()}
</DateTimeTooltip> </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 { withTracking } from 'web/lib/service/analytics'
import { Button, SizeType } from './button' import { Button, SizeType } from './button'
export function SignUpPrompt(props: { export function BetSignUpPrompt(props: {
label?: string label?: string
className?: string className?: string
size?: SizeType size?: SizeType

View File

@ -3,7 +3,7 @@ import { ReactNode } from 'react'
import Link from 'next/link' import Link from 'next/link'
export const linkClass = 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: { export const SiteLink = (props: {
href: string href: string
@ -19,7 +19,6 @@ export const SiteLink = (props: {
className={clsx(linkClass, className)} className={clsx(linkClass, className)}
href={href} href={href}
target={href.startsWith('http') ? '_blank' : undefined} target={href.startsWith('http') ? '_blank' : undefined}
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (onClick) onClick() if (onClick) onClick()

View File

@ -10,7 +10,7 @@ export function ToastClipboard(props: { className?: string }) {
className={clsx( className={clsx(
'border-base-300 absolute items-center' + 'border-base-300 absolute items-center' +
'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' + '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 className
)} )}
> >

View File

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

View File

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

View File

@ -5,13 +5,19 @@ import clsx from 'clsx'
export default function ShortToggle(props: { export default function ShortToggle(props: {
enabled: boolean enabled: boolean
setEnabled: (enabled: boolean) => void setEnabled: (enabled: boolean) => void
onChange?: (enabled: boolean) => void
}) { }) {
const { enabled, setEnabled } = props const { enabled, setEnabled } = props
return ( return (
<Switch <Switch
checked={enabled} 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" 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> <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 { isEqual } from 'lodash'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
@ -8,6 +9,7 @@ import {
listenForHotContracts, listenForHotContracts,
listenForInactiveContracts, listenForInactiveContracts,
listenForNewContracts, listenForNewContracts,
getUserBetContractsQuery,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
export const useContracts = () => { export const useContracts = () => {
@ -89,3 +91,15 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
? contracts.map((c) => contractDict.current[c.id]) ? contracts.map((c) => contractDict.current[c.id])
: undefined : 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 { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' 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) => { export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | 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_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification' import { Notification } from 'common/notification'
import { import { getNotificationsQuery } from 'web/lib/firebase/notifications'
getNotificationsQuery,
listenForNotifications,
} from 'web/lib/firebase/notifications'
import { groupBy, map, partition } from 'lodash' import { groupBy, map, partition } from 'lodash'
import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export type NotificationGroup = { export type NotificationGroup = {
notifications: Notification[] notifications: Notification[]
@ -17,49 +13,49 @@ export type NotificationGroup = {
type: 'income' | 'normal' type: 'income' | 'normal'
} }
// For some reason react-query subscriptions don't actually listen for notifications function useNotifications(privateUser: PrivateUser) {
// Use useUnseenPreferredNotificationGroups to listen for new notifications
export function usePreferredGroupedNotifications(
privateUser: PrivateUser,
cachedNotifications?: Notification[]
) {
const result = useFirestoreQueryData( const result = useFirestoreQueryData(
['notifications-all', privateUser.id], ['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(() => { const notifications = useMemo(() => {
if (result.isLoading) return cachedNotifications ?? [] if (!result.data) return undefined
if (!result.data) return cachedNotifications ?? []
const notifications = result.data as Notification[] const notifications = result.data as Notification[]
return getAppropriateNotifications( return getAppropriateNotifications(
notifications, notifications,
privateUser.notificationPreferences privateUser.notificationPreferences
).filter((n) => !n.isSeenOnHref) ).filter((n) => !n.isSeenOnHref)
}, [ }, [privateUser.notificationPreferences, result.data])
cachedNotifications,
privateUser.notificationPreferences,
result.data,
result.isLoading,
])
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(() => { return useMemo(() => {
if (notifications) return groupNotifications(notifications) if (notifications) return groupNotifications(notifications)
}, [notifications]) }, [notifications])
} }
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { export function useUnseenGroupedNotification(privateUser: PrivateUser) {
const notifications = useUnseenPreferredNotifications(privateUser, {}) const notifications = useUnseenNotifications(privateUser)
const [notificationGroups, setNotificationGroups] = useState< return useMemo(() => {
NotificationGroup[] | undefined if (notifications) return groupNotifications(notifications)
>(undefined)
useEffect(() => {
if (!notifications) return
const groupedNotifications = groupNotifications(notifications)
setNotificationGroups(groupedNotifications)
}, [notifications]) }, [notifications])
return notificationGroups
} }
export function groupNotifications(notifications: Notification[]) { export function groupNotifications(notifications: Notification[]) {
@ -120,36 +116,6 @@ export function groupNotifications(notifications: Notification[]) {
return notificationGroups 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 = [ const lessPriorityReasons = [
'on_contract_with_users_comment', 'on_contract_with_users_comment',
'on_contract_with_users_answer', '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 { useEffect, useState } from 'react'
import { import {
Bet, Bet,
listenForUserBets, getUserBetsQuery,
listenForUserContractBets, listenForUserContractBets,
} from 'web/lib/firebase/bets' } from 'web/lib/firebase/bets'
export const useUserBets = ( export const useUserBets = (userId: string) => {
userId: string | undefined, const result = useFirestoreQueryData(
options: { includeRedemptions: boolean } ['bets', userId],
) => { getUserBetsQuery(userId),
const [bets, setBets] = useState<Bet[] | undefined>(undefined) { subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
useEffect(() => { // https://github.com/invertase/react-query-firebase/issues/25
if (userId) return listenForUserBets(userId, setBets, options) { refetchOnMount: 'always' }
}, [userId]) )
return result.data
return bets
} }
export const useUserContractBets = ( export const useUserContractBets = (
@ -33,36 +32,6 @@ export const useUserContractBets = (
return bets 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) => { export const useGetUserBetContractIds = (userId: string | undefined) => {
const [contractIds, setContractIds] = useState<string[] | undefined>() const [contractIds, setContractIds] = useState<string[] | undefined>()

View File

@ -1,5 +1,6 @@
import { auth } from './users' import { auth } from './users'
import { APIError, getFunctionUrl } from 'common/api' import { APIError, getFunctionUrl } from 'common/api'
import { JSONContent } from '@tiptap/core'
export { APIError } from 'common/api' export { APIError } from 'common/api'
export async function call(url: string, method: string, params: any) { export async function call(url: string, method: string, params: any) {
@ -88,3 +89,7 @@ export function acceptChallenge(params: any) {
export function getCurrentUser(params: any) { export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params) 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, getDocs,
getDoc, getDoc,
DocumentSnapshot, DocumentSnapshot,
Query,
} from 'firebase/firestore' } from 'firebase/firestore'
import { uniq } from 'lodash' import { uniq } from 'lodash'
@ -131,24 +132,12 @@ export async function getContractsOfUserBets(userId: string) {
return filterDefined(contracts) return filterDefined(contracts)
} }
export function listenForUserBets( export function getUserBetsQuery(userId: string) {
userId: string, return query(
setBets: (bets: Bet[]) => void,
options: { includeRedemptions: boolean }
) {
const { includeRedemptions } = options
const userQuery = query(
collectionGroup(db, 'bets'), collectionGroup(db, 'bets'),
where('userId', '==', userId), where('userId', '==', userId),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc')
) ) as Query<Bet>
return listenForValues<Bet>(userQuery, (bets) => {
setBets(
bets.filter(
(bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte
)
)
})
} }
export function listenForUserContractBets( export function listenForUserContractBets(

View File

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

View File

@ -6,13 +6,14 @@ import {
getDocs, getDocs,
limit, limit,
orderBy, orderBy,
Query,
query, query,
setDoc, setDoc,
startAfter, startAfter,
updateDoc, updateDoc,
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { sortBy, sum } from 'lodash' import { sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils' import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract' import { BinaryContract, Contract } from 'common/contract'
@ -156,6 +157,13 @@ export function listenForUserContracts(
return listenForValues<Contract>(q, setContracts) return listenForValues<Contract>(q, setContracts)
} }
export function getUserBetContractsQuery(userId: string) {
return query(
contracts,
where('uniqueBettorIds', 'array-contains', userId)
) as Query<Contract>
}
const activeContractsQuery = query( const activeContractsQuery = query(
contracts, contracts,
where('isResolved', '==', false), where('isResolved', '==', false),
@ -305,7 +313,7 @@ export const getRandTopCreatorContracts = async (
where('isResolved', '==', false), where('isResolved', '==', false),
where('creatorId', '==', creatorId), where('creatorId', '==', creatorId),
orderBy('popularityScore', 'desc'), orderBy('popularityScore', 'desc'),
limit(Math.max(count * 2, 15)) limit(count * 2)
) )
const data = await getValues<Contract>(creatorContractsQuery) const data = await getValues<Contract>(creatorContractsQuery)
const open = data const open = data
@ -315,6 +323,44 @@ export const getRandTopCreatorContracts = async (
return chooseRandomSubset(open, count) 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) { export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(contracts, contract.id) const contractDoc = doc(contracts, contract.id)

View File

@ -1,7 +1,5 @@
import { collection, limit, orderBy, query, where } from 'firebase/firestore' import { collection, limit, orderBy, query, where } from 'firebase/firestore'
import { Notification } from 'common/notification'
import { db } from 'web/lib/firebase/init' import { db } from 'web/lib/firebase/init'
import { listenForValues } from 'web/lib/firebase/utils'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
export function getNotificationsQuery( export function getNotificationsQuery(
@ -23,17 +21,3 @@ export function getNotificationsQuery(
limit(NOTIFICATIONS_PER_PAGE * 10) 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, deleteDoc,
collectionGroup, collectionGroup,
onSnapshot, onSnapshot,
Query,
} from 'firebase/firestore' } from 'firebase/firestore'
import { getAuth } from 'firebase/auth' import { getAuth } from 'firebase/auth'
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth' import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
@ -253,14 +254,13 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
await deleteDoc(followDoc) await deleteDoc(followDoc)
} }
export async function getPortfolioHistory(userId: string) { export function getPortfolioHistoryQuery(userId: string, since: number) {
return getValues<PortfolioMetrics>( return query(
query( collectionGroup(db, 'portfolioHistory'),
collectionGroup(db, 'portfolioHistory'), where('userId', '==', userId),
where('userId', '==', userId), where('timestamp', '>=', since),
orderBy('timestamp', 'asc') orderBy('timestamp', 'asc')
) ) as Query<PortfolioMetrics>
)
} }
export function listenForFollows( 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/line": "0.74.0",
"@nivo/tooltip": "0.74.0", "@nivo/tooltip": "0.74.0",
"@react-query-firebase/firestore": "0.4.2", "@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-character-count": "2.0.0-beta.31",
"@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/extension-placeholder": "2.0.0-beta.53", "@tiptap/extension-placeholder": "2.0.0-beta.53",
"@tiptap/react": "2.0.0-beta.114", "@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", "algoliasearch": "4.13.0",
"browser-image-compression": "2.0.0", "browser-image-compression": "2.0.0",
"clsx": "1.1.1", "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 { ArrowLeftIcon } from '@heroicons/react/outline'
import { useContractWithPreload } from 'web/hooks/use-contract' import { useContractWithPreload } from 'web/hooks/use-contract'
@ -11,7 +11,7 @@ import { Spacer } from 'web/components/layout/spacer'
import { import {
Contract, Contract,
getContractFromSlug, getContractFromSlug,
getRandTopCreatorContracts, getRecommendedContracts,
tradingAllowed, tradingAllowed,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
@ -40,8 +40,9 @@ import {
ContractLeaderboard, ContractLeaderboard,
ContractTopTrades, ContractTopTrades,
} from 'web/components/contract/contract-leaderboard' } from 'web/components/contract/contract-leaderboard'
import { Subtitle } from 'web/components/subtitle'
import { ContractsGrid } from 'web/components/contract/contracts-grid' 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 const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -54,9 +55,7 @@ export async function getStaticPropz(props: {
const [bets, comments, recommendedContracts] = await Promise.all([ const [bets, comments, recommendedContracts] = await Promise.all([
contractId ? listAllBets(contractId) : [], contractId ? listAllBets(contractId) : [],
contractId ? listAllComments(contractId) : [], contractId ? listAllComments(contractId) : [],
contract contract ? getRecommendedContracts(contract, 6) : [],
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
: [],
]) ])
return { return {
@ -108,7 +107,9 @@ export default function ContractPage(props: {
return <Custom404 /> return <Custom404 />
} }
return <ContractPageContent {...{ ...props, contract, user }} /> return (
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
)
} }
export function ContractPageSidebar(props: { export function ContractPageSidebar(props: {
@ -154,9 +155,10 @@ export function ContractPageContent(
user?: User | null user?: User | null
} }
) { ) {
const { backToHome, comments, user, recommendedContracts } = props const { backToHome, comments, user } = props
const contract = useContractWithPreload(props.contract) ?? props.contract const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id)
useTracking('view market', { useTracking('view market', {
slug: contract.slug, slug: contract.slug,
@ -165,6 +167,10 @@ export function ContractPageContent(
}) })
const bets = useBets(contract.id) ?? props.bets 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. // Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -182,6 +188,16 @@ export function ContractPageContent(
setShowConfetti(shouldSeeConfetti) setShowConfetti(shouldSeeConfetti)
}, [contract, user]) }, [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 { isResolved, question, outcomeType } = contract
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
@ -220,10 +236,7 @@ export function ContractPageContent(
</button> </button>
)} )}
<ContractOverview <ContractOverview contract={contract} bets={nonChallengeBets} />
contract={contract}
bets={bets.filter((b) => !b.challengeSlug)}
/>
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<AlertBox <AlertBox
@ -267,14 +280,17 @@ export function ContractPageContent(
tips={tips} tips={tips}
comments={comments} comments={comments}
/> />
{recommendedContracts?.length > 0 && (
<Col className="mx-2 gap-2 sm:mx-0">
<Subtitle text="Recommended" />
<ContractsGrid contracts={recommendedContracts} />
</Col>
)}
</Col> </Col>
{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>
)}
</Page> </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 { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
import { MINUTE_MS } from 'common/util/time'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } } 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 (!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 ( return (
<Page> <Page>
<SEO <SEO
@ -209,7 +222,9 @@ export function NewContract(props: {
max: MAX_DESCRIPTION_LENGTH, max: MAX_DESCRIPTION_LENGTH,
placeholder: descriptionPlaceholder, placeholder: descriptionPlaceholder,
disabled: isSubmitting, disabled: isSubmitting,
defaultValue: JSON.parse(params?.description ?? '{}'), defaultValue: params?.description
? JSON.parse(params.description)
: undefined,
}) })
const isEditorFilled = editor != null && !editor.isEmpty const isEditorFilled = editor != null && !editor.isEmpty
@ -427,7 +442,7 @@ export function NewContract(props: {
className="input input-bordered mt-4" className="input input-bordered mt-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value)} onChange={(e) => setCloseDate(e.target.value)}
min={Date.now()} min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
disabled={isSubmitting} disabled={isSubmitting}
value={closeDate} value={closeDate}
/> />

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