Merge branch 'main' into likes-market-tips
This commit is contained in:
commit
77d927a4ec
43
.github/workflows/lint.yml
vendored
Normal file
43
.github/workflows/lint.yml
vendored
Normal 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 }}
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['lib'],
|
||||
env: {
|
||||
|
@ -26,6 +26,7 @@ module.exports = {
|
|||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -140,6 +140,8 @@ function getCpmmInvested(yourBets: Bet[]) {
|
|||
const sortedBets = sortBy(yourBets, 'createdTime')
|
||||
for (const bet of sortedBets) {
|
||||
const { outcome, shares, amount } = bet
|
||||
if (floatingEqual(shares, 0)) continue
|
||||
|
||||
if (amount > 0) {
|
||||
totalShares[outcome] = (totalShares[outcome] ?? 0) + shares
|
||||
totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { PortfolioMetrics, User } from './user'
|
||||
import { filterDefined } from './util/array'
|
||||
|
||||
const LOAN_DAILY_RATE = 0.01
|
||||
const LOAN_DAILY_RATE = 0.02
|
||||
|
||||
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
|
||||
const netValue = investedValue - loanTotal
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"lodash": "4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
12
common/post.ts
Normal file
12
common/post.ts
Normal 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
|
|
@ -44,6 +44,7 @@ export type User = {
|
|||
currentBettingStreak?: number
|
||||
hasSeenContractFollowModal?: boolean
|
||||
freeMarketsCreated?: number
|
||||
isBannedFromPosting?: boolean
|
||||
}
|
||||
|
||||
export type PrivateUser = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -40,6 +40,10 @@
|
|||
"collectionGroup": "comments",
|
||||
"queryScope": "COLLECTION_GROUP",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "commentType",
|
||||
"order": "ASCENDING"
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"order": "ASCENDING"
|
||||
|
|
|
@ -180,5 +180,14 @@ service cloud.firestore {
|
|||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
||||
}
|
||||
}
|
||||
|
||||
match /posts/{postId} {
|
||||
allow read;
|
||||
allow update: if request.auth.uid == resource.data.creatorId
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'content']);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: ['lodash'],
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
env: {
|
||||
|
@ -26,6 +26,7 @@ module.exports = {
|
|||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -26,11 +26,11 @@
|
|||
"dependencies": {
|
||||
"@amplitude/node": "1.10.0",
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
|
|
83
functions/src/create-post.ts
Normal file
83
functions/src/create-post.ts
Normal 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)
|
||||
}
|
|
@ -1,84 +1,91 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
style="
|
||||
<html style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Market answer</title>
|
||||
">
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
<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">
|
||||
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 {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
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;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h3 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
itemscope
|
||||
itemtype="http://schema.org/EmailMessage"
|
||||
style="
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -89,43 +96,29 @@
|
|||
line-height: 1.6em;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<table
|
||||
class="body-wrap"
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<table class="body-wrap" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
<td
|
||||
class="container"
|
||||
width="600"
|
||||
style="
|
||||
" valign="top"></td>
|
||||
<td class="container" width="600" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -134,12 +127,8 @@
|
|||
max-width: 600px !important;
|
||||
clear: both !important;
|
||||
margin: 0 auto;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
style="
|
||||
" valign="top">
|
||||
<div class="content" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -147,14 +136,8 @@
|
|||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
class="main"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -162,20 +145,14 @@
|
|||
background-color: #fff;
|
||||
margin: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
"
|
||||
bgcolor="#fff"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#fff">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-wrap aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-wrap aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -183,35 +160,23 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -220,29 +185,21 @@
|
|||
margin: 0;
|
||||
padding: 0 0 0px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<img
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
width="300"
|
||||
style="height: auto"
|
||||
alt="Manifold Markets"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||
alt="Manifold Markets" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-block aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -251,13 +208,8 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
class="invoice"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table class="invoice" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -266,19 +218,15 @@
|
|||
width: 80%;
|
||||
margin: 40px auto;
|
||||
margin-top: 10px;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -286,37 +234,26 @@
|
|||
margin: 0;
|
||||
padding: 5px 0;
|
||||
font-weight: bold;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
src="{{avatarUrl}}"
|
||||
width="30"
|
||||
height="30"
|
||||
style="
|
||||
" valign="top">
|
||||
<div>
|
||||
<img src="{{avatarUrl}}" width="30" height="30" style="
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
{{name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" alt="" />
|
||||
{{name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -324,40 +261,29 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
" valign="top">
|
||||
<div style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<span style="white-space: pre-line"
|
||||
>{{answer}}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<span style="white-space: pre-line">{{answer}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td style="padding: 20px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a
|
||||
href="{{marketUrl}}"
|
||||
target="_blank"
|
||||
style="
|
||||
">
|
||||
<td style="padding: 20px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a href="{{marketUrl}}" target="_blank" style="
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-family: arial, helvetica, sans-serif;
|
||||
|
@ -375,38 +301,29 @@
|
|||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
mso-border-alt: none;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
">
|
||||
<span style="
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
line-height: 120%;
|
||||
"
|
||||
><span
|
||||
style="
|
||||
"><span style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 18.8px;
|
||||
"
|
||||
>View answer</span
|
||||
></span
|
||||
>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div
|
||||
class="footer"
|
||||
style="
|
||||
">View answer</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footer" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -415,28 +332,20 @@
|
|||
color: #999;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
style="
|
||||
">
|
||||
<table width="100%" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="aligncenter content-block"
|
||||
style="
|
||||
">
|
||||
<td class="aligncenter content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -446,14 +355,9 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
Questions? Come ask in
|
||||
<a
|
||||
href="https://discord.gg/eHQBNBqXuh"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
Questions? Come ask in
|
||||
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -461,12 +365,8 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>our Discord</a
|
||||
>! Or,
|
||||
<a
|
||||
href="{{unsubscribeUrl}}"
|
||||
style="
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -474,26 +374,22 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>unsubscribe</a
|
||||
>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
">unsubscribe</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style="
|
||||
</div>
|
||||
</td>
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,84 +1,91 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
style="
|
||||
<html style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Market closed</title>
|
||||
">
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
<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">
|
||||
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 {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
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;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h3 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
itemscope
|
||||
itemtype="http://schema.org/EmailMessage"
|
||||
style="
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -89,43 +96,29 @@
|
|||
line-height: 1.6em;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<table
|
||||
class="body-wrap"
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<table class="body-wrap" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
<td
|
||||
class="container"
|
||||
width="600"
|
||||
style="
|
||||
" valign="top"></td>
|
||||
<td class="container" width="600" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -134,12 +127,8 @@
|
|||
max-width: 600px !important;
|
||||
clear: both !important;
|
||||
margin: 0 auto;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
style="
|
||||
" valign="top">
|
||||
<div class="content" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -147,14 +136,8 @@
|
|||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
class="main"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -162,20 +145,14 @@
|
|||
background-color: #fff;
|
||||
margin: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
"
|
||||
bgcolor="#fff"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#fff">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-wrap aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-wrap aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -183,35 +160,23 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -220,30 +185,22 @@
|
|||
margin: 0;
|
||||
padding: 0 0 40px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<img
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
width="300"
|
||||
style="height: auto"
|
||||
alt="Manifold Markets"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||
alt="Manifold Markets" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -252,24 +209,18 @@
|
|||
margin: 0;
|
||||
padding: 0 0 6px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
You asked
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
You asked
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -277,12 +228,8 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<a
|
||||
href="{{url}}"
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="{{url}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -295,24 +242,18 @@
|
|||
color: #4337c9;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
"
|
||||
>
|
||||
{{question}}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
{{question}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -320,12 +261,8 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 0px;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<h2
|
||||
class="aligncenter"
|
||||
style="
|
||||
" valign="top">
|
||||
<h2 class="aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -335,25 +272,19 @@
|
|||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin: 10px 0 0;
|
||||
"
|
||||
align="center"
|
||||
>
|
||||
Market closed
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" align="center">
|
||||
Market closed
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-block aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -362,13 +293,8 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
class="invoice"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table class="invoice" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -376,19 +302,15 @@
|
|||
text-align: left;
|
||||
width: 80%;
|
||||
margin: 40px auto;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -396,116 +318,91 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
Hi {{name}},
|
||||
<br
|
||||
style="
|
||||
" valign="top">
|
||||
Hi {{name}},
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
A market you created has closed. It's attracted
|
||||
<span style="font-weight: bold">{{volume}}</span>
|
||||
in bets — congrats!
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
A market you created has closed. It's attracted
|
||||
<span style="font-weight: bold">{{volume}}</span>
|
||||
in bets — congrats!
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Resolve your market to earn {{creatorFee}} as the
|
||||
creator commission.
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Resolve your market to earn {{creatorFee}} as the
|
||||
creator commission.
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Thanks,
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Thanks,
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Manifold Team
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Manifold Team
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td style="padding: 10px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a
|
||||
href="{{url}}"
|
||||
target="_blank"
|
||||
style="
|
||||
">
|
||||
<td style="padding: 10px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a href="{{url}}" target="_blank" style="
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-family: arial, helvetica, sans-serif;
|
||||
|
@ -523,38 +420,29 @@
|
|||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
mso-border-alt: none;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
">
|
||||
<span style="
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
line-height: 120%;
|
||||
"
|
||||
><span
|
||||
style="
|
||||
"><span style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 18.8px;
|
||||
"
|
||||
>View market</span
|
||||
></span
|
||||
>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div
|
||||
class="footer"
|
||||
style="
|
||||
">View market</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footer" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -563,28 +451,20 @@
|
|||
color: #999;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
style="
|
||||
">
|
||||
<table width="100%" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="aligncenter content-block"
|
||||
style="
|
||||
">
|
||||
<td class="aligncenter content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -594,14 +474,9 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
Questions? Come ask in
|
||||
<a
|
||||
href="https://discord.gg/eHQBNBqXuh"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
Questions? Come ask in
|
||||
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -609,12 +484,8 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>our Discord</a
|
||||
>! Or,
|
||||
<a
|
||||
href="{{unsubscribeUrl}}"
|
||||
style="
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -622,26 +493,22 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>unsubscribe</a
|
||||
>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
">unsubscribe</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style="
|
||||
</div>
|
||||
</td>
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,84 +1,91 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
style="
|
||||
<html style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Market comment</title>
|
||||
">
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
<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">
|
||||
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 {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
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;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h3 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
itemscope
|
||||
itemtype="http://schema.org/EmailMessage"
|
||||
style="
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -89,43 +96,29 @@
|
|||
line-height: 1.6em;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<table
|
||||
class="body-wrap"
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<table class="body-wrap" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
<td
|
||||
class="container"
|
||||
width="600"
|
||||
style="
|
||||
" valign="top"></td>
|
||||
<td class="container" width="600" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -134,12 +127,8 @@
|
|||
max-width: 600px !important;
|
||||
clear: both !important;
|
||||
margin: 0 auto;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
style="
|
||||
" valign="top">
|
||||
<div class="content" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -147,14 +136,8 @@
|
|||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
class="main"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -162,20 +145,14 @@
|
|||
background-color: #fff;
|
||||
margin: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
"
|
||||
bgcolor="#fff"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#fff">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-wrap aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-wrap aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -183,35 +160,23 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -220,29 +185,21 @@
|
|||
margin: 0;
|
||||
padding: 0 0 0px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<img
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
width="300"
|
||||
style="height: auto"
|
||||
alt="Manifold Markets"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||
alt="Manifold Markets" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-block aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -251,13 +208,8 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
class="invoice"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table class="invoice" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -266,59 +218,42 @@
|
|||
width: 80%;
|
||||
margin: 40px auto;
|
||||
margin-top: 10px;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
src="{{commentorAvatarUrl}}"
|
||||
width="30"
|
||||
height="30"
|
||||
style="
|
||||
" valign="top">
|
||||
<div>
|
||||
<img src="{{commentorAvatarUrl}}" width="30" height="30" style="
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
<span style="font-weight: bold"
|
||||
>{{commentorName}}</span
|
||||
>
|
||||
{{betDescription}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" alt="" />
|
||||
<span style="font-weight: bold">{{commentorName}}</span>
|
||||
{{betDescription}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -326,40 +261,29 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
" valign="top">
|
||||
<div style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<span style="white-space: pre-line"
|
||||
>{{comment}}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<span style="white-space: pre-line">{{comment}}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td style="padding: 20px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a
|
||||
href="{{marketUrl}}"
|
||||
target="_blank"
|
||||
style="
|
||||
">
|
||||
<td style="padding: 20px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a href="{{marketUrl}}" target="_blank" style="
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-family: arial, helvetica, sans-serif;
|
||||
|
@ -377,38 +301,29 @@
|
|||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
mso-border-alt: none;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
">
|
||||
<span style="
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
line-height: 120%;
|
||||
"
|
||||
><span
|
||||
style="
|
||||
"><span style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 18.8px;
|
||||
"
|
||||
>View comment</span
|
||||
></span
|
||||
>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div
|
||||
class="footer"
|
||||
style="
|
||||
">View comment</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footer" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -417,28 +332,20 @@
|
|||
color: #999;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
style="
|
||||
">
|
||||
<table width="100%" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="aligncenter content-block"
|
||||
style="
|
||||
">
|
||||
<td class="aligncenter content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -448,14 +355,9 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
Questions? Come ask in
|
||||
<a
|
||||
href="https://discord.gg/eHQBNBqXuh"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
Questions? Come ask in
|
||||
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -463,12 +365,8 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>our Discord</a
|
||||
>! Or,
|
||||
<a
|
||||
href="{{unsubscribeUrl}}"
|
||||
style="
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -476,26 +374,22 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>unsubscribe</a
|
||||
>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
">unsubscribe</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style="
|
||||
</div>
|
||||
</td>
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,84 +1,91 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
style="
|
||||
<html style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Market resolved</title>
|
||||
">
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
<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">
|
||||
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 {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
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;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h2 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h3 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
itemscope
|
||||
itemtype="http://schema.org/EmailMessage"
|
||||
style="
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -89,43 +96,29 @@
|
|||
line-height: 1.6em;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<table
|
||||
class="body-wrap"
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<table class="body-wrap" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
"
|
||||
bgcolor="#f6f6f6"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#f6f6f6">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
<td
|
||||
class="container"
|
||||
width="600"
|
||||
style="
|
||||
" valign="top"></td>
|
||||
<td class="container" width="600" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -134,12 +127,8 @@
|
|||
max-width: 600px !important;
|
||||
clear: both !important;
|
||||
margin: 0 auto;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
style="
|
||||
" valign="top">
|
||||
<div class="content" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -147,14 +136,8 @@
|
|||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
class="main"
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -162,20 +145,14 @@
|
|||
background-color: #fff;
|
||||
margin: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
"
|
||||
bgcolor="#fff"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
" bgcolor="#fff">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-wrap aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-wrap aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -183,35 +160,23 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -220,30 +185,22 @@
|
|||
margin: 0;
|
||||
padding: 0 0 40px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<img
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
width="300"
|
||||
style="height: auto"
|
||||
alt="Manifold Markets"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||
alt="Manifold Markets" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -252,24 +209,18 @@
|
|||
margin: 0;
|
||||
padding: 0 0 6px 0;
|
||||
text-align: left;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
{{creatorName}} asked
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" valign="top">
|
||||
{{creatorName}} asked
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -277,12 +228,8 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<a
|
||||
href="{{url}}"
|
||||
style="
|
||||
" valign="top">
|
||||
<a href="{{url}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -295,24 +242,18 @@
|
|||
color: #4337c9;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
"
|
||||
>
|
||||
{{question}}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
{{question}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block"
|
||||
style="
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -320,12 +261,8 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 0px;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
<h2
|
||||
class="aligncenter"
|
||||
style="
|
||||
" valign="top">
|
||||
<h2 class="aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -335,25 +272,19 @@
|
|||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin: 10px 0 0;
|
||||
"
|
||||
align="center"
|
||||
>
|
||||
Resolved {{outcome}}
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" align="center">
|
||||
Resolved {{outcome}}
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="content-block aligncenter"
|
||||
style="
|
||||
">
|
||||
<td class="content-block aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -362,13 +293,8 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
<table
|
||||
class="invoice"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
<table class="invoice" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -376,19 +302,15 @@
|
|||
text-align: left;
|
||||
width: 80%;
|
||||
margin: 40px auto;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
style="
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -396,138 +318,105 @@
|
|||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
"
|
||||
valign="top"
|
||||
>
|
||||
Dear {{name}},
|
||||
<br
|
||||
style="
|
||||
" valign="top">
|
||||
Dear {{name}},
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
A market you bet in has been resolved!
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
A market you bet in has been resolved!
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Your investment was
|
||||
<span style="font-weight: bold"
|
||||
>M$ {{investment}}</span
|
||||
>.
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Your investment was
|
||||
<span style="font-weight: bold">{{investment}}</span>.
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Your payout is
|
||||
<span style="font-weight: bold"
|
||||
>M$ {{payout}}</span
|
||||
>.
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Your payout is
|
||||
<span style="font-weight: bold">{{payout}}</span>.
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Thanks,
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Thanks,
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
Manifold Team
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
Manifold Team
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
<br
|
||||
style="
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
style="
|
||||
" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td style="padding: 10px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a
|
||||
href="{{url}}"
|
||||
target="_blank"
|
||||
style="
|
||||
">
|
||||
<td style="padding: 10px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a href="{{url}}" target="_blank" style="
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-family: arial, helvetica, sans-serif;
|
||||
|
@ -545,38 +434,29 @@
|
|||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
mso-border-alt: none;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
">
|
||||
<span style="
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
line-height: 120%;
|
||||
"
|
||||
><span
|
||||
style="
|
||||
"><span style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 18.8px;
|
||||
"
|
||||
>View market</span
|
||||
></span
|
||||
>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div
|
||||
class="footer"
|
||||
style="
|
||||
">View market</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footer" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
@ -585,28 +465,20 @@
|
|||
color: #999;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
style="
|
||||
">
|
||||
<table width="100%" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<tr
|
||||
style="
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
"
|
||||
>
|
||||
<td
|
||||
class="aligncenter content-block"
|
||||
style="
|
||||
">
|
||||
<td class="aligncenter content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -616,14 +488,9 @@
|
|||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
"
|
||||
align="center"
|
||||
valign="top"
|
||||
>
|
||||
Questions? Come ask in
|
||||
<a
|
||||
href="https://discord.gg/eHQBNBqXuh"
|
||||
style="
|
||||
" align="center" valign="top">
|
||||
Questions? Come ask in
|
||||
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -631,12 +498,8 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>our Discord</a
|
||||
>! Or,
|
||||
<a
|
||||
href="{{unsubscribeUrl}}"
|
||||
style="
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
@ -644,26 +507,22 @@
|
|||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
"
|
||||
>unsubscribe</a
|
||||
>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
">unsubscribe</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style="
|
||||
</div>
|
||||
</td>
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
"
|
||||
valign="top"
|
||||
></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -53,22 +53,29 @@ export const sendMarketResolutionEmail = async (
|
|||
|
||||
const subject = `Resolved ${outcome}: ${contract.question}`
|
||||
|
||||
// const creatorPayoutText =
|
||||
// userId === creator.id
|
||||
// ? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
// : ''
|
||||
const creatorPayoutText =
|
||||
creatorPayout >= 1 && userId === creator.id
|
||||
? ` (plus ${formatMoney(creatorPayout)} in commissions)`
|
||||
: ''
|
||||
|
||||
const emailType = 'market-resolved'
|
||||
const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}`
|
||||
|
||||
const displayedInvestment =
|
||||
Number.isNaN(investment) || investment < 0
|
||||
? formatMoney(0)
|
||||
: formatMoney(investment)
|
||||
|
||||
const displayedPayout = formatMoney(payout)
|
||||
|
||||
const templateData: market_resolved_template = {
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
creatorName: creator.name,
|
||||
question: contract.question,
|
||||
outcome,
|
||||
investment: `${Math.floor(investment)}`,
|
||||
payout: `${Math.floor(payout)}`,
|
||||
investment: displayedInvestment,
|
||||
payout: displayedPayout + creatorPayoutText,
|
||||
url: `https://${DOMAIN}/${creator.username}/${contract.slug}`,
|
||||
unsubscribeUrl,
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -98,6 +99,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
|||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -121,4 +123,5 @@ export {
|
|||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
getCustomTokenFunction as getcustomtoken,
|
||||
createPostFunction as createpost,
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem'
|
|||
|
||||
import { Contract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { floatingEqual } from '../../common/util/math'
|
||||
|
||||
export const redeemShares = async (userId: string, contractId: string) => {
|
||||
return await firestore.runTransaction(async (trans) => {
|
||||
|
@ -21,7 +22,7 @@ export const redeemShares = async (userId: string, contractId: string) => {
|
|||
const betsSnap = await trans.get(betsColl.where('userId', '==', userId))
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
const { shares, loanPayment, netAmount } = getRedeemableAmount(bets)
|
||||
if (netAmount === 0) {
|
||||
if (floatingEqual(netAmount, 0)) {
|
||||
return { status: 'success' }
|
||||
}
|
||||
const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract)
|
||||
|
|
39
functions/src/scripts/backfill-unique-bettors.ts
Normal file
39
functions/src/scripts/backfill-unique-bettors.ts
Normal 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()
|
||||
}
|
|
@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
|
|||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
|||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
||||
|
|
|
@ -55,16 +55,18 @@ export const updateMetricsCore = async () => {
|
|||
|
||||
const now = Date.now()
|
||||
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||
const contractUpdates = contracts.map((contract) => {
|
||||
const contractBets = betsByContract[contract.id] ?? []
|
||||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
},
|
||||
}
|
||||
})
|
||||
const contractUpdates = contracts
|
||||
.filter((contract) => contract.id)
|
||||
.map((contract) => {
|
||||
const contractBets = betsByContract[contract.id] ?? []
|
||||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
},
|
||||
}
|
||||
})
|
||||
await writeAsync(firestore, contractUpdates)
|
||||
log(`Updated metrics for ${contracts.length} contracts.`)
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { chunk } from 'lodash'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { Group } from '../../common/group'
|
||||
import { Post } from 'common/post'
|
||||
|
||||
export const log = (...args: unknown[]) => {
|
||||
console.log(`[${new Date().toISOString()}]`, ...args)
|
||||
|
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
|
|||
return getDoc<Group>('groups', groupId)
|
||||
}
|
||||
|
||||
export const getPost = (postId: string) => {
|
||||
return getDoc<Post>('posts', postId)
|
||||
}
|
||||
|
||||
export const getUser = (userId: string) => {
|
||||
return getDoc<User>('users', userId)
|
||||
}
|
||||
|
|
10
package.json
10
package.json
|
@ -8,20 +8,22 @@
|
|||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
|
||||
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)",
|
||||
"lint": "eslint common --fix ; eslint web --fix ; eslint functions --fix"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "16.11.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.25.0",
|
||||
"@typescript-eslint/parser": "5.25.0",
|
||||
"@types/node": "16.11.11",
|
||||
"concurrently": "6.5.1",
|
||||
"eslint": "8.15.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"nodemon": "2.0.19",
|
||||
"prettier": "2.5.0",
|
||||
"typescript": "4.6.4",
|
||||
"ts-node": "10.9.1",
|
||||
"nodemon": "2.0.19"
|
||||
"typescript": "4.6.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.43"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['lodash'],
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
|
@ -22,6 +22,7 @@ module.exports = {
|
|||
'@next/next/no-typos': 'off',
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { Point, ResponsiveLine } from '@nivo/line'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import { zip } from 'lodash'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
@ -26,8 +27,10 @@ export function DailyCountChart(props: {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
|
||||
className={clsx(
|
||||
'h-[250px] w-full overflow-hidden',
|
||||
!small && 'md:h-[400px]'
|
||||
)}
|
||||
>
|
||||
<ResponsiveLine
|
||||
data={data}
|
||||
|
@ -78,8 +81,10 @@ export function DailyPercentChart(props: {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ height: !small && (!width || width >= 800) ? 400 : 250 }}
|
||||
className={clsx(
|
||||
'h-[250px] w-full overflow-hidden',
|
||||
!small && 'md:h-[400px]'
|
||||
)}
|
||||
>
|
||||
<ResponsiveLine
|
||||
data={data}
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
} from 'common/calculate-dpm'
|
||||
import { Bet } from 'common/bet'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
import { isIOS } from 'web/lib/util/device'
|
||||
import { AlertBox } from '../alert-box'
|
||||
|
||||
|
@ -204,7 +204,7 @@ export function AnswerBetPanel(props: {
|
|||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
) : (
|
||||
<SignUpPrompt />
|
||||
<BetSignUpPrompt />
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
|
|
|
@ -71,10 +71,11 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
const yTickValues = [0, 25, 50, 75, 100]
|
||||
|
||||
const numXTickValues = isLargeWidth ? 5 : 2
|
||||
const hoursAgo = latestTime.subtract(5, 'hours')
|
||||
const startDate = dayjs(contract.createdTime).isBefore(hoursAgo)
|
||||
? new Date(contract.createdTime)
|
||||
: hoursAgo.toDate()
|
||||
const startDate = new Date(contract.createdTime)
|
||||
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours').toDate()
|
||||
: latestTime.toDate()
|
||||
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
|
||||
|
||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
|
||||
|
@ -96,16 +97,24 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
xScale={{
|
||||
type: 'time',
|
||||
min: startDate,
|
||||
max: latestTime.toDate(),
|
||||
max: endDate,
|
||||
}}
|
||||
xFormat={(d) =>
|
||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
|
||||
format: (time) =>
|
||||
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
|
||||
}}
|
||||
colors={{ scheme: 'pastel1' }}
|
||||
colors={[
|
||||
'#fca5a5', // red-300
|
||||
'#a5b4fc', // indigo-300
|
||||
'#86efac', // green-300
|
||||
'#fef08a', // yellow-200
|
||||
'#fdba74', // orange-300
|
||||
'#c084fc', // purple-400
|
||||
]}
|
||||
pointSize={0}
|
||||
curve="stepAfter"
|
||||
enableSlices="x"
|
||||
|
@ -156,7 +165,11 @@ function formatTime(
|
|||
) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
|
||||
if (
|
||||
d.add(1, 'minute').isAfter(Date.now()) &&
|
||||
d.subtract(1, 'minute').isBefore(Date.now())
|
||||
)
|
||||
return 'Now'
|
||||
|
||||
let format: string
|
||||
if (d.isSame(Date.now(), 'day')) {
|
||||
|
|
|
@ -25,6 +25,7 @@ import { Bet } from 'common/bet'
|
|||
import { MAX_ANSWER_LENGTH } from 'common/answer'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { lowerCase } from 'lodash'
|
||||
import { Button } from '../button'
|
||||
|
||||
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||
const { contract } = props
|
||||
|
@ -115,6 +116,8 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = (currentReturn * 100).toFixed() + '%'
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<Col className="gap-4 rounded">
|
||||
<Col className="flex-1 gap-2">
|
||||
|
@ -201,12 +204,14 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
</button>
|
||||
) : (
|
||||
text && (
|
||||
<button
|
||||
className="btn self-end whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
|
||||
<Button
|
||||
color="green"
|
||||
size="lg"
|
||||
className="self-end whitespace-nowrap "
|
||||
onClick={withTracking(firebaseLogin, 'answer panel sign in')}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
Add my answer
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Col>
|
||||
|
|
|
@ -19,6 +19,15 @@ import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
|||
type AuthUser = undefined | null | UserAndPrivateUser
|
||||
|
||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||
// Proxy localStorage in case it's not available (eg in incognito iframe)
|
||||
const localStorage =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage
|
||||
: {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
|
||||
const ensureDeviceToken = () => {
|
||||
let deviceToken = localStorage.getItem('device-token')
|
||||
|
@ -46,29 +55,35 @@ export function AuthProvider(props: {
|
|||
}, [setAuthUser, serverUser])
|
||||
|
||||
useEffect(() => {
|
||||
return onIdTokenChanged(auth, async (fbUser) => {
|
||||
if (fbUser) {
|
||||
setTokenCookies({
|
||||
id: await fbUser.getIdToken(),
|
||||
refresh: fbUser.refreshToken,
|
||||
})
|
||||
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||
if (!current.user || !current.privateUser) {
|
||||
const deviceToken = ensureDeviceToken()
|
||||
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
||||
return onIdTokenChanged(
|
||||
auth,
|
||||
async (fbUser) => {
|
||||
if (fbUser) {
|
||||
setTokenCookies({
|
||||
id: await fbUser.getIdToken(),
|
||||
refresh: fbUser.refreshToken,
|
||||
})
|
||||
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||
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.
|
||||
// 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)
|
||||
},
|
||||
(e) => {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
)
|
||||
}, [setAuthUser])
|
||||
|
||||
const uid = authUser?.user.id
|
||||
|
|
|
@ -9,7 +9,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets'
|
|||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { Col } from './layout/col'
|
||||
import { Button } from 'web/components/button'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
|
||||
/** Button that opens BetPanel in a new modal */
|
||||
export default function BetButton(props: {
|
||||
|
@ -32,15 +32,17 @@ export default function BetButton(props: {
|
|||
return (
|
||||
<>
|
||||
<Col className={clsx('items-center', className)}>
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||
onClick={() => {
|
||||
!user ? firebaseLogin() : setOpen(true)
|
||||
}}
|
||||
>
|
||||
{user ? 'Bet' : 'Sign up to Bet'}
|
||||
</Button>
|
||||
{user ? (
|
||||
<Button
|
||||
size="lg"
|
||||
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Bet
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className={'mt-1 w-24 text-center text-sm text-gray-500'}>
|
||||
|
|
|
@ -12,7 +12,7 @@ import { Row } from './layout/row'
|
|||
import { YesNoSelector } from './yes-no-selector'
|
||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { Col } from './layout/col'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
@ -112,7 +112,7 @@ export function BetInline(props: {
|
|||
: 'Submit'}
|
||||
</Button>
|
||||
)}
|
||||
<SignUpPrompt size="xs" />
|
||||
<BetSignUpPrompt size="xs" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setProbAfter(undefined)
|
||||
|
|
|
@ -31,7 +31,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
|||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { SellRow } from './sell-row'
|
||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { isIOS } from 'web/lib/util/device'
|
||||
import { ProbabilityOrNumericInput } from './probability-input'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
@ -86,7 +86,7 @@ export function BetPanel(props: {
|
|||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
<BetSignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
|
@ -146,7 +146,7 @@ export function SimpleBetPanel(props: {
|
|||
onBuySuccess={onBetSuccess}
|
||||
/>
|
||||
|
||||
<SignUpPrompt />
|
||||
<BetSignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
</Col>
|
||||
|
@ -560,7 +560,7 @@ function LimitOrderPanel(props: {
|
|||
<Row className="mt-1 items-center gap-4">
|
||||
<Col className="gap-2">
|
||||
<div className="relative ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} at
|
||||
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -571,7 +571,7 @@ function LimitOrderPanel(props: {
|
|||
</Col>
|
||||
<Col className="gap-2">
|
||||
<div className="ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} at
|
||||
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
|
|
@ -1,14 +1,5 @@
|
|||
import Link from 'next/link'
|
||||
import {
|
||||
Dictionary,
|
||||
keyBy,
|
||||
groupBy,
|
||||
mapValues,
|
||||
sortBy,
|
||||
partition,
|
||||
sumBy,
|
||||
uniq,
|
||||
} from 'lodash'
|
||||
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
@ -28,7 +19,6 @@ import {
|
|||
Contract,
|
||||
contractPath,
|
||||
getBinaryProbPercent,
|
||||
getContractFromId,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { Row } from './layout/row'
|
||||
import { sellBet } from 'web/lib/firebase/api'
|
||||
|
@ -55,10 +45,10 @@ import { SellSharesModal } from './sell-modal'
|
|||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||
import { LimitBet } from 'common/bet'
|
||||
import { floatingEqual } from 'common/util/math'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { Pagination } from './pagination'
|
||||
import { LimitOrderTable } from './limit-bets'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
||||
|
||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
|
||||
|
@ -72,26 +62,22 @@ export function BetsList(props: { user: User }) {
|
|||
const signedInUser = useUser()
|
||||
const isYourBets = user.id === signedInUser?.id
|
||||
const hideBetsBefore = isYourBets ? 0 : JUNE_1_2022
|
||||
const userBets = useUserBets(user.id, { includeRedemptions: true })
|
||||
const [contractsById, setContractsById] = useState<
|
||||
Dictionary<Contract> | undefined
|
||||
>()
|
||||
const userBets = useUserBets(user.id)
|
||||
|
||||
// Hide bets before 06-01-2022 if this isn't your own profile
|
||||
// NOTE: This means public profits also begin on 06-01-2022 as well.
|
||||
const bets = useMemo(
|
||||
() => userBets?.filter((bet) => bet.createdTime >= (hideBetsBefore ?? 0)),
|
||||
() =>
|
||||
userBets?.filter(
|
||||
(bet) => !bet.isAnte && bet.createdTime >= (hideBetsBefore ?? 0)
|
||||
),
|
||||
[userBets, hideBetsBefore]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bets) {
|
||||
const contractIds = uniq(bets.map((b) => b.contractId))
|
||||
Promise.all(contractIds.map(getContractFromId)).then((contracts) => {
|
||||
setContractsById(keyBy(filterDefined(contracts), 'id'))
|
||||
})
|
||||
}
|
||||
}, [bets])
|
||||
const contractList = useUserBetContracts(user.id)
|
||||
const contractsById = useMemo(() => {
|
||||
return contractList ? keyBy(contractList, 'id') : undefined
|
||||
}, [contractList])
|
||||
|
||||
const [sort, setSort] = useState<BetSort>('newest')
|
||||
const [filter, setFilter] = useState<BetFilter>('open')
|
||||
|
@ -405,7 +391,8 @@ export function BetsSummary(props: {
|
|||
const isClosed = closeTime && Date.now() > closeTime
|
||||
|
||||
const bets = props.bets.filter((b) => !b.isAnte)
|
||||
const { hasShares } = getContractBetMetrics(contract, bets)
|
||||
const { hasShares, invested, profitPercent, payout, profit, totalShares } =
|
||||
getContractBetMetrics(contract, bets)
|
||||
|
||||
const excludeSalesAndAntes = bets.filter(
|
||||
(b) => !b.isAnte && !b.isSold && !b.sale
|
||||
|
@ -416,8 +403,6 @@ export function BetsSummary(props: {
|
|||
const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
|
||||
calculatePayout(contract, bet, 'NO')
|
||||
)
|
||||
const { invested, profitPercent, payout, profit, totalShares } =
|
||||
getContractBetMetrics(contract, bets)
|
||||
|
||||
const [showSellModal, setShowSellModal] = useState(false)
|
||||
const user = useUser()
|
||||
|
@ -520,7 +505,7 @@ export function BetsSummary(props: {
|
|||
) : (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Current value
|
||||
Expected value
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(payout)}</div>
|
||||
</Col>
|
||||
|
|
|
@ -37,8 +37,8 @@ export function Button(props: {
|
|||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
xl: 'px-6 py-3 text-base',
|
||||
'2xl': 'px-6 py-3 text-xl',
|
||||
xl: 'px-6 py-2.5 text-base font-semibold',
|
||||
'2xl': 'px-6 py-3 text-xl font-semibold',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
|
@ -52,9 +52,9 @@ export function Button(props: {
|
|||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
color === 'gray' && 'bg-gray-50 text-gray-600 hover:bg-gray-200',
|
||||
color === 'gradient' &&
|
||||
'bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
||||
'border-none bg-gradient-to-r from-indigo-500 to-blue-500 text-white hover:from-indigo-700 hover:to-blue-700',
|
||||
color === 'gray-white' &&
|
||||
'border-none bg-white text-gray-500 shadow-none hover:bg-gray-200',
|
||||
className
|
||||
|
|
|
@ -2,7 +2,7 @@ import { User } from 'common/user'
|
|||
import { Contract } from 'common/contract'
|
||||
import { Challenge } from 'common/challenge'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SignUpPrompt } from 'web/components/sign-up-prompt'
|
||||
import { BetSignUpPrompt } from 'web/components/sign-up-prompt'
|
||||
import { acceptChallenge, APIError } from 'web/lib/firebase/api'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
|
@ -27,7 +27,7 @@ export function AcceptChallengeButton(props: {
|
|||
setErrorText('')
|
||||
}, [open])
|
||||
|
||||
if (!user) return <SignUpPrompt label="Accept this bet" className="mt-4" />
|
||||
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" />
|
||||
|
||||
const iAcceptChallenge = () => {
|
||||
setLoading(true)
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Comment, ContractComment } from 'common/comment'
|
||||
import { ContractComment } from 'common/comment'
|
||||
import { groupConsecutive } from 'common/util/array'
|
||||
import { getUsersComments } from 'web/lib/firebase/comments'
|
||||
import { getUserCommentsQuery } from 'web/lib/firebase/comments'
|
||||
import { usePagination } from 'web/hooks/use-pagination'
|
||||
import { SiteLink } from './site-link'
|
||||
import { Row } from './layout/row'
|
||||
import { Avatar } from './avatar'
|
||||
|
@ -10,11 +9,15 @@ import { RelativeTimestamp } from './relative-timestamp'
|
|||
import { User } from 'common/user'
|
||||
import { Col } from './layout/col'
|
||||
import { Content } from './editor'
|
||||
import { Pagination } from './pagination'
|
||||
import { LoadingIndicator } from './loading-indicator'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { PaginationNextPrev } from 'web/components/pagination'
|
||||
|
||||
const COMMENTS_PER_PAGE = 50
|
||||
type ContractKey = {
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
}
|
||||
|
||||
function contractPath(slug: string) {
|
||||
// by convention this includes the contract creator username, but we don't
|
||||
|
@ -24,67 +27,83 @@ function contractPath(slug: string) {
|
|||
|
||||
export function UserCommentsList(props: { user: User }) {
|
||||
const { user } = props
|
||||
const [comments, setComments] = useState<ContractComment[] | undefined>()
|
||||
const [page, setPage] = useState(0)
|
||||
const start = page * COMMENTS_PER_PAGE
|
||||
const end = start + COMMENTS_PER_PAGE
|
||||
|
||||
useEffect(() => {
|
||||
getUsersComments(user.id).then((cs) => {
|
||||
// we don't show comments in groups here atm, just comments on contracts
|
||||
setComments(
|
||||
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
|
||||
)
|
||||
})
|
||||
}, [user.id])
|
||||
const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
|
||||
const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
|
||||
|
||||
if (comments == null) {
|
||||
const pageComments = groupConsecutive(getItems(), (c) => {
|
||||
return {
|
||||
contractId: c.contractId,
|
||||
contractQuestion: c.contractQuestion,
|
||||
contractSlug: c.contractSlug,
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
||||
const pageComments = groupConsecutive(comments.slice(start, end), (c) => {
|
||||
return { question: c.contractQuestion, slug: c.contractSlug }
|
||||
})
|
||||
if (pageComments.length === 0) {
|
||||
if (isStart && isEnd) {
|
||||
return <p>This user hasn't made any comments yet.</p>
|
||||
} else {
|
||||
// this can happen if their comment count is a multiple of page size
|
||||
return <p>No more comments to display.</p>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Col className={'bg-white'}>
|
||||
{pageComments.map(({ key, items }, i) => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <ProfileCommentGroup key={i} groupKey={key} items={items} />
|
||||
})}
|
||||
<Pagination
|
||||
page={page}
|
||||
itemsPerPage={COMMENTS_PER_PAGE}
|
||||
totalItems={comments.length}
|
||||
setPage={setPage}
|
||||
/>
|
||||
<nav
|
||||
className="border-t border-gray-200 px-4 py-3 sm:px-6"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<PaginationNextPrev
|
||||
prev={!isStart ? 'Previous' : null}
|
||||
next={!isEnd ? 'Next' : null}
|
||||
onClickPrev={getPrev}
|
||||
onClickNext={getNext}
|
||||
scrollToTop={true}
|
||||
/>
|
||||
</nav>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileComment(props: { comment: Comment; className?: string }) {
|
||||
const { comment, className } = props
|
||||
function ProfileCommentGroup(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 } =
|
||||
comment
|
||||
// TODO: find and attach relevant bets by comment betId at some point
|
||||
return (
|
||||
<Row className={className}>
|
||||
<Row className="relative flex items-start space-x-3">
|
||||
<Avatar username={userUsername} avatarUrl={userAvatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -32,23 +32,28 @@ import { track } from '@amplitude/analytics-browser'
|
|||
import { trackCallback } from 'web/lib/service/analytics'
|
||||
import { getMappedValue } from 'common/pseudo-numeric'
|
||||
import { Tooltip } from '../tooltip'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
className?: string
|
||||
questionClass?: string
|
||||
onClick?: () => void
|
||||
hideQuickBet?: boolean
|
||||
hideGroupLink?: boolean
|
||||
trackingPostfix?: string
|
||||
}) {
|
||||
const {
|
||||
showHotVolume,
|
||||
showTime,
|
||||
className,
|
||||
questionClass,
|
||||
onClick,
|
||||
hideQuickBet,
|
||||
hideGroupLink,
|
||||
trackingPostfix,
|
||||
} = props
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
const { question, outcomeType } = contract
|
||||
|
@ -59,7 +64,11 @@ export function ContractCard(props: {
|
|||
const marketClosed =
|
||||
(contract.closeTime || Infinity) < Date.now() || !!resolution
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 768
|
||||
|
||||
const showQuickBet =
|
||||
!isMobile &&
|
||||
user &&
|
||||
!marketClosed &&
|
||||
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
||||
|
@ -68,45 +77,20 @@ export function ContractCard(props: {
|
|||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
'relative gap-3 self-start rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
|
||||
'group relative gap-3 rounded-lg bg-white shadow-md hover:cursor-pointer hover:bg-gray-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Col className="group relative flex-1 gap-3 py-4 pb-12 pl-6">
|
||||
{onClick ? (
|
||||
<a
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
href={contractPath(contract)}
|
||||
onClick={(e) => {
|
||||
// Let the browser handle the link click (opens in new tab).
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
|
||||
e.preventDefault()
|
||||
track('click market card', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
})
|
||||
onClick()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Link href={contractPath(contract)}>
|
||||
<a
|
||||
onClick={trackCallback('click market card', {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
})}
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Col className="relative flex-1 gap-3 py-4 pb-12 pl-6">
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
className={'hidden md:inline-flex'}
|
||||
/>
|
||||
<p
|
||||
className="break-words font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2"
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
className={clsx(
|
||||
'break-anywhere font-semibold text-indigo-700 group-hover:underline group-hover:decoration-indigo-400 group-hover:decoration-2',
|
||||
questionClass
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</p>
|
||||
|
@ -124,7 +108,7 @@ export function ContractCard(props: {
|
|||
))}
|
||||
</Col>
|
||||
{showQuickBet ? (
|
||||
<QuickBet contract={contract} user={user} />
|
||||
<QuickBet contract={contract} user={user} className="z-10" />
|
||||
) : (
|
||||
<>
|
||||
{outcomeType === 'BINARY' && (
|
||||
|
@ -165,11 +149,7 @@ export function ContractCard(props: {
|
|||
showQuickBet ? 'w-[85%]' : 'w-full'
|
||||
)}
|
||||
>
|
||||
<AvatarDetails
|
||||
contract={contract}
|
||||
short={true}
|
||||
className={'block md:hidden'}
|
||||
/>
|
||||
<AvatarDetails contract={contract} short={true} className="md:hidden" />
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
|
@ -177,6 +157,38 @@ export function ContractCard(props: {
|
|||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Add click layer */}
|
||||
{onClick ? (
|
||||
<a
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
href={contractPath(contract)}
|
||||
onClick={(e) => {
|
||||
// Let the browser handle the link click (opens in new tab).
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
|
||||
e.preventDefault()
|
||||
track('click market card' + (trackingPostfix ?? ''), {
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
})
|
||||
onClick()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Link href={contractPath(contract)}>
|
||||
<a
|
||||
onClick={trackCallback(
|
||||
'click market card' + (trackingPostfix ?? ''),
|
||||
{
|
||||
slug: contract.slug,
|
||||
contractId: contract.id,
|
||||
}
|
||||
)}
|
||||
className="absolute top-0 left-0 right-0 bottom-0"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -128,6 +128,7 @@ function EditQuestion(props: {
|
|||
|
||||
function joinContent(oldContent: ContentType, newContent: string) {
|
||||
const editor = new Editor({ content: oldContent, extensions: exhibitExts })
|
||||
editor.commands.focus('end')
|
||||
insertContent(editor, newContent)
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
|
|
@ -5,11 +5,14 @@ import {
|
|||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import Router from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||
import dayjs from 'dayjs'
|
||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||
import { fromNow } from 'web/lib/util/time'
|
||||
import { Avatar } from '../avatar'
|
||||
|
@ -20,7 +23,6 @@ import NewContractBadge from '../new-contract-badge'
|
|||
import { UserFollowButton } from '../follow-button'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import { exhibitExts } from 'common/util/parse'
|
||||
import { Button } from 'web/components/button'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
|
@ -29,11 +31,10 @@ import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
|||
import { SiteLink } from 'web/components/site-link'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { insertContent } from '../editor/utils'
|
||||
import clsx from 'clsx'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
import { User } from 'common/user'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/FeaturedContractBadge'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||
|
||||
export type ShowTime = 'resolve-date' | 'close-date'
|
||||
|
||||
|
@ -187,14 +188,29 @@ export function ContractDetails(props: {
|
|||
) : !groupToDisplay && !user ? (
|
||||
<div />
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px]'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{groupInfo}
|
||||
</Button>
|
||||
<Row>
|
||||
<Button
|
||||
size={'xs'}
|
||||
className={'max-w-[200px] pr-2'}
|
||||
color={'gray-white'}
|
||||
onClick={() =>
|
||||
groupToDisplay
|
||||
? Router.push(groupPath(groupToDisplay.slug))
|
||||
: setOpen(!open)
|
||||
}
|
||||
>
|
||||
{groupInfo}
|
||||
</Button>
|
||||
{user && (
|
||||
<Button
|
||||
size={'xs'}
|
||||
color={'gray-white'}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<PencilIcon className="mb-0.5 mr-0.5 inline h-4 w-4 shrink-0" />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
<Modal open={open} setOpen={setOpen} size={'md'}>
|
||||
|
@ -218,7 +234,7 @@ export function ContractDetails(props: {
|
|||
<ClockIcon className="h-5 w-5" />
|
||||
<DateTimeTooltip
|
||||
text="Market resolved:"
|
||||
time={dayjs(contract.resolutionTime)}
|
||||
time={contract.resolutionTime}
|
||||
>
|
||||
{resolvedDate}
|
||||
</DateTimeTooltip>
|
||||
|
@ -262,14 +278,22 @@ function EditableCloseDate(props: {
|
|||
|
||||
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
|
||||
const [closeDate, setCloseDate] = useState(
|
||||
closeTime && dayJsCloseTime.format('YYYY-MM-DDTHH:mm')
|
||||
closeTime && dayJsCloseTime.format('YYYY-MM-DD')
|
||||
)
|
||||
const [closeHoursMinutes, setCloseHoursMinutes] = useState(
|
||||
closeTime && dayJsCloseTime.format('HH:mm')
|
||||
)
|
||||
|
||||
const newCloseTime = closeDate
|
||||
? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf()
|
||||
: undefined
|
||||
|
||||
const isSameYear = dayJsCloseTime.isSame(dayJsNow, 'year')
|
||||
const isSameDay = dayJsCloseTime.isSame(dayJsNow, 'day')
|
||||
|
||||
const onSave = () => {
|
||||
const newCloseTime = dayjs(closeDate).valueOf()
|
||||
if (!newCloseTime) return
|
||||
|
||||
if (newCloseTime === closeTime) setIsEditingCloseTime(false)
|
||||
else if (newCloseTime > Date.now()) {
|
||||
const content = contract.description
|
||||
|
@ -294,20 +318,28 @@ function EditableCloseDate(props: {
|
|||
return (
|
||||
<>
|
||||
{isEditingCloseTime ? (
|
||||
<div className="form-control mr-1 items-start">
|
||||
<Row className="mr-1 items-start">
|
||||
<input
|
||||
type="datetime-local"
|
||||
type="date"
|
||||
className="input input-bordered"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseDate(e.target.value || '')}
|
||||
onChange={(e) => setCloseDate(e.target.value)}
|
||||
min={Date.now()}
|
||||
value={closeDate}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
className="input input-bordered ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||
min="00:00"
|
||||
value={closeHoursMinutes}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<DateTimeTooltip
|
||||
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
||||
time={dayJsCloseTime}
|
||||
time={closeTime}
|
||||
>
|
||||
{isSameYear
|
||||
? dayJsCloseTime.format('MMM D')
|
||||
|
@ -327,7 +359,7 @@ function EditableCloseDate(props: {
|
|||
color={'gray-white'}
|
||||
onClick={() => setIsEditingCloseTime(true)}
|
||||
>
|
||||
<PencilIcon className="mr-0.5 inline h-4 w-4" /> Edit
|
||||
<PencilIcon className="!container mr-0.5 mb-0.5 inline h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -17,6 +17,7 @@ import { useAdmin, useDev } from 'web/hooks/use-admin'
|
|||
import { SiteLink } from '../site-link'
|
||||
import { firestoreConsolePath } from 'common/envs/constants'
|
||||
import { deleteField } from 'firebase/firestore'
|
||||
import ShortToggle from '../widgets/short-toggle'
|
||||
|
||||
export const contractDetailsButtonClassName =
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||
|
@ -31,7 +32,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
const isDev = useDev()
|
||||
const isAdmin = useAdmin()
|
||||
|
||||
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
|
||||
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a')
|
||||
|
||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
||||
contract
|
||||
|
@ -50,6 +51,21 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
? 'Multiple choice'
|
||||
: 'Numeric'
|
||||
|
||||
const onFeaturedToggle = async (enabled: boolean) => {
|
||||
if (
|
||||
enabled &&
|
||||
(contract.featuredOnHomeRank === 0 || !contract?.featuredOnHomeRank)
|
||||
) {
|
||||
await updateContract(id, { featuredOnHomeRank: 1 })
|
||||
setFeatured(true)
|
||||
} else if (!enabled && (contract?.featuredOnHomeRank ?? 0) > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await updateContract(id, { featuredOnHomeRank: deleteField() })
|
||||
setFeatured(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
|
@ -134,7 +150,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
{/* Show a path to Firebase if user is an admin, or we're on localhost */}
|
||||
{(isAdmin || isDev) && (
|
||||
<tr>
|
||||
<td>[DEV] Firestore</td>
|
||||
<td>[ADMIN] Firestore</td>
|
||||
<td>
|
||||
<SiteLink href={firestoreConsolePath(id)}>
|
||||
Console link
|
||||
|
@ -144,43 +160,28 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
)}
|
||||
{isAdmin && (
|
||||
<tr>
|
||||
<td>Set featured</td>
|
||||
<td>[ADMIN] Featured</td>
|
||||
<td>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={featured ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.value === 'true'
|
||||
if (
|
||||
newVal &&
|
||||
(contract.featuredOnHomeRank === 0 ||
|
||||
!contract?.featuredOnHomeRank)
|
||||
)
|
||||
updateContract(id, {
|
||||
featuredOnHomeRank: 1,
|
||||
})
|
||||
.then(() => {
|
||||
setFeatured(true)
|
||||
})
|
||||
.catch(console.error)
|
||||
else if (
|
||||
!newVal &&
|
||||
(contract?.featuredOnHomeRank ?? 0) > 0
|
||||
)
|
||||
updateContract(id, {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
featuredOnHomeRank: deleteField(),
|
||||
})
|
||||
.then(() => {
|
||||
setFeatured(false)
|
||||
})
|
||||
.catch(console.error)
|
||||
}}
|
||||
>
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
<ShortToggle
|
||||
enabled={featured}
|
||||
setEnabled={setFeatured}
|
||||
onChange={onFeaturedToggle}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<tr>
|
||||
<td>[ADMIN] Unlisted</td>
|
||||
<td>
|
||||
<ShortToggle
|
||||
enabled={contract.visibility === 'unlisted'}
|
||||
setEnabled={(b) =>
|
||||
updateContract(id, {
|
||||
visibility: b ? 'unlisted' : 'public',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
@ -75,36 +75,30 @@ export const ContractOverview = (props: {
|
|||
{isBinary ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
<Row className={'items-center justify-center'}>
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
<Row className={'items-center justify-center'}>
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
|
|
@ -16,6 +16,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
}) {
|
||||
const { contract, height } = props
|
||||
const { resolutionTime, closeTime, outcomeType } = contract
|
||||
const now = Date.now()
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale
|
||||
|
||||
|
@ -23,10 +24,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
|
||||
const startProb = getInitialProbability(contract)
|
||||
|
||||
const times = [
|
||||
contract.createdTime,
|
||||
...bets.map((bet) => bet.createdTime),
|
||||
].map((time) => new Date(time))
|
||||
const times = [contract.createdTime, ...bets.map((bet) => bet.createdTime)]
|
||||
|
||||
const f: (p: number) => number = isBinary
|
||||
? (p) => p
|
||||
|
@ -36,17 +34,17 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
|
||||
const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f)
|
||||
|
||||
const isClosed = !!closeTime && Date.now() > closeTime
|
||||
const isClosed = !!closeTime && now > closeTime
|
||||
const latestTime = dayjs(
|
||||
resolutionTime && isClosed
|
||||
? Math.min(resolutionTime, closeTime)
|
||||
: isClosed
|
||||
? closeTime
|
||||
: resolutionTime ?? Date.now()
|
||||
: resolutionTime ?? now
|
||||
)
|
||||
|
||||
// Add a fake datapoint so the line continues to the right
|
||||
times.push(latestTime.toDate())
|
||||
times.push(latestTime.valueOf())
|
||||
probs.push(probs[probs.length - 1])
|
||||
|
||||
const quartiles = [0, 25, 50, 75, 100]
|
||||
|
@ -58,15 +56,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
const { width } = useWindowSize()
|
||||
|
||||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const hoursAgo = latestTime.subtract(1, 'hours')
|
||||
const startDate = dayjs(times[0]).isBefore(hoursAgo)
|
||||
? times[0]
|
||||
: hoursAgo.toDate()
|
||||
const startDate = dayjs(times[0])
|
||||
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours')
|
||||
: latestTime
|
||||
const includeMinute = endDate.diff(startDate, 'hours') < 2
|
||||
|
||||
// Minimum number of points for the graph to have. For smooth tooltip movement
|
||||
// On first load, width is undefined, skip adding extra points to let page load faster
|
||||
// If we aren't actually loading any data yet, skip adding extra points to let page load faster
|
||||
// This fn runs again once DOM is finished loading
|
||||
const totalPoints = width ? (width > 800 ? 300 : 50) : 1
|
||||
const totalPoints = width && bets.length ? (width > 800 ? 300 : 50) : 1
|
||||
|
||||
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
|
||||
|
||||
|
@ -74,20 +73,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
const s = isBinary ? 100 : 1
|
||||
|
||||
for (let i = 0; i < times.length - 1; i++) {
|
||||
points[points.length] = { x: times[i], y: s * probs[i] }
|
||||
const numPoints: number = Math.floor(
|
||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep
|
||||
)
|
||||
const p = probs[i]
|
||||
const d0 = times[i]
|
||||
const d1 = times[i + 1]
|
||||
const msDiff = d1 - d0
|
||||
const numPoints = Math.floor(msDiff / timeStep)
|
||||
points.push({ x: new Date(times[i]), y: s * p })
|
||||
if (numPoints > 1) {
|
||||
const thisTimeStep: number =
|
||||
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / numPoints
|
||||
const thisTimeStep: number = msDiff / numPoints
|
||||
for (let n = 1; n < numPoints; n++) {
|
||||
points[points.length] = {
|
||||
x: dayjs(times[i])
|
||||
.add(thisTimeStep * n, 'ms')
|
||||
.toDate(),
|
||||
y: s * probs[i],
|
||||
}
|
||||
points.push({ x: new Date(d0 + thisTimeStep * n), y: s * p })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,8 +91,8 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
{ id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' },
|
||||
]
|
||||
|
||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||
const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime)
|
||||
const multiYear = !startDate.isSame(latestTime, 'year')
|
||||
const lessThanAWeek = startDate.add(8, 'day').isAfter(latestTime)
|
||||
|
||||
const formatter = isBinary
|
||||
? formatPercent
|
||||
|
@ -132,15 +127,16 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
|
|||
}}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: startDate,
|
||||
max: latestTime.toDate(),
|
||||
min: startDate.toDate(),
|
||||
max: endDate.toDate(),
|
||||
}}
|
||||
xFormat={(d) =>
|
||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, multiYear, lessThanAWeek, false),
|
||||
format: (time) =>
|
||||
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
|
||||
}}
|
||||
colors={{ datum: 'color' }}
|
||||
curve="stepAfter"
|
||||
|
@ -176,19 +172,20 @@ function formatPercent(y: DatumValue) {
|
|||
}
|
||||
|
||||
function formatTime(
|
||||
now: number,
|
||||
time: number,
|
||||
includeYear: boolean,
|
||||
includeHour: boolean,
|
||||
includeMinute: boolean
|
||||
) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (d.add(1, 'minute').isAfter(Date.now())) return 'Now'
|
||||
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
|
||||
return 'Now'
|
||||
|
||||
let format: string
|
||||
if (d.isSame(Date.now(), 'day')) {
|
||||
if (d.isSame(now, 'day')) {
|
||||
format = '[Today]'
|
||||
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) {
|
||||
} else if (d.add(1, 'day').isSame(now, 'day')) {
|
||||
format = '[Yesterday]'
|
||||
} else {
|
||||
format = 'MMM D'
|
||||
|
|
|
@ -26,6 +26,7 @@ export function ContractsGrid(props: {
|
|||
hideGroupLink?: boolean
|
||||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
trackingPostfix?: string
|
||||
}) {
|
||||
const {
|
||||
contracts,
|
||||
|
@ -34,6 +35,7 @@ export function ContractsGrid(props: {
|
|||
onContractClick,
|
||||
cardHideOptions,
|
||||
highlightOptions,
|
||||
trackingPostfix,
|
||||
} = props
|
||||
const { hideQuickBet, hideGroupLink } = cardHideOptions || {}
|
||||
const { contractIds, highlightClassName } = highlightOptions || {}
|
||||
|
@ -79,6 +81,7 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
hideQuickBet={hideQuickBet}
|
||||
hideGroupLink={hideGroupLink}
|
||||
trackingPostfix={trackingPostfix}
|
||||
className={clsx(
|
||||
'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox)
|
||||
contractIds?.includes(contract.id) && highlightClassName
|
||||
|
|
9
web/components/contract/featured-contract-badge.tsx
Normal file
9
web/components/contract/featured-contract-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -41,8 +41,9 @@ const BET_SIZE = 10
|
|||
export function QuickBet(props: {
|
||||
contract: BinaryContract | PseudoNumericContract
|
||||
user: User
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const { contract, user, className } = props
|
||||
const { mechanism, outcomeType } = contract
|
||||
const isCpmm = mechanism === 'cpmm-1'
|
||||
|
||||
|
@ -139,6 +140,7 @@ export function QuickBet(props: {
|
|||
return (
|
||||
<Col
|
||||
className={clsx(
|
||||
className,
|
||||
'relative min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
|
||||
// Use this for colored QuickBet panes
|
||||
// `bg-opacity-10 bg-${color}`
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { firebaseLogin, User } from 'web/lib/firebase/users'
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const createButtonStyle =
|
||||
'border-w-0 mx-auto mt-4 -ml-1 w-full rounded-md bg-gradient-to-r py-2.5 text-base font-semibold text-white shadow-sm lg:-ml-0 h-11'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Button } from './button'
|
||||
|
||||
export const CreateQuestionButton = (props: {
|
||||
user: User | null | undefined
|
||||
|
@ -13,32 +11,17 @@ export const CreateQuestionButton = (props: {
|
|||
className?: string
|
||||
query?: string
|
||||
}) => {
|
||||
const gradient =
|
||||
'from-indigo-500 to-blue-500 hover:from-indigo-700 hover:to-blue-700'
|
||||
|
||||
const { user, overrideText, className, query } = props
|
||||
const router = useRouter()
|
||||
|
||||
if (!user || user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<div className={clsx('flex justify-center', className)}>
|
||||
{user ? (
|
||||
<Link href={`/create${query ? query : ''}`} passHref>
|
||||
<button className={clsx(gradient, createButtonStyle)}>
|
||||
{overrideText ? overrideText : 'Create a market'}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
<Link href={`/create${query ? query : ''}`} passHref>
|
||||
<Button color="gradient" size="xl" className="mt-4">
|
||||
{overrideText ?? 'Create a market'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import advanced from 'dayjs/plugin/advancedFormat'
|
||||
import { Tooltip } from './tooltip'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(advanced)
|
||||
const FORMATTER = new Intl.DateTimeFormat('default', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'long',
|
||||
})
|
||||
|
||||
export function DateTimeTooltip(props: {
|
||||
time: Dayjs
|
||||
time: number
|
||||
text?: string
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
|
@ -17,7 +14,7 @@ export function DateTimeTooltip(props: {
|
|||
}) {
|
||||
const { className, time, text, noTap } = props
|
||||
|
||||
const formattedTime = time.format('MMM DD, YYYY hh:mm a z')
|
||||
const formattedTime = FORMATTER.format(time)
|
||||
const toolTip = text ? `${text} ${formattedTime}` : formattedTime
|
||||
|
||||
return (
|
||||
|
|
|
@ -40,6 +40,11 @@ const embedPatterns: EmbedPattern[] = [
|
|||
rewrite: (id) =>
|
||||
`<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`,
|
||||
},
|
||||
{
|
||||
regex: /^(https?:\/\/www\.figma\.com\/(?:file|proto)\/[^\/]+\/[^\/]+)/,
|
||||
rewrite: (url) =>
|
||||
`<iframe src="https://www.figma.com/embed?embed_host=manifold&url=${url}"></iframe>`,
|
||||
},
|
||||
// Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match
|
||||
{
|
||||
// Twitch: https://www.twitch.tv/videos/1445087149
|
||||
|
|
|
@ -48,8 +48,17 @@ export function MarketModal(props: {
|
|||
{contracts.length > 1 && 's'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setContracts([])} color="gray">
|
||||
Cancel
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (contracts.length > 0) {
|
||||
setContracts([])
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
color="gray"
|
||||
>
|
||||
{contracts.length > 0 ? 'Reset' : 'Cancel'}
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
|
|
|
@ -6,8 +6,6 @@ import Link from 'next/link'
|
|||
import { fromNow } from 'web/lib/util/time'
|
||||
import { ToastClipboard } from 'web/components/toast-clipboard'
|
||||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export function CopyLinkDateTimeComponent(props: {
|
||||
prefix: string
|
||||
|
@ -18,7 +16,6 @@ export function CopyLinkDateTimeComponent(props: {
|
|||
}) {
|
||||
const { prefix, slug, elementId, createdTime, className } = props
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
const time = dayjs(createdTime)
|
||||
|
||||
function copyLinkToComment(
|
||||
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
|
@ -31,26 +28,19 @@ export function CopyLinkDateTimeComponent(props: {
|
|||
setTimeout(() => setShowToast(false), 2000)
|
||||
}
|
||||
return (
|
||||
<div className={clsx('inline', className)}>
|
||||
<DateTimeTooltip time={time} noTap>
|
||||
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
|
||||
<a
|
||||
onClick={(event) => copyLinkToComment(event)}
|
||||
className={'mx-1 cursor-pointer'}
|
||||
>
|
||||
<span className="whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100 ">
|
||||
{fromNow(createdTime)}
|
||||
{showToast && (
|
||||
<ToastClipboard className={'left-24 sm:-left-16'} />
|
||||
)}
|
||||
<LinkIcon
|
||||
className="ml-1 mb-0.5 inline-block text-gray-400"
|
||||
height={13}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
</DateTimeTooltip>
|
||||
</div>
|
||||
<DateTimeTooltip className={className} time={createdTime} noTap>
|
||||
<Link href={`/${prefix}/${slug}#${elementId}`} passHref={true}>
|
||||
<a
|
||||
onClick={copyLinkToComment}
|
||||
className={
|
||||
'mx-1 whitespace-nowrap rounded-sm px-1 text-gray-400 hover:bg-gray-100'
|
||||
}
|
||||
>
|
||||
{fromNow(createdTime)}
|
||||
{showToast && <ToastClipboard />}
|
||||
<LinkIcon className="ml-1 mb-0.5 inline" height={13} />
|
||||
</a>
|
||||
</Link>
|
||||
</DateTimeTooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import { useUser, useUserById } from 'web/hooks/use-user'
|
|||
import { Row } from 'web/components/layout/row'
|
||||
import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||
import clsx from 'clsx'
|
||||
import { UsersIcon } from '@heroicons/react/solid'
|
||||
import { formatMoney, formatPercent } from 'common/util/format'
|
||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||
|
@ -154,79 +153,3 @@ export function BetStatusText(props: {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BetGroupSpan(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
outcome?: string
|
||||
}) {
|
||||
const { contract, bets, outcome } = props
|
||||
|
||||
const numberTraders = uniqBy(bets, (b) => b.userId).length
|
||||
|
||||
const [buys, sells] = partition(bets, (bet) => bet.amount >= 0)
|
||||
const buyTotal = sumBy(buys, (b) => b.amount)
|
||||
const sellTotal = sumBy(sells, (b) => -b.amount)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '}
|
||||
<JoinSpans>
|
||||
{buyTotal > 0 && <>bought {formatMoney(buyTotal)} </>}
|
||||
{sellTotal > 0 && <>sold {formatMoney(sellTotal)} </>}
|
||||
</JoinSpans>
|
||||
{outcome && (
|
||||
<>
|
||||
{' '}
|
||||
of{' '}
|
||||
<OutcomeLabel
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
truncate="short"
|
||||
/>
|
||||
</>
|
||||
)}{' '}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedBetGroup(props: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
hideOutcome: boolean
|
||||
}) {
|
||||
const { contract, bets, hideOutcome } = props
|
||||
|
||||
const betGroups = groupBy(bets, (bet) => bet.outcome)
|
||||
const outcomes = Object.keys(betGroups)
|
||||
|
||||
// Use the time of the last bet for the entire group
|
||||
const createdTime = bets[bets.length - 1].createdTime
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
||||
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx('min-w-0 flex-1', outcomes.length === 1 && 'mt-1')}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{outcomes.map((outcome, index) => (
|
||||
<Fragment key={outcome}>
|
||||
<BetGroupSpan
|
||||
contract={contract}
|
||||
outcome={hideOutcome ? undefined : outcome}
|
||||
bets={betGroups[outcome]}
|
||||
/>
|
||||
{index !== outcomes.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
<RelativeTimestamp time={createdTime} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -382,6 +382,8 @@ export function CommentInput(props: {
|
|||
|
||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className={'mb-2 gap-1 sm:gap-2'}>
|
||||
|
@ -535,7 +537,7 @@ export function CommentInputTextArea(props: {
|
|||
className={'btn btn-outline btn-sm mt-2 normal-case'}
|
||||
onClick={() => submitComment(presetId)}
|
||||
>
|
||||
Sign in to comment
|
||||
Add my comment
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
|
|
|
@ -33,7 +33,7 @@ import {
|
|||
import { FeedBet } from 'web/components/feed/feed-bets'
|
||||
import { CPMMBinaryContract, NumericContract } from 'common/contract'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
import { SignUpPrompt } from '../sign-up-prompt'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
import { User } from 'common/user'
|
||||
import { PlayMoneyDisclaimer } from '../play-money-disclaimer'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
|
@ -70,7 +70,7 @@ export function FeedItems(props: {
|
|||
|
||||
{!user ? (
|
||||
<Col className="mt-4 max-w-sm items-center xl:hidden">
|
||||
<SignUpPrompt />
|
||||
<BetSignUpPrompt />
|
||||
<PlayMoneyDisclaimer />
|
||||
</Col>
|
||||
) : (
|
||||
|
|
|
@ -10,7 +10,11 @@ export function FileUploadButton(props: {
|
|||
const ref = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<>
|
||||
<button className={className} onClick={() => ref.current?.click()}>
|
||||
<button
|
||||
type={'button'}
|
||||
className={className}
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<input
|
||||
|
|
|
@ -76,6 +76,8 @@ export function CreateGroupButton(props: {
|
|||
}
|
||||
}
|
||||
|
||||
if (user.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<ConfirmationButton
|
||||
openModalBtn={{
|
||||
|
|
|
@ -18,7 +18,7 @@ import { sum } from 'lodash'
|
|||
import { formatMoney } from 'common/util/format'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { Content, useTextEditor } from 'web/components/editor'
|
||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||
import { useUnseenNotifications } from 'web/hooks/use-notifications'
|
||||
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
|
@ -277,14 +277,18 @@ function GroupChatNotificationsIcon(props: {
|
|||
hidden: boolean
|
||||
}) {
|
||||
const { privateUser, group, shouldSetAsSeen, hidden } = props
|
||||
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
|
||||
privateUser,
|
||||
{
|
||||
customHref: `/group/${group.slug}`,
|
||||
}
|
||||
const notificationsForThisGroup = useUnseenNotifications(
|
||||
privateUser
|
||||
// Disabled tracking by customHref for now.
|
||||
// {
|
||||
// customHref: `/group/${group.slug}`,
|
||||
// }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
preferredNotificationsForThisGroup.forEach((notification) => {
|
||||
if (!notificationsForThisGroup) return
|
||||
|
||||
notificationsForThisGroup.forEach((notification) => {
|
||||
if (
|
||||
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
|
||||
// old style chat notif that simply ended with the group slug
|
||||
|
@ -293,13 +297,14 @@ function GroupChatNotificationsIcon(props: {
|
|||
setNotificationsAsSeen([notification])
|
||||
}
|
||||
})
|
||||
}, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen])
|
||||
}, [group.slug, notificationsForThisGroup, shouldSetAsSeen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
!hidden &&
|
||||
preferredNotificationsForThisGroup.length > 0 &&
|
||||
notificationsForThisGroup &&
|
||||
notificationsForThisGroup.length > 0 &&
|
||||
!shouldSetAsSeen
|
||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
||||
: 'hidden'
|
||||
|
|
|
@ -4,8 +4,8 @@ import { Tooltip } from './tooltip'
|
|||
export function InfoTooltip(props: { text: string }) {
|
||||
const { text } = props
|
||||
return (
|
||||
<Tooltip text={text}>
|
||||
<InformationCircleIcon className="h-5 w-5 text-gray-500" />
|
||||
<Tooltip className="inline-block" text={text}>
|
||||
<InformationCircleIcon className="-mb-1 h-5 w-5 text-gray-500" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ export function Leaderboard(props: {
|
|||
{users.map((user, index) => (
|
||||
<tr key={user.id}>
|
||||
<td>{index + 1}</td>
|
||||
<td style={{ maxWidth: 190 }}>
|
||||
<td className="max-w-[190px]">
|
||||
<SiteLink className="relative" href={`/${user.username}`}>
|
||||
<Row className="items-center gap-4">
|
||||
<Avatar avatarUrl={user.avatarUrl} size={8} />
|
||||
|
|
|
@ -38,10 +38,7 @@ export function Linkify(props: { text: string; gray?: boolean }) {
|
|||
)
|
||||
})
|
||||
return (
|
||||
<span
|
||||
className="break-words"
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
>
|
||||
<span className="break-anywhere">
|
||||
{text.split(regex).map((part, i) => (
|
||||
<Fragment key={i}>
|
||||
{part}
|
||||
|
|
|
@ -5,8 +5,6 @@ import {
|
|||
DotsHorizontalIcon,
|
||||
CashIcon,
|
||||
HeartIcon,
|
||||
UserGroupIcon,
|
||||
TrendingUpIcon,
|
||||
ChatIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
|
@ -18,17 +16,14 @@ import { ManifoldLogo } from './manifold-logo'
|
|||
import { MenuButton } from './menu'
|
||||
import { ProfileSummary } from './profile-menu'
|
||||
import NotificationsIcon from 'web/components/notifications-icon'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { trackCallback, withTracking } from 'web/lib/service/analytics'
|
||||
import { Group } from 'common/group'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||
import { buildArray } from 'common/util/array'
|
||||
import TrophyIcon from 'web/lib/icons/trophy-icon'
|
||||
import { SignInButton } from '../sign-in-button'
|
||||
|
||||
const logout = async () => {
|
||||
// log out, and then reload the page, in case SSR wants to boot them out
|
||||
|
@ -46,11 +41,12 @@ function getNavigation() {
|
|||
icon: NotificationsIcon,
|
||||
},
|
||||
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
: [
|
||||
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
|
||||
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -67,14 +63,14 @@ function getMoreNavigation(user?: User | null) {
|
|||
}
|
||||
|
||||
if (!user) {
|
||||
// Signed out "More"
|
||||
return buildArray(
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||
[
|
||||
{ name: 'Tournaments', href: '/tournaments' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{
|
||||
name: 'Salem tournament',
|
||||
href: 'https://salemcenter.manifold.markets/',
|
||||
},
|
||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
||||
|
@ -82,16 +78,15 @@ function getMoreNavigation(user?: User | null) {
|
|||
)
|
||||
}
|
||||
|
||||
// Signed in "More"
|
||||
return buildArray(
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||
[
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{
|
||||
name: 'Salem tournament',
|
||||
href: 'https://salemcenter.manifold.markets/',
|
||||
},
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
{ name: 'Help & About', href: 'https://help.manifold.markets/' },
|
||||
{
|
||||
|
@ -120,12 +115,12 @@ const signedOutMobileNavigation = [
|
|||
icon: BookOpenIcon,
|
||||
},
|
||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||
]
|
||||
|
||||
const signedInMobileNavigation = [
|
||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||
{ name: 'Tournaments', href: '/tournaments', icon: TrophyIcon },
|
||||
...(IS_PRIVATE_MANIFOLD
|
||||
? []
|
||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||
|
@ -147,11 +142,9 @@ function getMoreMobileNav() {
|
|||
return buildArray<Item>(
|
||||
CHALLENGES_ENABLED && { name: 'Challenges', href: '/challenges' },
|
||||
[
|
||||
{ name: 'Groups', href: '/groups' },
|
||||
{ name: 'Referrals', href: '/referrals' },
|
||||
{
|
||||
name: 'Salem tournament',
|
||||
href: 'https://salemcenter.manifold.markets/',
|
||||
},
|
||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||
{ name: 'Charity', href: '/charity' },
|
||||
{ name: 'Send M$', href: '/links' },
|
||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||
|
@ -232,29 +225,23 @@ export default function Sidebar(props: { className?: string }) {
|
|||
? signedOutMobileNavigation
|
||||
: signedInMobileNavigation
|
||||
|
||||
const memberItems = (
|
||||
useMemberGroups(user?.id, undefined, {
|
||||
by: 'mostRecentContractAddedTime',
|
||||
}) ?? []
|
||||
).map((group: Group) => ({
|
||||
name: group.name,
|
||||
href: `${groupPath(group.slug)}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<nav aria-label="Sidebar" className={className}>
|
||||
<nav
|
||||
aria-label="Sidebar"
|
||||
className={clsx('flex max-h-[100vh] flex-col', className)}
|
||||
>
|
||||
<ManifoldLogo className="py-6" twoLine />
|
||||
|
||||
<CreateQuestionButton user={user} />
|
||||
<Spacer h={4} />
|
||||
{!user && <SignInButton className="mb-4" />}
|
||||
|
||||
{user && (
|
||||
<div className="w-full" style={{ minHeight: 80 }}>
|
||||
<div className="min-h-[80px] w-full">
|
||||
<ProfileSummary user={user} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="space-y-1 lg:hidden">
|
||||
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
|
||||
{mobileNavigationOptions.map((item) => (
|
||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
|
@ -265,15 +252,10 @@ export default function Sidebar(props: { className?: string }) {
|
|||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
)}
|
||||
{/* Spacer if there are any groups */}
|
||||
{memberItems.length > 0 && (
|
||||
<hr className="!my-4 mr-2 border-gray-300" />
|
||||
)}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<div className="hidden space-y-1 lg:block">
|
||||
<div className="hidden min-h-0 shrink flex-col gap-1 lg:flex">
|
||||
{navigationOptions.map((item) => (
|
||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
|
@ -282,65 +264,8 @@ export default function Sidebar(props: { className?: string }) {
|
|||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
|
||||
{/* Spacer if there are any groups */}
|
||||
{memberItems.length > 0 && <hr className="!my-4 border-gray-300" />}
|
||||
<GroupsList currentPage={router.asPath} memberItems={memberItems} />
|
||||
{user && <CreateQuestionButton user={user} />}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||
const { currentPage, memberItems } = props
|
||||
|
||||
const { height } = useWindowSize()
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const remainingHeight = (height ?? 0) - (containerRef?.offsetTop ?? 0)
|
||||
|
||||
// const preferredNotifications = useUnseenPreferredNotifications(
|
||||
// privateUser,
|
||||
// {
|
||||
// customHref: '/group/',
|
||||
// },
|
||||
// memberItems.length > 0 ? memberItems.length : undefined
|
||||
// )
|
||||
// const notifIsForThisItem = useMemo(
|
||||
// () => (itemHref: string) =>
|
||||
// preferredNotifications.some(
|
||||
// (n) =>
|
||||
// !n.isSeen &&
|
||||
// (n.isSeenOnHref === itemHref ||
|
||||
// n.isSeenOnHref?.replace('/chat', '') === itemHref)
|
||||
// ),
|
||||
// [preferredNotifications]
|
||||
// )
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarItem
|
||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 space-y-0.5 overflow-auto"
|
||||
style={{ height: remainingHeight }}
|
||||
ref={setContainerRef}
|
||||
>
|
||||
{memberItems.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
key={item.name}
|
||||
onClick={trackCallback('click sidebar group', { name: item.name })}
|
||||
className={clsx(
|
||||
'cursor-pointer truncate',
|
||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Row } from 'web/components/layout/row'
|
|||
import { useEffect, useState } from 'react'
|
||||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
|
||||
import { useUnseenGroupedNotification } from 'web/hooks/use-notifications'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
import { PrivateUser } from 'common/user'
|
||||
|
||||
|
@ -30,7 +30,7 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
|
|||
else setSeen(false)
|
||||
}, [router.pathname])
|
||||
|
||||
const notifications = useUnseenPreferredNotificationGroups(privateUser)
|
||||
const notifications = useUnseenGroupedNotification(privateUser)
|
||||
if (!notifications || notifications.length === 0 || seen) {
|
||||
return <div />
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import { BucketInput } from './bucket-input'
|
|||
import { Col } from './layout/col'
|
||||
import { Row } from './layout/row'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { SignUpPrompt } from './sign-up-prompt'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
|
||||
export function NumericBetPanel(props: {
|
||||
|
@ -34,7 +34,7 @@ export function NumericBetPanel(props: {
|
|||
|
||||
<NumericBuyPanel contract={contract} user={user} />
|
||||
|
||||
<SignUpPrompt />
|
||||
<BetSignUpPrompt />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -164,10 +164,7 @@ export function AnswerLabel(props: {
|
|||
|
||||
return (
|
||||
<Tooltip text={truncated === text ? false : text}>
|
||||
<span
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
className={clsx('whitespace-pre-line break-words', className)}
|
||||
>
|
||||
<span className={clsx('break-anywhere whitespace-pre-line', className)}>
|
||||
{truncated}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
|
|
@ -6,13 +6,11 @@ import { Toaster } from 'react-hot-toast'
|
|||
|
||||
export function Page(props: {
|
||||
rightSidebar?: ReactNode
|
||||
suspend?: boolean
|
||||
className?: string
|
||||
rightSidebarClassName?: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const { children, rightSidebar, suspend, className, rightSidebarClassName } =
|
||||
props
|
||||
const { children, rightSidebar, className, rightSidebarClassName } = props
|
||||
|
||||
const bottomBarPadding = 'pb-[58px] lg:pb-0 '
|
||||
return (
|
||||
|
@ -23,10 +21,9 @@ export function Page(props: {
|
|||
bottomBarPadding,
|
||||
'mx-auto w-full lg:grid lg:grid-cols-12 lg:gap-x-2 xl:max-w-7xl xl:gap-x-8'
|
||||
)}
|
||||
style={suspend ? visuallyHiddenStyle : undefined}
|
||||
>
|
||||
<Toaster />
|
||||
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:block" />
|
||||
<Sidebar className="sticky top-0 hidden divide-gray-300 self-start pl-2 lg:col-span-2 lg:flex" />
|
||||
<main
|
||||
className={clsx(
|
||||
'lg:col-span-8 lg:pt-6',
|
||||
|
@ -46,22 +43,7 @@ export function Page(props: {
|
|||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<BottomNavBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const visuallyHiddenStyle = {
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
visibility: 'hidden',
|
||||
} as const
|
||||
|
|
|
@ -1,6 +1,40 @@
|
|||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function PaginationNextPrev(props: {
|
||||
className?: string
|
||||
prev?: ReactNode
|
||||
next?: ReactNode
|
||||
onClickPrev: () => void
|
||||
onClickNext: () => void
|
||||
scrollToTop?: boolean
|
||||
}) {
|
||||
const { className, prev, next, onClickPrev, onClickNext, scrollToTop } = props
|
||||
return (
|
||||
<Row className={clsx(className, 'flex-1 justify-between sm:justify-end')}>
|
||||
{prev != null && (
|
||||
<a
|
||||
href={scrollToTop ? '#' : undefined}
|
||||
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={onClickPrev}
|
||||
>
|
||||
{prev ?? 'Previous'}
|
||||
</a>
|
||||
)}
|
||||
{next != null && (
|
||||
<a
|
||||
href={scrollToTop ? '#' : undefined}
|
||||
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={onClickNext}
|
||||
>
|
||||
{next ?? 'Next'}
|
||||
</a>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
export function Pagination(props: {
|
||||
page: number
|
||||
itemsPerPage: number
|
||||
|
@ -44,24 +78,13 @@ export function Pagination(props: {
|
|||
of <span className="font-medium">{totalItems}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 justify-between sm:justify-end">
|
||||
{page > 0 && (
|
||||
<a
|
||||
href={scrollToTop ? '#' : undefined}
|
||||
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => page > 0 && setPage(page - 1)}
|
||||
>
|
||||
{prevTitle ?? 'Previous'}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={scrollToTop ? '#' : undefined}
|
||||
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => page < maxPage && setPage(page + 1)}
|
||||
>
|
||||
{nextTitle ?? 'Next'}
|
||||
</a>
|
||||
</div>
|
||||
<PaginationNextPrev
|
||||
prev={page > 0 ? prevTitle ?? 'Previous' : null}
|
||||
next={page < maxPage ? nextTitle ?? 'Next' : null}
|
||||
onClickPrev={() => setPage(page - 1)}
|
||||
onClickNext={() => setPage(page + 1)}
|
||||
scrollToTop={scrollToTop}
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { PortfolioMetrics } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { DAY_MS } from 'common/util/time'
|
||||
import { last } from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||
|
@ -10,28 +9,12 @@ import { formatTime } from 'web/lib/util/time'
|
|||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
height?: number
|
||||
period?: string
|
||||
includeTime?: boolean
|
||||
}) {
|
||||
const { portfolioHistory, height, period } = props
|
||||
|
||||
const { portfolioHistory, height, includeTime } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const portfolioHistoryFiltered = portfolioHistory.filter((p) => {
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
return p.timestamp > Date.now() - 1 * DAY_MS
|
||||
case 'weekly':
|
||||
return p.timestamp > Date.now() - 7 * DAY_MS
|
||||
case 'monthly':
|
||||
return p.timestamp > Date.now() - 30 * DAY_MS
|
||||
case 'allTime':
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
const points = portfolioHistoryFiltered.map((p) => {
|
||||
const points = portfolioHistory.map((p) => {
|
||||
return {
|
||||
x: new Date(p.timestamp),
|
||||
y: p.balance + p.investmentValue,
|
||||
|
@ -41,7 +24,6 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
|||
const numXTickValues = !width || width < 800 ? 2 : 5
|
||||
const numYTickValues = 4
|
||||
const endDate = last(points)?.x
|
||||
const includeTime = period === 'daily'
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
|
@ -66,7 +48,7 @@ export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
|||
colors={{ datum: 'color' }}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) => formatTime(+time, includeTime),
|
||||
format: (time) => formatTime(+time, !!includeTime),
|
||||
}}
|
||||
pointBorderColor="#fff"
|
||||
pointSize={points.length > 100 ? 0 : 6}
|
||||
|
|
|
@ -1,75 +1,56 @@
|
|||
import { PortfolioMetrics } from 'common/user'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { last } from 'lodash'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { Period, getPortfolioHistory } from 'web/lib/firebase/users'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
||||
import { Period } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
function PortfolioValueSection(props: {
|
||||
userId: string
|
||||
disableSelector?: boolean
|
||||
}) {
|
||||
const { disableSelector, userId } = props
|
||||
function PortfolioValueSection(props: { userId: string }) {
|
||||
const { userId } = props
|
||||
|
||||
const [portfolioPeriod, setPortfolioPeriod] = useState<Period>('weekly')
|
||||
const [portfolioHistory, setUsersPortfolioHistory] = useState<
|
||||
PortfolioMetrics[]
|
||||
>([])
|
||||
useEffect(() => {
|
||||
getPortfolioHistory(userId).then(setUsersPortfolioHistory)
|
||||
}, [userId])
|
||||
const lastPortfolioMetrics = last(portfolioHistory)
|
||||
const portfolioHistory = usePortfolioHistory(userId, portfolioPeriod)
|
||||
|
||||
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
|
||||
// Remember the last defined portfolio history.
|
||||
const portfolioRef = useRef(portfolioHistory)
|
||||
if (portfolioHistory) portfolioRef.current = portfolioHistory
|
||||
const currPortfolioHistory = portfolioRef.current
|
||||
|
||||
const lastPortfolioMetrics = last(currPortfolioHistory)
|
||||
if (!currPortfolioHistory || !lastPortfolioMetrics) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
// PATCH: If portfolio history started on June 1st, then we label it as "Since June"
|
||||
// instead of "All time"
|
||||
const allTimeLabel =
|
||||
lastPortfolioMetrics.timestamp < Date.parse('2022-06-20T00:00:00.000Z')
|
||||
? 'Since June'
|
||||
: 'All time'
|
||||
const { balance, investmentValue } = lastPortfolioMetrics
|
||||
const totalValue = balance + investmentValue
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<Row className="gap-8">
|
||||
<div className="mb-4 w-full">
|
||||
<Col
|
||||
className={disableSelector ? 'items-center justify-center' : ''}
|
||||
>
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">
|
||||
{formatMoney(
|
||||
lastPortfolioMetrics.balance +
|
||||
lastPortfolioMetrics.investmentValue
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</div>
|
||||
{!disableSelector && (
|
||||
<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>
|
||||
)}
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
</Col>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
value={portfolioPeriod}
|
||||
onChange={(e) => {
|
||||
setPortfolioPeriod(e.target.value as Period)
|
||||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
<option value="daily">Last 24h</option>
|
||||
</select>
|
||||
</Row>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={portfolioHistory}
|
||||
period={portfolioPeriod}
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -15,7 +15,7 @@ export function LoansModal(props: {
|
|||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
Every day at midnight PT, get 1% of your total bet amount back as a
|
||||
Every day at midnight PT, get 2% of your total bet amount back as a
|
||||
loan.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
|
@ -34,12 +34,12 @@ export function LoansModal(props: {
|
|||
</span>
|
||||
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||
<span className={'ml-2'}>
|
||||
For example, if you bet M$1000 on "Will I become a millionare?" on
|
||||
Monday, you will get M$10 back on Tuesday.
|
||||
For example, if you bet M$1000 on "Will I become a millionare?"
|
||||
today, you will get M$20 back tomorrow.
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
Previous loans count against your total bet amount. So on Wednesday,
|
||||
you would get back 1% of M$990 = M$9.9.
|
||||
Previous loans count against your total bet amount. So on the next
|
||||
day, you would get back 2% of M$(1000 - 20) = M$19.6.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
||||
|
|
|
@ -8,7 +8,7 @@ export function RelativeTimestamp(props: { time: number }) {
|
|||
return (
|
||||
<DateTimeTooltip
|
||||
className="ml-1 whitespace-nowrap text-gray-400"
|
||||
time={dayJsTime}
|
||||
time={time}
|
||||
>
|
||||
{dayJsTime.fromNow()}
|
||||
</DateTimeTooltip>
|
||||
|
|
46
web/components/share-post-modal.tsx
Normal file
46
web/components/share-post-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
25
web/components/sign-in-button.tsx
Normal file
25
web/components/sign-in-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -4,7 +4,7 @@ import { firebaseLogin } from 'web/lib/firebase/users'
|
|||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Button, SizeType } from './button'
|
||||
|
||||
export function SignUpPrompt(props: {
|
||||
export function BetSignUpPrompt(props: {
|
||||
label?: string
|
||||
className?: string
|
||||
size?: SizeType
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ReactNode } from 'react'
|
|||
import Link from 'next/link'
|
||||
|
||||
export const linkClass =
|
||||
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||
'z-10 break-anywhere hover:underline hover:decoration-indigo-400 hover:decoration-2'
|
||||
|
||||
export const SiteLink = (props: {
|
||||
href: string
|
||||
|
@ -19,7 +19,6 @@ export const SiteLink = (props: {
|
|||
className={clsx(linkClass, className)}
|
||||
href={href}
|
||||
target={href.startsWith('http') ? '_blank' : undefined}
|
||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (onClick) onClick()
|
||||
|
|
|
@ -10,7 +10,7 @@ export function ToastClipboard(props: { className?: string }) {
|
|||
className={clsx(
|
||||
'border-base-300 absolute items-center' +
|
||||
'gap-2 divide-x divide-gray-200 rounded-md border-2 bg-white ' +
|
||||
'h-15 w-[15rem] p-2 pr-3 text-gray-500',
|
||||
'h-15 z-10 w-[15rem] p-2 pr-3 text-gray-500',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
useRole,
|
||||
} from '@floating-ui/react-dom-interactions'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import { ReactNode, useRef, useState } from 'react'
|
||||
|
||||
// See https://floating-ui.com/docs/react-dom
|
||||
|
@ -58,14 +57,10 @@ export function Tooltip(props: {
|
|||
}[placement.split('-')[0]] as string
|
||||
|
||||
return text ? (
|
||||
<div className="contents">
|
||||
<div
|
||||
className={clsx('inline-block', className)}
|
||||
ref={reference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<>
|
||||
<span className={className} ref={reference} {...getReferenceProps()}>
|
||||
{children}
|
||||
</div>
|
||||
</span>
|
||||
{/* conditionally render tooltip and fade in/out */}
|
||||
<Transition
|
||||
show={open}
|
||||
|
@ -95,7 +90,7 @@ export function Tooltip(props: {
|
|||
}}
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)
|
||||
|
|
|
@ -119,10 +119,7 @@ export function UserPage(props: { user: User }) {
|
|||
<Col className="mx-4 -mt-6">
|
||||
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||
<Col>
|
||||
<span
|
||||
className="text-2xl font-bold"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
<span className="break-anywhere text-2xl font-bold">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-gray-500">@{user.username}</span>
|
||||
|
|
|
@ -5,13 +5,19 @@ import clsx from 'clsx'
|
|||
export default function ShortToggle(props: {
|
||||
enabled: boolean
|
||||
setEnabled: (enabled: boolean) => void
|
||||
onChange?: (enabled: boolean) => void
|
||||
}) {
|
||||
const { enabled, setEnabled } = props
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
onChange={(e: boolean) => {
|
||||
setEnabled(e)
|
||||
if (props.onChange) {
|
||||
props.onChange(e)
|
||||
}
|
||||
}}
|
||||
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
|
@ -8,6 +9,7 @@ import {
|
|||
listenForHotContracts,
|
||||
listenForInactiveContracts,
|
||||
listenForNewContracts,
|
||||
getUserBetContractsQuery,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useContracts = () => {
|
||||
|
@ -89,3 +91,15 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
|
|||
? contracts.map((c) => contractDict.current[c.id])
|
||||
: undefined
|
||||
}
|
||||
|
||||
export const useUserBetContracts = (userId: string) => {
|
||||
const result = useFirestoreQueryData(
|
||||
['contracts', 'bets', userId],
|
||||
getUserBetContractsQuery(userId),
|
||||
{ subscribe: true, includeMetadataChanges: true },
|
||||
// Temporary workaround for react-query bug:
|
||||
// https://github.com/invertase/react-query-firebase/issues/25
|
||||
{ refetchOnMount: 'always' }
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
||||
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||
import { listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||
|
||||
export const useFollows = (userId: string | null | undefined) => {
|
||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
||||
import { Notification } from 'common/notification'
|
||||
import {
|
||||
getNotificationsQuery,
|
||||
listenForNotifications,
|
||||
} from 'web/lib/firebase/notifications'
|
||||
import { getNotificationsQuery } from 'web/lib/firebase/notifications'
|
||||
import { groupBy, map, partition } from 'lodash'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
|
||||
export type NotificationGroup = {
|
||||
notifications: Notification[]
|
||||
|
@ -17,49 +13,49 @@ export type NotificationGroup = {
|
|||
type: 'income' | 'normal'
|
||||
}
|
||||
|
||||
// For some reason react-query subscriptions don't actually listen for notifications
|
||||
// Use useUnseenPreferredNotificationGroups to listen for new notifications
|
||||
export function usePreferredGroupedNotifications(
|
||||
privateUser: PrivateUser,
|
||||
cachedNotifications?: Notification[]
|
||||
) {
|
||||
function useNotifications(privateUser: PrivateUser) {
|
||||
const result = useFirestoreQueryData(
|
||||
['notifications-all', privateUser.id],
|
||||
getNotificationsQuery(privateUser.id)
|
||||
getNotificationsQuery(privateUser.id),
|
||||
{ subscribe: true, includeMetadataChanges: true },
|
||||
// Temporary workaround for react-query bug:
|
||||
// https://github.com/invertase/react-query-firebase/issues/25
|
||||
{ refetchOnMount: 'always' }
|
||||
)
|
||||
|
||||
const notifications = useMemo(() => {
|
||||
if (result.isLoading) return cachedNotifications ?? []
|
||||
if (!result.data) return cachedNotifications ?? []
|
||||
if (!result.data) return undefined
|
||||
const notifications = result.data as Notification[]
|
||||
|
||||
return getAppropriateNotifications(
|
||||
notifications,
|
||||
privateUser.notificationPreferences
|
||||
).filter((n) => !n.isSeenOnHref)
|
||||
}, [
|
||||
cachedNotifications,
|
||||
privateUser.notificationPreferences,
|
||||
result.data,
|
||||
result.isLoading,
|
||||
])
|
||||
}, [privateUser.notificationPreferences, result.data])
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
export function useUnseenNotifications(privateUser: PrivateUser) {
|
||||
const notifications = useNotifications(privateUser)
|
||||
return useMemo(
|
||||
() => notifications && notifications.filter((n) => !n.isSeen),
|
||||
[notifications]
|
||||
)
|
||||
}
|
||||
|
||||
export function useGroupedNotifications(privateUser: PrivateUser) {
|
||||
const notifications = useNotifications(privateUser)
|
||||
return useMemo(() => {
|
||||
if (notifications) return groupNotifications(notifications)
|
||||
}, [notifications])
|
||||
}
|
||||
|
||||
export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) {
|
||||
const notifications = useUnseenPreferredNotifications(privateUser, {})
|
||||
const [notificationGroups, setNotificationGroups] = useState<
|
||||
NotificationGroup[] | undefined
|
||||
>(undefined)
|
||||
useEffect(() => {
|
||||
if (!notifications) return
|
||||
|
||||
const groupedNotifications = groupNotifications(notifications)
|
||||
setNotificationGroups(groupedNotifications)
|
||||
export function useUnseenGroupedNotification(privateUser: PrivateUser) {
|
||||
const notifications = useUnseenNotifications(privateUser)
|
||||
return useMemo(() => {
|
||||
if (notifications) return groupNotifications(notifications)
|
||||
}, [notifications])
|
||||
return notificationGroups
|
||||
}
|
||||
|
||||
export function groupNotifications(notifications: Notification[]) {
|
||||
|
@ -120,36 +116,6 @@ export function groupNotifications(notifications: Notification[]) {
|
|||
return notificationGroups
|
||||
}
|
||||
|
||||
export function useUnseenPreferredNotifications(
|
||||
privateUser: PrivateUser,
|
||||
options: { customHref?: string },
|
||||
limit: number = NOTIFICATIONS_PER_PAGE
|
||||
) {
|
||||
const { customHref } = options
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
||||
useState<Notification[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return listenForNotifications(privateUser.id, setNotifications, {
|
||||
unseenOnly: true,
|
||||
limit,
|
||||
})
|
||||
}, [limit, privateUser.id])
|
||||
|
||||
useEffect(() => {
|
||||
const notificationsToShow = getAppropriateNotifications(
|
||||
notifications,
|
||||
privateUser.notificationPreferences
|
||||
).filter((n) =>
|
||||
customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref
|
||||
)
|
||||
setUserAppropriateNotifications(notificationsToShow)
|
||||
}, [notifications, customHref, privateUser.notificationPreferences])
|
||||
|
||||
return userAppropriateNotifications
|
||||
}
|
||||
|
||||
const lessPriorityReasons = [
|
||||
'on_contract_with_users_comment',
|
||||
'on_contract_with_users_answer',
|
||||
|
|
109
web/hooks/use-pagination.ts
Normal file
109
web/hooks/use-pagination.ts
Normal 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()),
|
||||
}
|
||||
}
|
32
web/hooks/use-portfolio-history.ts
Normal file
32
web/hooks/use-portfolio-history.ts
Normal 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
11
web/hooks/use-prefetch.ts
Normal 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')
|
||||
}
|
|
@ -1,22 +1,21 @@
|
|||
import { uniq } from 'lodash'
|
||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Bet,
|
||||
listenForUserBets,
|
||||
getUserBetsQuery,
|
||||
listenForUserContractBets,
|
||||
} from 'web/lib/firebase/bets'
|
||||
|
||||
export const useUserBets = (
|
||||
userId: string | undefined,
|
||||
options: { includeRedemptions: boolean }
|
||||
) => {
|
||||
const [bets, setBets] = useState<Bet[] | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) return listenForUserBets(userId, setBets, options)
|
||||
}, [userId])
|
||||
|
||||
return bets
|
||||
export const useUserBets = (userId: string) => {
|
||||
const result = useFirestoreQueryData(
|
||||
['bets', userId],
|
||||
getUserBetsQuery(userId),
|
||||
{ subscribe: true, includeMetadataChanges: true },
|
||||
// Temporary workaround for react-query bug:
|
||||
// https://github.com/invertase/react-query-firebase/issues/25
|
||||
{ refetchOnMount: 'always' }
|
||||
)
|
||||
return result.data
|
||||
}
|
||||
|
||||
export const useUserContractBets = (
|
||||
|
@ -33,36 +32,6 @@ export const useUserContractBets = (
|
|||
return bets
|
||||
}
|
||||
|
||||
export const useUserBetContracts = (
|
||||
userId: string | undefined,
|
||||
options: { includeRedemptions: boolean }
|
||||
) => {
|
||||
const [contractIds, setContractIds] = useState<string[] | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
const key = `user-bet-contractIds-${userId}`
|
||||
|
||||
const userBetContractJson = localStorage.getItem(key)
|
||||
if (userBetContractJson) {
|
||||
setContractIds(JSON.parse(userBetContractJson))
|
||||
}
|
||||
|
||||
return listenForUserBets(
|
||||
userId,
|
||||
(bets) => {
|
||||
const contractIds = uniq(bets.map((bet) => bet.contractId))
|
||||
setContractIds(contractIds)
|
||||
localStorage.setItem(key, JSON.stringify(contractIds))
|
||||
},
|
||||
options
|
||||
)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
return contractIds
|
||||
}
|
||||
|
||||
export const useGetUserBetContractIds = (userId: string | undefined) => {
|
||||
const [contractIds, setContractIds] = useState<string[] | undefined>()
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { auth } from './users'
|
||||
import { APIError, getFunctionUrl } from 'common/api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
export { APIError } from 'common/api'
|
||||
|
||||
export async function call(url: string, method: string, params: any) {
|
||||
|
@ -88,3 +89,7 @@ export function acceptChallenge(params: any) {
|
|||
export function getCurrentUser(params: any) {
|
||||
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
|
||||
}
|
||||
|
||||
export function createPost(params: { title: string; content: JSONContent }) {
|
||||
return call(getFunctionUrl('createpost'), 'POST', params)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
getDocs,
|
||||
getDoc,
|
||||
DocumentSnapshot,
|
||||
Query,
|
||||
} from 'firebase/firestore'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
|
@ -131,24 +132,12 @@ export async function getContractsOfUserBets(userId: string) {
|
|||
return filterDefined(contracts)
|
||||
}
|
||||
|
||||
export function listenForUserBets(
|
||||
userId: string,
|
||||
setBets: (bets: Bet[]) => void,
|
||||
options: { includeRedemptions: boolean }
|
||||
) {
|
||||
const { includeRedemptions } = options
|
||||
const userQuery = query(
|
||||
export function getUserBetsQuery(userId: string) {
|
||||
return query(
|
||||
collectionGroup(db, 'bets'),
|
||||
where('userId', '==', userId),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
return listenForValues<Bet>(userQuery, (bets) => {
|
||||
setBets(
|
||||
bets.filter(
|
||||
(bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte
|
||||
)
|
||||
)
|
||||
})
|
||||
) as Query<Bet>
|
||||
}
|
||||
|
||||
export function listenForUserContractBets(
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Query,
|
||||
collection,
|
||||
collectionGroup,
|
||||
doc,
|
||||
|
@ -148,12 +149,10 @@ export function listenForRecentComments(
|
|||
return listenForValues<Comment>(recentCommentsQuery, setComments)
|
||||
}
|
||||
|
||||
const getUsersCommentsQuery = (userId: string) =>
|
||||
export const getUserCommentsQuery = (userId: string) =>
|
||||
query(
|
||||
collectionGroup(db, 'comments'),
|
||||
where('userId', '==', userId),
|
||||
where('commentType', '==', 'contract'),
|
||||
orderBy('createdTime', 'desc')
|
||||
)
|
||||
export async function getUsersComments(userId: string) {
|
||||
return await getValues<Comment>(getUsersCommentsQuery(userId))
|
||||
}
|
||||
) as Query<ContractComment>
|
||||
|
|
|
@ -6,13 +6,14 @@ import {
|
|||
getDocs,
|
||||
limit,
|
||||
orderBy,
|
||||
Query,
|
||||
query,
|
||||
setDoc,
|
||||
startAfter,
|
||||
updateDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { sortBy, sum } from 'lodash'
|
||||
import { sortBy, sum, uniqBy } from 'lodash'
|
||||
|
||||
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||
import { BinaryContract, Contract } from 'common/contract'
|
||||
|
@ -156,6 +157,13 @@ export function listenForUserContracts(
|
|||
return listenForValues<Contract>(q, setContracts)
|
||||
}
|
||||
|
||||
export function getUserBetContractsQuery(userId: string) {
|
||||
return query(
|
||||
contracts,
|
||||
where('uniqueBettorIds', 'array-contains', userId)
|
||||
) as Query<Contract>
|
||||
}
|
||||
|
||||
const activeContractsQuery = query(
|
||||
contracts,
|
||||
where('isResolved', '==', false),
|
||||
|
@ -305,7 +313,7 @@ export const getRandTopCreatorContracts = async (
|
|||
where('isResolved', '==', false),
|
||||
where('creatorId', '==', creatorId),
|
||||
orderBy('popularityScore', 'desc'),
|
||||
limit(Math.max(count * 2, 15))
|
||||
limit(count * 2)
|
||||
)
|
||||
const data = await getValues<Contract>(creatorContractsQuery)
|
||||
const open = data
|
||||
|
@ -315,6 +323,44 @@ export const getRandTopCreatorContracts = async (
|
|||
return chooseRandomSubset(open, count)
|
||||
}
|
||||
|
||||
export const getRandTopGroupContracts = async (
|
||||
groupSlug: string,
|
||||
count: number,
|
||||
excluding: string[] = []
|
||||
) => {
|
||||
const creatorContractsQuery = query(
|
||||
contracts,
|
||||
where('groupSlugs', 'array-contains', groupSlug),
|
||||
where('isResolved', '==', false),
|
||||
orderBy('popularityScore', 'desc'),
|
||||
limit(count * 2)
|
||||
)
|
||||
const data = await getValues<Contract>(creatorContractsQuery)
|
||||
const open = data
|
||||
.filter((c) => c.closeTime && c.closeTime > Date.now())
|
||||
.filter((c) => !excluding.includes(c.id))
|
||||
|
||||
return chooseRandomSubset(open, count)
|
||||
}
|
||||
|
||||
export const getRecommendedContracts = async (
|
||||
contract: Contract,
|
||||
count: number
|
||||
) => {
|
||||
const { creatorId, groupSlugs, id } = contract
|
||||
|
||||
const [userContracts, groupContracts] = await Promise.all([
|
||||
getRandTopCreatorContracts(creatorId, count, [id]),
|
||||
groupSlugs && groupSlugs[0]
|
||||
? getRandTopGroupContracts(groupSlugs[0], count, [id])
|
||||
: [],
|
||||
])
|
||||
|
||||
const combined = uniqBy([...userContracts, ...groupContracts], (c) => c.id)
|
||||
|
||||
return chooseRandomSubset(combined, count)
|
||||
}
|
||||
|
||||
export async function getRecentBetsAndComments(contract: Contract) {
|
||||
const contractDoc = doc(contracts, contract.id)
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { collection, limit, orderBy, query, where } from 'firebase/firestore'
|
||||
import { Notification } from 'common/notification'
|
||||
import { db } from 'web/lib/firebase/init'
|
||||
import { listenForValues } from 'web/lib/firebase/utils'
|
||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||
|
||||
export function getNotificationsQuery(
|
||||
|
@ -23,17 +21,3 @@ export function getNotificationsQuery(
|
|||
limit(NOTIFICATIONS_PER_PAGE * 10)
|
||||
)
|
||||
}
|
||||
|
||||
export function listenForNotifications(
|
||||
userId: string,
|
||||
setNotifications: (notifs: Notification[]) => void,
|
||||
unseenOnlyOptions?: { unseenOnly: boolean; limit: number }
|
||||
) {
|
||||
return listenForValues<Notification>(
|
||||
getNotificationsQuery(userId, unseenOnlyOptions),
|
||||
(notifs) => {
|
||||
notifs.sort((n1, n2) => n2.createdTime - n1.createdTime)
|
||||
setNotifications(notifs)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
34
web/lib/firebase/posts.ts
Normal file
34
web/lib/firebase/posts.ts
Normal 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()
|
||||
}
|
|
@ -12,6 +12,7 @@ import {
|
|||
deleteDoc,
|
||||
collectionGroup,
|
||||
onSnapshot,
|
||||
Query,
|
||||
} from 'firebase/firestore'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
||||
|
@ -253,14 +254,13 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
|
|||
await deleteDoc(followDoc)
|
||||
}
|
||||
|
||||
export async function getPortfolioHistory(userId: string) {
|
||||
return getValues<PortfolioMetrics>(
|
||||
query(
|
||||
collectionGroup(db, 'portfolioHistory'),
|
||||
where('userId', '==', userId),
|
||||
orderBy('timestamp', 'asc')
|
||||
)
|
||||
)
|
||||
export function getPortfolioHistoryQuery(userId: string, since: number) {
|
||||
return query(
|
||||
collectionGroup(db, 'portfolioHistory'),
|
||||
where('userId', '==', userId),
|
||||
where('timestamp', '>=', since),
|
||||
orderBy('timestamp', 'asc')
|
||||
) as Query<PortfolioMetrics>
|
||||
}
|
||||
|
||||
export function listenForFollows(
|
||||
|
|
27
web/lib/icons/trophy-icon.tsx
Normal file
27
web/lib/icons/trophy-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -27,14 +27,14 @@
|
|||
"@nivo/line": "0.74.0",
|
||||
"@nivo/tooltip": "0.74.0",
|
||||
"@react-query-firebase/firestore": "0.4.2",
|
||||
"@tiptap/core": "2.0.0-beta.181",
|
||||
"@tiptap/core": "2.0.0-beta.182",
|
||||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||
"@tiptap/extension-image": "2.0.0-beta.30",
|
||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||
"@tiptap/extension-placeholder": "2.0.0-beta.53",
|
||||
"@tiptap/react": "2.0.0-beta.114",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.191",
|
||||
"algoliasearch": "4.13.0",
|
||||
"browser-image-compression": "2.0.0",
|
||||
"clsx": "1.1.1",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||
|
@ -11,7 +11,7 @@ import { Spacer } from 'web/components/layout/spacer'
|
|||
import {
|
||||
Contract,
|
||||
getContractFromSlug,
|
||||
getRandTopCreatorContracts,
|
||||
getRecommendedContracts,
|
||||
tradingAllowed,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
|
@ -40,8 +40,9 @@ import {
|
|||
ContractLeaderboard,
|
||||
ContractTopTrades,
|
||||
} from 'web/components/contract/contract-leaderboard'
|
||||
import { Subtitle } from 'web/components/subtitle'
|
||||
import { ContractsGrid } from 'web/components/contract/contracts-grid'
|
||||
import { Title } from 'web/components/title'
|
||||
import { usePrefetch } from 'web/hooks/use-prefetch'
|
||||
|
||||
export const getStaticProps = fromPropz(getStaticPropz)
|
||||
export async function getStaticPropz(props: {
|
||||
|
@ -54,9 +55,7 @@ export async function getStaticPropz(props: {
|
|||
const [bets, comments, recommendedContracts] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contract
|
||||
? getRandTopCreatorContracts(contract.creatorId, 4, [contract?.id])
|
||||
: [],
|
||||
contract ? getRecommendedContracts(contract, 6) : [],
|
||||
])
|
||||
|
||||
return {
|
||||
|
@ -108,7 +107,9 @@ export default function ContractPage(props: {
|
|||
return <Custom404 />
|
||||
}
|
||||
|
||||
return <ContractPageContent {...{ ...props, contract, user }} />
|
||||
return (
|
||||
<ContractPageContent key={contract.id} {...{ ...props, contract, user }} />
|
||||
)
|
||||
}
|
||||
|
||||
export function ContractPageSidebar(props: {
|
||||
|
@ -154,9 +155,10 @@ export function ContractPageContent(
|
|||
user?: User | null
|
||||
}
|
||||
) {
|
||||
const { backToHome, comments, user, recommendedContracts } = props
|
||||
const { backToHome, comments, user } = props
|
||||
|
||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||
usePrefetch(user?.id)
|
||||
|
||||
useTracking('view market', {
|
||||
slug: contract.slug,
|
||||
|
@ -165,6 +167,10 @@ export function ContractPageContent(
|
|||
})
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const nonChallengeBets = useMemo(
|
||||
() => bets.filter((b) => !b.challengeSlug),
|
||||
[bets]
|
||||
)
|
||||
|
||||
// Sort for now to see if bug is fixed.
|
||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||
|
@ -182,6 +188,16 @@ export function ContractPageContent(
|
|||
setShowConfetti(shouldSeeConfetti)
|
||||
}, [contract, user])
|
||||
|
||||
const [recommendedContracts, setRecommendedMarkets] = useState(
|
||||
props.recommendedContracts
|
||||
)
|
||||
useEffect(() => {
|
||||
if (contract && recommendedContracts.length === 0) {
|
||||
getRecommendedContracts(contract, 6).then(setRecommendedMarkets)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contract.id, recommendedContracts])
|
||||
|
||||
const { isResolved, question, outcomeType } = contract
|
||||
|
||||
const allowTrade = tradingAllowed(contract)
|
||||
|
@ -220,10 +236,7 @@ export function ContractPageContent(
|
|||
</button>
|
||||
)}
|
||||
|
||||
<ContractOverview
|
||||
contract={contract}
|
||||
bets={bets.filter((b) => !b.challengeSlug)}
|
||||
/>
|
||||
<ContractOverview contract={contract} bets={nonChallengeBets} />
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<AlertBox
|
||||
|
@ -267,14 +280,17 @@ export function ContractPageContent(
|
|||
tips={tips}
|
||||
comments={comments}
|
||||
/>
|
||||
|
||||
{recommendedContracts?.length > 0 && (
|
||||
<Col className="mx-2 gap-2 sm:mx-0">
|
||||
<Subtitle text="Recommended" />
|
||||
<ContractsGrid contracts={recommendedContracts} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
93
web/pages/create-post.tsx
Normal file
93
web/pages/create-post.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -33,6 +33,7 @@ import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
|||
import { Title } from 'web/components/title'
|
||||
import { SEO } from 'web/components/SEO'
|
||||
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
|
||||
import { MINUTE_MS } from 'common/util/time'
|
||||
|
||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||
return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
|
||||
|
@ -66,6 +67,18 @@ export default function Create(props: { auth: { user: User } }) {
|
|||
|
||||
if (!router.isReady) return <div />
|
||||
|
||||
if (user.isBannedFromPosting)
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="rounded-lg px-6 py-4 sm:py-0">
|
||||
<Title className="!mt-0" text="Create a market" />
|
||||
<p>Sorry, you are currently banned from creating a market.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<SEO
|
||||
|
@ -209,7 +222,9 @@ export function NewContract(props: {
|
|||
max: MAX_DESCRIPTION_LENGTH,
|
||||
placeholder: descriptionPlaceholder,
|
||||
disabled: isSubmitting,
|
||||
defaultValue: JSON.parse(params?.description ?? '{}'),
|
||||
defaultValue: params?.description
|
||||
? JSON.parse(params.description)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const isEditorFilled = editor != null && !editor.isEmpty
|
||||
|
@ -427,7 +442,7 @@ export function NewContract(props: {
|
|||
className="input input-bordered mt-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setCloseDate(e.target.value)}
|
||||
min={Date.now()}
|
||||
min={Math.round(Date.now() / MINUTE_MS) * MINUTE_MS}
|
||||
disabled={isSubmitting}
|
||||
value={closeDate}
|
||||
/>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user