This commit is contained in:
marsteralex 2022-08-04 11:10:04 -07:00
commit b98a6caee3
63 changed files with 2255 additions and 539 deletions

View File

@ -40,12 +40,14 @@ export type User = {
referredByContractId?: string
referredByGroupId?: string
lastPingTime?: number
shouldShowWelcome?: boolean
}
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
// for sus users, i.e. multiple sign ups for same person
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
@ -55,6 +57,7 @@ export type PrivateUser = {
unsubscribedFromCommentEmails?: boolean
unsubscribedFromAnswerEmails?: boolean
unsubscribedFromGenericEmails?: boolean
manaBonusEmailSent?: boolean
initialDeviceToken?: string
initialIpAddress?: string
apiKey?: string

View File

@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
Requires no authorization.
### GET /v0/me
Returns the authenticated user.
### `GET /v0/groups`
Gets all groups, in no particular order.
Requires no authorization.
### `GET /v0/groups/[slug]`
Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/markets`
Lists all markets, ordered by creation date descending.
@ -481,6 +503,20 @@ Parameters:
answer. For numeric markets, this is a string representing the target bucket,
and an additional `value` parameter is required which is a number representing
the target value. (Bet on numeric markets at your own peril.)
- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing
the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the
probability percentage).
The bet will execute immediately in the direction of `outcome`, but not beyond this
specified limit. If not all the bet is filled, the bet will remain as an open offer
that can later be matched against an opposite direction bet.
- For example, if the current market probability is `50%`:
- A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market
probability moves down to `40%` and someone bets `M$15` of `NO` to match your
bet odds.
- A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely
depending on current unfilled limit bets and the AMM's liquidity. Any remaining
portion of the bet not filled would remain to be matched against in the future.
- An unfilled limit order bet can be cancelled using the cancel API.
Example request:
@ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
### `POST /v0/market/[marketId]/sell`
Sells some quantity of shares in a market on behalf of the authorized user.
Sells some quantity of shares in a binary market on behalf of the authorized user.
Parameters:
- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric
bucket ID, depending on the market type.
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
own one kind of shares, you will sell that kind of shares.
- `shares`: Optional. The amount of shares to sell of the outcome given
above. If not provided, all the shares you own will be sold.
@ -617,7 +653,7 @@ Requires no authorization.
- Example request
```
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa
https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord
```
- Response type: A `Bet[]`.
@ -625,31 +661,60 @@ Requires no authorization.
```json
[
// Limit bet, partially filled.
{
"probAfter": 0.44418877319153904,
"shares": -645.8346334931828,
"isFilled": false,
"amount": 15.596681605353808,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"probBefore": 0.5730753474948571,
"isCancelled": false,
"outcome": "YES",
"contractId": "tgB1XmvFXZNhjr3xMNLp",
"sale": {
"betId": "RcOtarI3d1DUUTjiE0rx",
"amount": 474.9999999999998
},
"createdTime": 1644602886293,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.7229189477449224,
"id": "x9eNmCaqQeXW8AgJ8Zmp",
"amount": -499.9999999999998
},
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
"shares": 31.193363210707616,
"limitProb": 0.5,
"id": "yXB8lVbs86TKkhWA1FVi",
"loanAmount": 0,
"orderAmount": 100,
"probAfter": 0.5730753474948571,
"createdTime": 1659482775970,
"fills": [
{
"probAfter": 0.9901970375647697,
"contractId": "zdeaYVAfHlo9jKzWh57J",
"outcome": "YES",
"amount": 1,
"id": "8PqxKYwXCcLYoXy2m2Nm",
"shares": 1.0049875638533763,
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
"probBefore": 0.9900000000000001,
"createdTime": 1644705818872
"timestamp": 1659483249648,
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
"amount": 15.596681605353808,
"shares": 31.193363210707616
}
]
},
// Normal bet (no limitProb specified).
{
"shares": 17.350459904608414,
"probBefore": 0.5304358279113885,
"isFilled": true,
"probAfter": 0.5730753474948571,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"amount": 10,
"contractId": "Tz5dA01GkK5QKiQfZeDL",
"id": "1LPJHNz5oAX4K6YtJlP1",
"fees": {
"platformFee": 0,
"liquidityFee": 0,
"creatorFee": 0.4251333951457593
},
"isCancelled": false,
"loanAmount": 0,
"orderAmount": 10,
"fills": [
{
"amount": 10,
"matchedBetId": null,
"shares": 17.350459904608414,
"timestamp": 1659482757271
}
],
"createdTime": 1659482757271,
"outcome": "YES"
}
]
```

View File

@ -10,6 +10,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government
- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold
- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$.
## API / Dev
@ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets.
## Bots
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets

View File

@ -22,7 +22,7 @@ service cloud.firestore {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']);
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']);
// User referral rules
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()

View File

@ -31,6 +31,7 @@
"@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/starter-kit": "2.0.0-beta.190",
"dayjs": "1.11.4",
"cors": "2.8.5",
"express": "4.18.1",
"firebase-admin": "10.0.0",

View File

@ -14,7 +14,7 @@ import {
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
import { chargeUser } from './utils'
import { chargeUser, getContract } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import {
@ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user'
import { Group, MAX_ID_LENGTH } from '../../common/group'
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
import { getPseudoProbability } from '../../common/pseudo-numeric'
import { JSONContent } from '@tiptap/core'
import { zip } from 'lodash'
import { Bet } from 'common/bet'
import { uniq, zip } from 'lodash'
import { Bet } from '../../common/bet'
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
@ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
let group = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (!group.memberIds.includes(user.id)) {
throw new APIError(
400,
'User must be a member of the group to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id))
await groupDocRef.update({
contractIds: [...group.contractIds, contractRef.id],
})
}
console.log(
'creating contract for',
user.username,
@ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
await contractRef.create(contract)
let group = null
if (groupId) {
const groupDocRef = firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (
!group.memberIds.includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
throw new APIError(
400,
'User must be a member/creator of the group or group must be open to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
await groupDocRef.update({
contractIds: uniq([...group.contractIds, contractRef.id]),
})
}
}
const providerId = user.id
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
@ -284,3 +290,38 @@ export async function getContractFromSlug(slug: string) {
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}
async function createGroupLinks(
group: Group,
contractIds: string[],
userId: string
) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
if (!contract?.groupSlugs?.includes(group.slug)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]),
})
}
if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) {
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
...(contract?.groupLinks ?? []),
],
})
}
}
}

View File

@ -1,5 +1,7 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
@ -24,7 +26,6 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { uniq } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
@ -77,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
}
await firestore.collection('users').doc(auth.uid).create(user)
@ -92,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
await sendWelcomeEmail(user, privateUser)
await addUserToDefaultGroups(user)
await sendWelcomeEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,738 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<title>(no subject)</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Readex+Pro"
rel="stylesheet"
type="text/css"
/>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="background-color: #f4f4f4">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 25px 0px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 0px;
padding-left: 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<a
href="https://manifold.markets/home"
target="_blank"
><img
alt=""
height="auto"
src="https://03jlj.mjt.lu/img/03jlj/b/96u/omk8.gif"
style="
border: none;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 0px 25px 20px 25px;
padding-top: 0px;
padding-right: 25px;
padding-bottom: 20px;
padding-left: 25px;
word-break: break-word;
"
>
<div
style="
font-family: Arial, sans-serif;
font-size: 17px;
letter-spacing: normal;
line-height: 1;
text-align: left;
color: #000000;
"
>
<p
class="text-build-content"
style="
line-height: 23px;
margin: 10px 0;
margin-top: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>On Manifold Markets, several important factors
go into making a good question. These lead to
more people betting on them and allowing a more
accurate prediction to be formed!</span
>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Manifold also gives its creators 10 Mana for
each unique trader that bets on your
market!</span
>
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b>What makes a good question?</b></span
>
</p>
<ul>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution criteria. </b>This is
needed so users know how you are going to
decide on what the correct answer is.</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Clear resolution date</b>. This is
sometimes slightly different from the closing
date. We recommend leaving the market open up
until you resolve it, but if it is different
make sure you say what day you intend to
resolve it in the description!</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Detailed description. </b>Use the rich
text editor to create an easy to read
description. Include any context or background
information that could be useful to people who
are interested in learning more that are
uneducated on the subject.</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Add it to a group. </b>Groups are the
primary way users filter for relevant markets.
Also, consider making your own groups and
inviting friends/interested communities to
them from other sites!</span
>
</li>
<li style="line-height: 23px">
<span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><b>Bonus: </b>Add a comment on your
prediction and explain (with links and
sources) supporting it.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #292fd7;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 20px;
"
><b
>Examples of markets you should
emulate!&nbsp;</b
></span
>
</p>
<ul>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/DavidChee/will-our-upcoming-twitch-bot-be-a-s"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This complex market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
about the project I am working on.</span
>
</li>
<li style="line-height: 23px">
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/SneakySly/will-manifold-reach-1000-weekly-act"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>This simple market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
about Manifold&apos;s weekly active
users.</span
>
</li>
</ul>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
&nbsp;
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Why not </span>
<a
class="link-build-content"
style="color: inherit; text-decoration: none"
target="_blank"
href="https://manifold.markets/create"
><span
style="
color: #55575d;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
><u>create a market</u></span
></a
><span
style="
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>
while it is still fresh on your mind?
</p>
<p
class="text-build-content"
style="line-height: 23px; margin: 10px 0"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>Thanks for reading!</span
>
</p>
<p
class="text-build-content"
style="
line-height: 23px;
margin: 10px 0;
margin-bottom: 10px;
"
data-testid="3Q8BP69fq"
>
<span
style="
color: #000000;
font-family: Readex Pro, Arial, Helvetica,
sans-serif;
font-size: 17px;
"
>David from Manifold</span
>
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
"
>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
"
>
<p style="margin: 10px 0">
This e-mail has been sent to {{name}},
<a
href="{{unsubscribeLink}}"
style="
color: inherit;
text-decoration: none;
"
target="_blank"
>click here to unsubscribe</a
>.
</p>
</div>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
)
}
// TODO: use manalinks to give out M$500
export const sendOneWeekBonusEmail = async (
user: User,
privateUser: PrivateUser
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
await sendTemplateEmail(
privateUser.email,
'Manifold one week anniversary gift',
'Manifold Markets one week anniversary gift',
'one-week',
{
name: firstName,
unsubscribeLink,
manalink: '', // TODO
manalink: 'https://manifold.markets/link/lj4JbBvE',
},
{
from: 'David from Manifold <david@manifold.markets>',

View File

@ -0,0 +1,18 @@
import { User } from 'common/user'
import * as admin from 'firebase-admin'
import { newEndpoint, APIError } from './api'
export const getcurrentuser = newEndpoint(
{ method: 'GET' },
async (_req, auth) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const [userSnap] = await firestore.getAll(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
return user
}
)
const firestore = admin.firestore()

View File

@ -27,6 +27,25 @@ export * from './on-delete-group'
export * from './score-contracts'
// v2
export * from './health'
export * from './transact'
export * from './change-user-info'
export * from './create-user'
export * from './create-answer'
export * from './place-bet'
export * from './cancel-bet'
export * from './sell-bet'
export * from './sell-shares'
export * from './claim-manalink'
export * from './create-contract'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
import { health } from './health'
import { transact } from './transact'
import { changeuserinfo } from './change-user-info'
@ -44,6 +63,7 @@ import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -66,6 +86,7 @@ const resolveMarketFunction = toCloudFunction(resolvemarket)
const unsubscribeFunction = toCloudFunction(unsubscribe)
const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
export {
healthFunction as health,
@ -86,4 +107,5 @@ export {
unsubscribeFunction as unsubscribe,
stripeWebhookFunction as stripewebhook,
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
}

View File

@ -0,0 +1,42 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as dayjs from 'dayjs'
import { getPrivateUser } from './utils'
import { sendOneWeekBonusEmail } from './emails'
import { User } from 'common/user'
export const manabonusemail = functions
.runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('0 9 * * 1-7')
.onRun(async () => {
await sendOneWeekEmails()
})
const firestore = admin.firestore()
async function sendOneWeekEmails() {
const oneWeekAgo = dayjs().subtract(1, 'week').valueOf()
const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf()
const userDocs = await firestore
.collection('users')
.where('createdTime', '<=', oneWeekAgo)
.get()
for (const user of userDocs.docs.map((d) => d.data() as User)) {
if (user.createdTime < twoWeekAgo) continue
const privateUser = await getPrivateUser(user.id)
if (!privateUser || privateUser.manaBonusEmailSent) continue
await firestore
.collection('private-users')
.doc(user.id)
.update({ manaBonusEmailSent: true })
console.log('sending m$ bonus email to', user.username)
await sendOneWeekBonusEmail(user, privateUser)
return
}
}

View File

@ -1,6 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { getContract } from './utils'
import { uniq } from 'lodash'
const firestore = admin.firestore()
export const onUpdateGroup = functions.firestore
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
const prevGroup = change.before.data() as Group
const group = change.after.data() as Group
// ignore the update we just made
// Ignore the activity update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
.doc(group.id)
.update({ mostRecentActivityTime: Date.now() })
})
export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)
await firestore
.collection('contracts')
.doc(contractId)
.update({
groupSlugs: uniq([
...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ??
[]),
]),
groupLinks: [
...(contract?.groupLinks?.filter(
(link) => link.groupId !== group.id
) ?? []),
],
})
}
}

View File

@ -18,6 +18,7 @@ import {
groupPayoutsByUser,
Payout,
} from '../../common/payouts'
import { isAdmin } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
@ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const { contractId } = validate(bodySchema, req.body)
const userId = auth.uid
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get()
if (!contractSnap.exists)
@ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
req.body
)
if (creatorId !== userId)
if (creatorId !== auth.uid && !isAdmin(auth.uid))
throw new APIError(403, 'User is not creator of contract')
if (contract.resolution) throw new APIError(400, 'Contract already resolved')

View File

@ -0,0 +1,25 @@
// We have some groups without IDs. Let's fill them in.
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const groupsQuery = firestore.collection('groups')
groupsQuery.get().then(async (groupSnaps) => {
log(`Loaded ${groupSnaps.size} groups.`)
const needsFilling = groupSnaps.docs.filter((ct) => {
return !('id' in ct.data())
})
log(`${needsFilling.length} groups need IDs.`)
const updates = needsFilling.map((group) => {
return { doc: group.ref, fields: { id: group.id } }
})
log(`Updating ${updates.length} groups.`)
await writeAsync(firestore, updates)
log(`Updated all groups.`)
})
}

View File

@ -1,4 +1,4 @@
import { sumBy, uniq } from 'lodash'
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
@ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { getValues, log } from './utils'
import { Bet } from '../../common/bet'
import { floatingLesserEqual } from '../../common/util/math'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares'
@ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares'
const bodySchema = z.object({
contractId: z.string(),
shares: z.number().optional(), // leave it out to sell all shares
outcome: z.enum(['YES', 'NO']),
outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have
})
export const sellshares = newEndpoint({}, async (req, auth) => {
@ -46,9 +46,31 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
throw new APIError(400, 'Trading is closed.')
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
sumBy(bets, (b) => b.shares)
)
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
let chosenOutcome: 'YES' | 'NO'
if (outcome != null) {
chosenOutcome = outcome
} else {
const nonzeroShares = Object.entries(sharesByOutcome).filter(
([_k, v]) => !floatingEqual(0, v)
)
if (nonzeroShares.length == 0) {
throw new APIError(400, "You don't own any shares in this market.")
}
if (nonzeroShares.length > 1) {
throw new APIError(
400,
`You own multiple kinds of shares, but did not specify which to sell.`
)
}
chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO'
}
const maxShares = sharesByOutcome[chosenOutcome]
const sharesToSell = shares ?? maxShares
if (!floatingLesserEqual(sharesToSell, maxShares))
@ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
outcome,
chosenOutcome,
contract,
prevLoanAmount,
unfilledBets

View File

@ -26,9 +26,10 @@ export const sendTemplateEmail = (
subject: string,
templateId: string,
templateData: Record<string, string>,
options?: { from: string }
options?: Partial<mailgun.messages.SendTemplateData>
) => {
const data = {
const data: mailgun.messages.SendTemplateData = {
...options,
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
to,
subject,
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
'h:X-Mailgun-Variables': JSON.stringify(templateData),
}
const mg = initMailgun()
return mg.messages().send(data, (error) => {
if (error) console.log('Error sending email', error)
else console.log('Sent template email', templateId, to, subject)

View File

@ -25,6 +25,7 @@ import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@ -62,6 +63,7 @@ addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
app.listen(PORT)

View File

@ -157,9 +157,7 @@ export function BetsList(props: {
(c) => contractsMetrics[c.id].netPayout
)
const totalPortfolio = currentNetInvestment + user.balance
const totalPnl = totalPortfolio - user.totalDeposits
const totalPnl = user.profitCached.allTime
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
const investedProfitPercent =
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
@ -354,7 +352,7 @@ function ContractBets(props: {
<LimitOrderTable
contract={contract}
limitBets={limitBets}
isYou={true}
isYou={isYourBets}
/>
</div>
)}

View File

@ -39,8 +39,10 @@ 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-white' && 'bg-white text-gray-500 hover:bg-gray-200',
color === 'gray' &&
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
color === 'gray-white' &&
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
className
)}
disabled={disabled}

View File

@ -15,8 +15,8 @@ export function PillButton(props: {
className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full',
selected
? ['text-white', color ?? 'bg-gray-700']
: 'bg-gray-100 hover:bg-gray-200',
? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3',
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)}
onClick={onSelect}

View File

@ -1,26 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite'
import {
Configure,
InstantSearch,
SearchBox,
SortBy,
useInfiniteHits,
useSortBy,
} from 'react-instantsearch-hooks-web'
import { Contract } from 'common/contract'
import {
Sort,
useInitialQueryAndSort,
useUpdateQueryAndSort,
} from '../hooks/use-sort-and-query-params'
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
import {
ContractHighlightOptions,
ContractsGrid,
} from './contract/contracts-list'
import { Row } from './layout/row'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
@ -30,8 +18,9 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
import { PillButton } from './buttons/pill-button'
import { sortBy } from 'lodash'
import { range, sortBy } from 'lodash'
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
import { Col } from './layout/col'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -39,17 +28,17 @@ const searchClient = algoliasearch(
)
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
const sortOptions = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
]
export const DEFAULT_SORT = 'score'
@ -108,31 +97,27 @@ export function ContractSearch(props: {
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
const follows = useFollows(user?.id)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const sort = sortIndexes
.map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort
: querySortOptions?.defaultSort ?? DEFAULT_SORT
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
defaultSort,
shouldLoadFromStorage,
})
const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open'
)
const pillsEnabled = !additionalFilter
const pillsEnabled = !additionalFilter && !query
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
const selectFilter = (pill: string | undefined) => () => {
const selectPill = (pill: string | undefined) => () => {
setPillFilter(pill)
setPage(0)
track('select search category', { category: pill ?? 'all' })
}
const { filters, numericFilters } = useMemo(() => {
let filters = [
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
const additionalFilters = [
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
@ -140,6 +125,14 @@ export function ContractSearch(props: {
additionalFilter?.groupSlug
? `groupLinks.slug:${additionalFilter.groupSlug}`
: '',
]
const facetFilters = query
? additionalFilters
: [
...additionalFilters,
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
? `groupLinks.slug:${pillFilter}`
: '',
@ -161,24 +154,97 @@ export function ContractSearch(props: {
`uniqueBettorIds:${user.id}`
: '',
].filter((f) => f)
// Hack to make Algolia work.
filters = ['', ...filters]
const numericFilters = [
const numericFilters = query
? []
: [
filter === 'open' ? `closeTime > ${Date.now()}` : '',
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
].filter((f) => f)
return { filters, numericFilters }
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
memberGroupSlugs.join(','),
(follows ?? []).join(','),
pillFilter,
])
const indexName = `${indexPrefix}contracts-${sort}`
const index = useMemo(() => searchClient.initIndex(indexName), [indexName])
const searchIndex = useMemo(
() => searchClient.initIndex(searchIndexName),
[searchIndexName]
)
const [page, setPage] = useState(0)
const [numPages, setNumPages] = useState(1)
const [hitsByPage, setHitsByPage] = useState<{ [page: string]: Contract[] }>(
{}
)
useEffect(() => {
let wasMostRecentQuery = true
const algoliaIndex = query ? searchIndex : index
algoliaIndex
.search(query, {
facetFilters,
numericFilters,
page,
hitsPerPage: 20,
})
.then((results) => {
if (!wasMostRecentQuery) return
if (page === 0) {
setHitsByPage({
[0]: results.hits as any as Contract[],
})
} else {
setHitsByPage((hitsByPage) => ({
...hitsByPage,
[page]: results.hits,
}))
}
setNumPages(results.nbPages)
})
return () => {
wasMostRecentQuery = false
}
// Note numeric filters are unique based on current time, so can't compare
// them by value.
}, [query, page, index, searchIndex, JSON.stringify(facetFilters), filter])
const loadMore = () => {
if (page >= numPages - 1) return
const haveLoadedCurrentPage = hitsByPage[page]
if (haveLoadedCurrentPage) setPage(page + 1)
}
const hits = range(0, page + 1)
.map((p) => hitsByPage[p] ?? [])
.flat()
const contracts = hits.filter(
(c) => !additionalFilter?.excludeContractIds?.includes(c.id)
)
const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : undefined
const updateQuery = (newQuery: string) => {
setQuery(newQuery)
setPage(0)
}
const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return
setFilter(newFilter)
setPage(0)
trackCallback('select search filter', { filter: newFilter })
}
const selectSort = (newSort: Sort) => {
if (newSort === sort) return
setPage(0)
setSort(newSort)
track('select sort', { sort: newSort })
}
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return (
@ -190,44 +256,40 @@ export function ContractSearch(props: {
}
return (
<InstantSearch searchClient={searchClient} indexName={indexName}>
<Col>
<Row className="gap-1 sm:gap-2">
<SearchBox
className="flex-1"
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
classNames={{
form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
resetIcon: 'mt-2 hidden sm:flex',
}}
<input
type="text"
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
className="input input-bordered w-full"
/>
{/*// TODO track WHICH filter users are using*/}
{!query && (
<select
className="!select !select-bordered"
className="select select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
onBlur={trackCallback('select search filter', { filter })}
onChange={(e) => selectFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
onBlur={trackCallback('select search sort', { sort })}
/>
)}
<Configure
facetFilters={filters}
numericFilters={numericFilters}
// Page resets on filters change.
page={0}
/>
{!hideOrderSelector && !query && (
<select
className="select select-bordered"
value={sort}
onChange={(e) => selectSort(e.target.value as Sort)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</Row>
<Spacer h={3} />
@ -237,14 +299,14 @@ export function ContractSearch(props: {
<PillButton
key={'all'}
selected={pillFilter === undefined}
onSelect={selectFilter(undefined)}
onSelect={selectPill(undefined)}
>
All
</PillButton>
<PillButton
key={'personal'}
selected={pillFilter === 'personal'}
onSelect={selectFilter('personal')}
onSelect={selectPill('personal')}
>
{user ? 'For you' : 'Featured'}
</PillButton>
@ -253,7 +315,7 @@ export function ContractSearch(props: {
<PillButton
key={'your-bets'}
selected={pillFilter === 'your-bets'}
onSelect={selectFilter('your-bets')}
onSelect={selectPill('your-bets')}
>
Your bets
</PillButton>
@ -264,7 +326,7 @@ export function ContractSearch(props: {
<PillButton
key={slug}
selected={pillFilter === slug}
onSelect={selectFilter(slug)}
onSelect={selectPill(slug)}
>
{name}
</PillButton>
@ -280,103 +342,17 @@ export function ContractSearch(props: {
memberGroupSlugs.length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractSearchInner
querySortOptions={querySortOptions}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
excludeContractIds={additionalFilter?.excludeContractIds}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/>
)}
</InstantSearch>
)
}
export function ContractSearchInner(props: {
querySortOptions?: {
defaultSort: Sort
shouldLoadFromStorage?: boolean
}
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
excludeContractIds?: string[]
highlightOptions?: ContractHighlightOptions
cardHideOptions?: {
hideQuickBet?: boolean
hideGroupLink?: boolean
}
}) {
const {
querySortOptions,
onContractClick,
overrideGridClassName,
cardHideOptions,
excludeContractIds,
highlightOptions,
} = props
const { initialQuery } = useInitialQueryAndSort(querySortOptions)
const { query, setQuery, setSort } = useUpdateQueryAndSort({
shouldLoadFromStorage: true,
})
useEffect(() => {
setQuery(initialQuery)
}, [initialQuery])
const { currentRefinement: index } = useSortBy({
items: [],
})
useEffect(() => {
setQuery(query)
}, [query])
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
const sort = index.split('contracts-')[1] as Sort
if (sort) {
setSort(sort)
}
}, [index])
const [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => {
const id = setTimeout(() => setIsInitialLoad(false), 1000)
return () => clearTimeout(id)
}, [])
const { showMore, hits, isLastPage } = useInfiniteHits()
let contracts = hits as any as Contract[]
if (isInitialLoad && contracts.length === 0) return <></>
const showTime = index.endsWith('close-date')
? 'close-date'
: index.endsWith('resolve-date')
? 'resolve-date'
: undefined
if (excludeContractIds)
contracts = contracts.filter((c) => !excludeContractIds.includes(c.id))
return (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
loadMore={loadMore}
hasMore={true}
showTime={showTime}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
highlightOptions={highlightOptions}
cardHideOptions={cardHideOptions}
/>
)}
</Col>
)
}

View File

@ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
import { useUser } from 'web/hooks/use-user'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { getMappedValue } from 'common/pseudo-numeric'
export function ContractCard(props: {
contract: Contract
@ -115,7 +115,8 @@ export function ContractCard(props: {
{question}
</p>
{outcomeType === 'FREE_RESPONSE' &&
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
(resolution ? (
<FreeResponseOutcomeLabel
contract={contract}
@ -158,7 +159,8 @@ export function ContractCard(props: {
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract}
@ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
}
function FreeResponseTopAnswer(props: {
contract: FreeResponseContract
contract: FreeResponseContract | MultipleChoiceContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
@ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
const { resolution, resolutionValue, resolutionProbability } = contract
const textColor = `text-blue-400`
const value = resolution
? resolutionValue
? resolutionValue
: getMappedValue(contract)(resolutionProbability ?? 0)
: getMappedValue(contract)(getProbability(contract))
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">
{resolutionValue
? formatLargeNumber(resolutionValue)
: formatNumericProbability(
resolutionProbability ?? 0,
contract
)}
<div
className={clsx('tooltip', textColor)}
data-tip={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatNumericProbability(getProbability(contract), contract)}
<div
className={clsx('tooltip text-3xl', textColor)}
data-tip={value.toFixed(2)}
>
{formatLargeNumber(value)}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>

View File

@ -23,6 +23,9 @@ export function ContractTabs(props: {
const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id)
const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
)
// Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id)
@ -99,7 +102,7 @@ export function ContractTabs(props: {
content: commentActivity,
badge: `${comments.length}`,
},
{ title: 'Bets', content: betActivity, badge: `${bets.length}` },
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
...(!user || !userBets?.length
? []
: [{ title: 'Your bets', content: yourTrades }]),

View File

@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
params.initValue = getMappedValue(contract)(contract.initialProbability)
}
if (contract.groupLinks && contract.groupLinks.length > 0) {
params.groupId = contract.groupLinks[0].groupId
}
return (
`/create?` +
Object.entries(params)

View File

@ -7,6 +7,7 @@ import { Button } from 'web/components/button'
import { GroupSelector } from 'web/components/groups/group-selector'
import {
addContractToGroup,
canModifyGroupContracts,
removeContractFromGroup,
} from 'web/lib/firebase/groups'
import { User } from 'common/user'
@ -57,11 +58,11 @@ export function ContractGroupsList(props: {
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
</Row>
{user && group.memberIds.includes(user.id) && (
{user && canModifyGroupContracts(group, user.id) && (
<Button
color={'gray-white'}
size={'xs'}
onClick={() => removeContractFromGroup(group, contract)}
onClick={() => removeContractFromGroup(group, contract, user.id)}
>
<XIcon className="h-4 w-4 text-gray-500" />
</Button>

View File

@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
const newGroup = {
name: groupName,
memberIds: memberUsers.map((user) => user.id),
anyoneCanJoin: false,
anyoneCanJoin: true,
}
const result = await createGroup(newGroup).catch((e) => {
const errorDetails = e.details[0]

View File

@ -1,6 +1,6 @@
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import { User } from 'common/user'
import { PrivateUser, User } from 'common/user'
import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
@ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash'
import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications'
export function GroupChat(props: {
messages: Comment[]
@ -44,6 +47,13 @@ export function GroupChat(props: {
const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id)
const { width, height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
// Subtract bottom bar when it's showing (less than lg screen)
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
const remainingHeight =
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
useMemo(() => {
// Group messages with createdTime within 2 minutes of each other.
const tempMessages = []
@ -70,9 +80,10 @@ export function GroupChat(props: {
}, [scrollToMessageRef])
useEffect(() => {
if (!isSubmitting)
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
}, [scrollToBottomRef, isSubmitting])
if (scrollToBottomRef)
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
// Must also listen to groupedMessages as they update the height of the messaging window
}, [scrollToBottomRef, groupedMessages])
useEffect(() => {
const elementInUrl = router.asPath.split('#')[1]
@ -81,6 +92,11 @@ export function GroupChat(props: {
}
}, [messages, router.asPath])
useEffect(() => {
// is mobile?
if (inputRef && width && width > 720) inputRef.focus()
}, [inputRef, width])
function onReplyClick(comment: Comment) {
setReplyToUsername(comment.userUsername)
}
@ -98,18 +114,6 @@ export function GroupChat(props: {
setReplyToUsername('')
inputRef?.focus()
}
function focusInput() {
inputRef?.focus()
}
const { width, height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
// Subtract bottom bar when it's showing (less than lg screen)
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
const remainingHeight =
(height ?? window.innerHeight) -
(containerRef?.offsetTop ?? 0) -
bottomBarHeight
return (
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
@ -140,7 +144,7 @@ export function GroupChat(props: {
No messages yet. Why not{isMember ? ` ` : ' join and '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={() => focusInput()}
onClick={() => inputRef?.focus()}
>
add one?
</button>
@ -175,6 +179,117 @@ export function GroupChat(props: {
)
}
export function GroupChatInBubble(props: {
messages: Comment[]
user: User | null | undefined
privateUser: PrivateUser | null | undefined
group: Group
tips: CommentTipMap
}) {
const { messages, user, group, tips, privateUser } = props
const [shouldShowChat, setShouldShowChat] = useState(false)
const router = useRouter()
useEffect(() => {
const groupsWithChatEmphasis = [
'welcome',
'bugs',
'manifold-features-25bad7c7792e',
'updates',
]
if (
router.asPath.includes('/chat') ||
groupsWithChatEmphasis.includes(
router.asPath.split('/group/')[1].split('/')[0]
)
) {
setShouldShowChat(true)
}
// Leave chat open between groups if user is using chat?
else {
setShouldShowChat(false)
}
}, [router.asPath])
return (
<Col
className={clsx(
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
)}
>
{shouldShowChat && (
<GroupChat messages={messages} user={user} group={group} tips={tips} />
)}
<button
type="button"
className={clsx(
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
' border-transparent p-3 text-white shadow-sm lg:p-4' +
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
' bottom-[70px] ',
shouldShowChat
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
)}
onClick={() => {
// router.push('/chat')
setShouldShowChat(!shouldShowChat)
track('mobile group chat button')
}}
>
{!shouldShowChat ? (
<ChatIcon className="h-10 w-10" aria-hidden="true" />
) : (
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={shouldShowChat}
/>
)}
</button>
</Col>
)
}
function GroupChatNotificationsIcon(props: {
group: Group
privateUser: PrivateUser
shouldSetAsSeen: boolean
}) {
const { privateUser, group, shouldSetAsSeen } = props
const preferredNotificationsForThisGroup = useUnseenPreferredNotifications(
privateUser,
{
customHref: `/group/${group.slug}`,
}
)
useEffect(() => {
preferredNotificationsForThisGroup.forEach((notification) => {
if (
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
// old style chat notif that simply ended with the group slug
notification.isSeenOnHref?.endsWith(group.slug)
) {
setNotificationsAsSeen([notification])
}
})
}, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen])
return (
<div
className={
preferredNotificationsForThisGroup.length > 0 && !shouldSetAsSeen
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
: 'hidden'
}
></div>
)
}
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comment: Comment

View File

@ -9,7 +9,7 @@ import {
import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button'
import { useState } from 'react'
import { useMemberGroups } from 'web/hooks/use-group'
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
@ -27,10 +27,15 @@ export function GroupSelector(props: {
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('')
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
(group) => !ignoreGroupIds?.includes(group.id)
const openGroups = useOpenGroups()
const availableGroups = openGroups
.concat(
(useMemberGroups(creator?.id) ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
const filteredGroups = memberGroups.filter((group) =>
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name)
)

View File

@ -1,15 +1,18 @@
import { useState } from 'react'
import clsx from 'clsx'
import { QrcodeIcon } from '@heroicons/react/outline'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Claim, Manalink } from 'common/manalink'
import { useState } from 'react'
import { ShareIconButton } from './share-icon-button'
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
import { useUserById } from 'web/hooks/use-user'
import getManalinkUrl from 'web/get-manalink-url'
export type ManalinkInfo = {
expiresTime: number | null
maxUses: number | null
@ -78,7 +81,9 @@ export function ManalinkCardFromView(props: {
const { className, link, highlightedSlug } = props
const { message, amount, expiresTime, maxUses, claims } = link
const [showDetails, setShowDetails] = useState(false)
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
link.slug
)}`
return (
<Col>
<Col
@ -127,6 +132,14 @@ export function ManalinkCardFromView(props: {
>
{formatMoney(amount)}
</div>
<button
onClick={() => (window.location.href = qrUrl)}
className={clsx(contractDetailsButtonClassName)}
>
<QrcodeIcon className="h-6 w-6" />
</button>
<ShareIconButton
toastClassName={'-left-48 min-w-[250%]'}
buttonClassName={'transition-colors'}

View File

@ -12,6 +12,7 @@ import dayjs from 'dayjs'
import { Button } from '../button'
import { getManalinkUrl } from 'web/pages/links'
import { DuplicateIcon } from '@heroicons/react/outline'
import { QRCode } from '../qr-code'
export function CreateLinksButton(props: {
user: User
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
})
}
const url = getManalinkUrl(highlightedSlug)
return (
<>
{!finishedCreating && (
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
)}
>
<div className="w-full select-text truncate">
{getManalinkUrl(highlightedSlug)}
</div>
<div className="w-full select-text truncate">{url}</div>
<DuplicateIcon
onClick={() => {
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug))
navigator.clipboard.writeText(url)
setCopyPressed(true)
}}
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
/>
</Row>
<QRCode url={url} className="self-center" />
</>
)}
</>

View File

@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useEffect, useState } from 'react'
import React, { useMemo, useState } 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'
@ -27,7 +27,6 @@ import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { PrivateUser } from 'common/user'
import { useWindowSize } from 'web/hooks/use-window-size'
@ -216,7 +215,7 @@ export default function Sidebar(props: { className?: string }) {
) ?? []
).map((group: Group) => ({
name: group.name,
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
href: `${groupPath(group.slug)}`,
}))
return (
@ -294,30 +293,22 @@ function GroupsList(props: {
memberItems.length > 0 ? memberItems.length : undefined
)
// Set notification as seen if our current page is equal to the isSeenOnHref property
useEffect(() => {
const currentPageWithoutQuery = currentPage.split('?')[0]
const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2]
preferredNotifications.forEach((notification) => {
if (
notification.isSeenOnHref === currentPage ||
// Old chat style group chat notif was just /group/slug
(notification.isSeenOnHref &&
currentPageWithoutQuery.includes(notification.isSeenOnHref)) ||
// They're on the home page, so if they've a chat notif, they're seeing the chat
(notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) &&
currentPageWithoutQuery.endsWith(currentPageGroupSlug))
) {
setNotificationsAsSeen([notification])
}
})
}, [currentPage, preferredNotifications])
const { height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const remainingHeight =
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
const notifIsForThisItem = useMemo(
() => (itemHref: string) =>
preferredNotifications.some(
(n) =>
!n.isSeen &&
(n.isSeenOnHref === itemHref ||
n.isSeenOnHref?.replace('/chat', '') === itemHref)
),
[preferredNotifications]
)
return (
<>
<SidebarItem
@ -332,19 +323,19 @@ function GroupsList(props: {
>
{memberItems.map((item) => (
<a
key={item.href}
href={item.href}
href={
item.href +
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
}
key={item.name}
onClick={trackCallback('sidebar: ' + 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',
preferredNotifications.some(
(n) =>
!n.isSeen &&
(n.isSeenOnHref === item.href ||
n.isSeenOnHref === item.href.replace('/chat', ''))
) && 'font-bold'
notifIsForThisItem(item.href) && 'font-bold'
)}
>
<span className="truncate">{item.name}</span>
{item.name}
</a>
))}
</div>

View File

@ -0,0 +1,173 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { updateUser } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Title } from '../title'
export default function Welcome() {
const user = useUser()
const [open, setOpen] = useState(true)
const [page, setPage] = useState(0)
const TOTAL_PAGES = 4
function increasePage() {
if (page < TOTAL_PAGES - 1) {
setPage(page + 1)
}
}
function decreasePage() {
if (page > 0) {
setPage(page - 1)
}
}
async function setUserHasSeenWelcome() {
if (user) {
await updateUser(user.id, { ['shouldShowWelcome']: false })
}
}
if (!user || !user.shouldShowWelcome) {
return <></>
} else
return (
<Modal
open={open}
setOpen={(newOpen) => {
setUserHasSeenWelcome()
setOpen(newOpen)
}}
>
<Col className="h-[32rem] place-content-between rounded-md bg-white px-8 py-6 text-sm font-light md:h-[40rem] md:text-lg">
{page === 0 && <Page0 />}
{page === 1 && <Page1 />}
{page === 2 && <Page2 />}
{page === 3 && <Page3 />}
<Col>
<Row className="place-content-between">
<ChevronLeftIcon
className={clsx(
'h-10 w-10 text-gray-400 hover:text-gray-500',
page === 0 ? 'disabled invisible' : ''
)}
onClick={decreasePage}
/>
<PageIndicator page={page} totalpages={TOTAL_PAGES} />
<ChevronRightIcon
className={clsx(
'h-10 w-10 text-indigo-500 hover:text-indigo-600',
page === TOTAL_PAGES - 1 ? 'disabled invisible' : ''
)}
onClick={increasePage}
/>
</Row>
<u
className="self-center text-xs text-gray-500"
onClick={() => {
setOpen(false)
setUserHasSeenWelcome()
}}
>
I got the gist, exit welcome
</u>
</Col>
</Col>
</Modal>
)
}
function PageIndicator(props: { page: number; totalpages: number }) {
const { page, totalpages } = props
return (
<Row>
{[...Array(totalpages)].map((e, i) => (
<div
className={clsx(
'mx-1.5 my-auto h-1.5 w-1.5 rounded-full',
i === page ? 'bg-indigo-500' : 'bg-gray-300'
)}
/>
))}
</Row>
)
}
function Page0() {
return (
<>
<img
className="h-2/3 w-2/3 place-self-center object-contain"
src="/welcome/manipurple.png"
/>
<Title className="text-center" text="Welcome to Manifold Markets!" />
<p>
Manifold Markets is a place where anyone can ask a question about the
future.
</p>
<div className="mt-4">For example,</div>
<div className="mt-2 font-normal text-indigo-700">
Will Michelle Obama be the next president of the United States?
</div>
</>
)
}
function Page1() {
return (
<>
<p>
Your question becomes a prediction market that people can bet{' '}
<span className="font-normal text-indigo-700">mana (M$)</span> on.
</p>
<div className="mt-8 font-semibold">The core idea</div>
<div className="mt-2">
If people have to put their mana where their mouth is, youll get a
pretty accurate answer!
</div>
<video loop autoPlay className="my-4 h-full w-full">
<source src="/welcome/mana-example.mp4" type="video/mp4" />
Your browser does not support video
</video>
</>
)
}
function Page2() {
return (
<>
<p>
<span className="mt-4 font-normal text-indigo-700">Mana (M$)</span> is
the play money you bet with. You can also turn it into a real donation
to charity, at a 100:1 ratio.
</p>
<div className="mt-8 font-semibold">Example</div>
<p className="mt-2">
When you donate <span className="font-semibold">M$1000</span> to
Givewell, Manifold sends them{' '}
<span className="font-semibold">$10 USD</span>.
</p>
<video loop autoPlay className="my-4 h-full w-full">
<source src="/welcome/charity.mp4" type="video/mp4" />
Your browser does not support video
</video>
</>
)
}
function Page3() {
return (
<>
<img className="mx-auto object-contain" src="/welcome/treasure.png" />
<Title className="mx-auto" text="Let's start predicting!" />
<p className="mb-8">
As a thank you for signing up, weve sent you{' '}
<span className="font-normal text-indigo-700">M$1000 Mana</span>{' '}
</p>
</>
)
}

View File

@ -1,4 +1,5 @@
import clsx from 'clsx'
import { Spacer } from './layout/spacer'
export function Pagination(props: {
page: number
@ -23,6 +24,8 @@ export function Pagination(props: {
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
if (maxPage === 0) return <Spacer h={4} />
return (
<nav
className={clsx(

View File

@ -0,0 +1,16 @@
export function QRCode(props: {
url: string
className?: string
width?: number
height?: number
}) {
const { url, className, width, height } = {
width: 200,
height: 200,
...props,
}
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${width}x${height}&data=${url}`
return <img src={qrUrl} width={width} height={height} className={className} />
}

View File

@ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) {
return (
<h1
className={clsx(
'my-4 inline-block text-2xl text-indigo-700 sm:my-6 sm:text-3xl',
'my-4 inline-block text-2xl font-normal text-indigo-700 sm:my-6 sm:text-3xl',
className
)}
>

View File

@ -214,6 +214,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
<Row className="gap-4">
<FollowingButton user={user} />
<FollowersButton user={user} />
{currentUser &&
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
currentUser.username
) && <ReferralsButton user={user} />}
<GroupsButton user={user} />
</Row>

View File

@ -5,6 +5,7 @@ import {
listenForGroup,
listenForGroups,
listenForMemberGroups,
listenForOpenGroups,
listGroups,
} from 'web/lib/firebase/groups'
import { getUser, getUsers } from 'web/lib/firebase/users'
@ -32,6 +33,16 @@ export const useGroups = () => {
return groups
}
export const useOpenGroups = () => {
const [groups, setGroups] = useState<Group[]>([])
useEffect(() => {
return listenForOpenGroups(setGroups)
}, [])
return groups
}
export const useMemberGroups = (
userId: string | null | undefined,
options?: { withChatEnabled: boolean },

View File

@ -18,10 +18,14 @@ export const useSaveReferral = (
referrer?: string
}
const actualReferrer = referrer || options?.defaultReferrer
const referrerOrDefault = referrer || options?.defaultReferrer
if (!user && router.isReady && actualReferrer) {
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
if (!user && router.isReady && referrerOrDefault) {
writeReferralInfo(referrerOrDefault, {
contractId: options?.contractId,
overwriteReferralUsername: referrer,
groupId: options?.groupId,
})
}
}, [user, router, options])
}

View File

@ -1,8 +1,6 @@
import { defaults, debounce } from 'lodash'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { useSearchBox } from 'react-instantsearch-hooks-web'
import { track } from 'web/lib/service/analytics'
import { DEFAULT_SORT } from 'web/components/contract-search'
const MARKETS_SORT = 'markets_sort'
@ -74,15 +72,20 @@ export function useInitialQueryAndSort(options?: {
}
}
export function useUpdateQueryAndSort(props: {
shouldLoadFromStorage: boolean
export function useQueryAndSortParams(options?: {
defaultSort?: Sort
shouldLoadFromStorage?: boolean
}) {
const { shouldLoadFromStorage } = props
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
options ?? {}
const router = useRouter()
const { s: sort, q: query } = router.query as {
q?: string
s?: Sort
}
const setSort = (sort: Sort | undefined) => {
if (sort !== router.query.s) {
router.query.s = sort
router.replace({ query: { ...router.query, s: sort } }, undefined, {
shallow: true,
})
@ -90,35 +93,50 @@ export function useUpdateQueryAndSort(props: {
localStorage.setItem(MARKETS_SORT, sort || '')
}
}
}
const { query, refine } = useSearchBox()
const [queryState, setQueryState] = useState(query)
useEffect(() => {
setQueryState(query)
}, [query])
// Debounce router query update.
const pushQuery = useMemo(
() =>
debounce((query: string | undefined) => {
if (query) {
router.query.q = query
} else {
delete router.query.q
}
router.replace({ query: router.query }, undefined, {
const queryObj = { ...router.query, q: query }
if (!query) delete queryObj.q
router.replace({ query: queryObj }, undefined, {
shallow: true,
})
track('search', { query })
}, 500),
}, 100),
[router]
)
const setQuery = (query: string | undefined) => {
refine(query ?? '')
setQueryState(query)
pushQuery(query)
}
useEffect(() => {
// If there's no sort option, then set the one from localstorage
if (router.isReady && !sort && shouldLoadFromStorage) {
const localSort = localStorage.getItem(MARKETS_SORT) as Sort
if (localSort && localSort !== defaultSort) {
// Use replace to not break navigating back.
router.replace(
{ query: { ...router.query, s: localSort } },
undefined,
{ shallow: true }
)
}
}
})
return {
sort: sort ?? defaultSort,
query: queryState ?? '',
setSort,
setQuery,
query,
}
}

View File

@ -80,3 +80,7 @@ export function claimManalink(params: any) {
export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}
export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
}

View File

@ -81,6 +81,7 @@ export async function createCommentOnGroup(
function getCommentsCollection(contractId: string) {
return collection(db, 'contracts', contractId, 'comments')
}
function getCommentsOnGroupCollection(groupId: string) {
return collection(db, 'groups', groupId, 'comments')
}
@ -91,6 +92,14 @@ export async function listAllComments(contractId: string) {
return comments
}
export async function listAllCommentsOnGroup(groupId: string) {
const comments = await getValues<Comment>(
getCommentsOnGroupCollection(groupId)
)
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
return comments
}
export function listenForCommentsOnContract(
contractId: string,
setComments: (comments: Comment[]) => void

View File

@ -8,7 +8,6 @@ import {
} from 'firebase/firestore'
import { sortBy, uniq } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import { updateContract } from './contracts'
import {
coll,
getValue,
@ -17,6 +16,7 @@ import {
listenForValues,
} from './utils'
import { Contract } from 'common/contract'
import { updateContract } from 'web/lib/firebase/contracts'
export const groups = coll<Group>('groups')
@ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groups, setGroups)
}
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(
query(groups, where('anyoneCanJoin', '==', true)),
setGroups
)
}
export function getGroup(groupId: string) {
return getValue<Group>(doc(groups, groupId))
}
@ -129,7 +136,7 @@ export async function addContractToGroup(
contract: Contract,
userId: string
) {
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
if (!canModifyGroupContracts(group, userId)) return
const newGroupLinks = [
...(contract.groupLinks ?? []),
{
@ -140,12 +147,12 @@ export async function addContractToGroup(
name: group.name,
} as GroupLink,
]
// It's good to update the contract first, so the on-update-group trigger doesn't re-add them
await updateContract(contract.id, {
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
if (!group.contractIds.includes(contract.id)) {
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]),
@ -160,8 +167,11 @@ export async function addContractToGroup(
export async function removeContractFromGroup(
group: Group,
contract: Contract
contract: Contract,
userId: string
) {
if (!canModifyGroupContracts(group, userId)) return
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
@ -186,29 +196,10 @@ export async function removeContractFromGroup(
}
}
export async function setContractGroupLinks(
group: Group,
contractId: string,
userId: string
) {
await updateContract(contractId, {
groupSlugs: [group.slug],
groupLinks: [
{
groupId: group.id,
name: group.name,
slug: group.slug,
userId,
createdTime: Date.now(),
} as GroupLink,
],
})
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contractId]),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
export function canModifyGroupContracts(group: Group, userId: string) {
return (
group.creatorId === userId ||
group.memberIds.includes(userId) ||
group.anyoneCanJoin
)
}

View File

@ -96,22 +96,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
export function writeReferralInfo(
defaultReferrerUsername: string,
contractId?: string,
referralUsername?: string,
otherOptions?: {
contractId?: string
overwriteReferralUsername?: string
groupId?: string
}
) {
const local = safeLocalStorage()
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
const { contractId, overwriteReferralUsername, groupId } = otherOptions || {}
// Write the first referral username we see.
if (!cachedReferralUser)
local?.setItem(
CACHED_REFERRAL_USERNAME_KEY,
referralUsername || defaultReferrerUsername
overwriteReferralUsername || defaultReferrerUsername
)
// If an explicit referral query is passed, overwrite the cached referral username.
if (referralUsername)
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
if (overwriteReferralUsername)
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername)
// Always write the most recent explicit group invite query value
if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)

View File

@ -6,6 +6,7 @@ import Script from 'next/script'
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from 'web/components/auth-context'
import Welcome from 'web/components/onboarding/welcome'
function firstLine(msg: string) {
return msg.replace(/\r?\n.*/s, '')
@ -78,9 +79,9 @@ function MyApp({ Component, pageProps }: AppProps) {
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<Welcome {...pageProps} />
<Component {...pageProps} />
</QueryClientProvider>
</AuthProvider>

View File

@ -0,0 +1,18 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { getGroupBySlug } from 'web/lib/firebase/groups'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { slug } = req.query
const group = await getGroupBySlug(slug as string)
if (!group) {
res.status(404).json({ error: 'Group not found' })
return
}
res.setHeader('Cache-Control', 'no-cache')
return res.status(200).json(group)
}

View File

@ -0,0 +1,18 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { getGroup } from 'web/lib/firebase/groups'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { id } = req.query
const group = await getGroup(id as string)
if (!group) {
res.status(404).json({ error: 'Group not found' })
return
}
res.setHeader('Cache-Control', 'no-cache')
return res.status(200).json(group)
}

View File

@ -0,0 +1,15 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { listAllGroups } from 'web/lib/firebase/groups'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
type Data = any[]
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const groups = await listAllGroups()
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups)
}

View File

@ -0,0 +1,23 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { ApiError, toLiteMarket, LiteMarket } from '../../_types'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<LiteMarket | ApiError>
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { id } = req.query
const contractId = id as string
const contract = await getContractFromId(contractId)
if (!contract) {
res.status(404).json({ error: 'Contract not found' })
return
}
res.setHeader('Cache-Control', 'max-age=0')
return res.status(200).json(toLiteMarket(contract))
}

16
web/pages/api/v0/me.ts Normal file
View File

@ -0,0 +1,16 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { fetchBackend, forwardResponse } from 'web/lib/api/proxy'
import { LiteUser, ApiError } from './_types'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<LiteUser | ApiError>
) {
try {
const backendRes = await fetchBackend(req, 'getcurrentuser')
await forwardResponse(res, backendRes)
} catch (err) {
console.error('Error talking to cloud function: ', err)
res.status(500).json({ error: 'Error communicating with backend.' })
}
}

View File

@ -102,7 +102,7 @@ export default function ContractSearchFirestore(props: {
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="score">Most popular</option>
<option value="score">Trending</option>
<option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option>

View File

@ -19,7 +19,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { getGroup, setContractGroupLinks } from 'web/lib/firebase/groups'
import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -122,7 +122,7 @@ export function NewContract(props: {
useEffect(() => {
if (groupId && creator)
getGroup(groupId).then((group) => {
if (group && group.memberIds.includes(creator.id)) {
if (group && canModifyGroupContracts(group, creator.id)) {
setSelectedGroup(group)
setShowGroupSelector(false)
}
@ -239,10 +239,6 @@ export function NewContract(props: {
selectedGroup: selectedGroup?.id,
isFree: false,
})
if (result && selectedGroup) {
await setContractGroupLinks(selectedGroup, result.id, creator.id)
}
await router.push(contractPath(result as Contract))
} catch (e) {
console.error('error creating contract', e, (e as any).details)
@ -477,6 +473,8 @@ export function NewContract(props: {
{isSubmitting ? 'Creating...' : 'Create question'}
</button>
</Row>
<Spacer h={6} />
</div>
)
}

View File

@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring'
@ -30,7 +30,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { Tabs } from 'web/components/layout/tabs'
import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useState } from 'react'
import { GroupChat } from 'web/components/groups/group-chat'
import { GroupChatInBubble } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
@ -45,11 +45,12 @@ import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { useWindowSize } from 'web/hooks/use-window-size'
import { CopyLinkButton } from 'web/components/copy-link-button'
import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { Comment } from 'common/comment'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -65,6 +66,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const bets = await Promise.all(
contracts.map((contract: Contract) => listAllBets(contract.id))
)
const messages = group && (await listAllCommentsOnGroup(group.id))
const creatorScores = scoreCreators(contracts)
const traderScores = scoreTraders(contracts, bets)
@ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
topTraders,
creatorScores,
topCreators,
messages,
},
revalidate: 60, // regenerate after a minute
@ -123,6 +126,7 @@ export default function GroupPage(props: {
topTraders: User[]
creatorScores: { [userId: string]: number }
topCreators: User[]
messages: Comment[]
}) {
props = usePropz(props, getStaticPropz) ?? {
group: null,
@ -132,6 +136,7 @@ export default function GroupPage(props: {
topTraders: [],
creatorScores: {},
topCreators: [],
messages: [],
}
const {
creator,
@ -149,19 +154,18 @@ export default function GroupPage(props: {
const group = useGroup(props.group?.id) ?? props.group
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id)
const messages = useCommentsOnGroup(group?.id) ?? props.messages
const user = useUser()
const privateUser = usePrivateUser(user?.id)
useSaveReferral(user, {
defaultReferrer: creator.username,
groupId: group?.id,
})
const { width } = useWindowSize()
const chatDisabled = !group || group.chatDisabled
const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280
const showChatTab = !chatDisabled && !showChatSidebar
const showChatBubble = !chatDisabled
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
@ -195,16 +199,6 @@ export default function GroupPage(props: {
</Col>
)
const chatTab = (
<Col className="">
{messages ? (
<GroupChat messages={messages} user={user} group={group} tips={tips} />
) : (
<LoadingIndicator />
)}
</Col>
)
const questionsTab = (
<ContractSearch
querySortOptions={{
@ -217,15 +211,6 @@ export default function GroupPage(props: {
)
const tabs = [
...(!showChatTab
? []
: [
{
title: 'Chat',
content: chatTab,
href: groupPath(group.slug, GROUP_CHAT_SLUG),
},
]),
{
title: 'Markets',
content: questionsTab,
@ -242,20 +227,17 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'about'),
},
]
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
return (
<Page
rightSidebar={showChatSidebar ? chatTab : undefined}
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
>
<Page>
<SEO
title={group.name}
description={`Created by ${creator.name}. ${group.about}`}
url={groupPath(group.slug)}
/>
<Col className="px-3">
<Col className="relative px-3">
<Row className={'items-center justify-between gap-4'}>
<div className={'sm:mb-1'}>
<div
@ -282,6 +264,15 @@ export default function GroupPage(props: {
defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs}
/>
{showChatBubble && (
<GroupChatInBubble
group={group}
user={user}
privateUser={privateUser}
tips={tips}
messages={messages}
/>
)}
</Page>
)
}

View File

@ -81,11 +81,18 @@ const useContractPage = () => {
if (!username || !contractSlug) setContract(undefined)
else {
// Show contract if route is to a contract: '/[username]/[contractSlug]'.
getContractFromSlug(contractSlug).then(setContract)
getContractFromSlug(contractSlug).then((contract) => {
const path = location.pathname.split('/').slice(1)
const [_username, contractSlug] = path
// Make sure we're still on the same contract.
if (contract?.slug === contractSlug) setContract(contract)
})
}
}
}
addEventListener('popstate', updateContract)
const { pushState, replaceState } = window.history
window.history.pushState = function () {
@ -101,6 +108,7 @@ const useContractPage = () => {
}
return () => {
removeEventListener('popstate', updateContract)
window.history.pushState = pushState
window.history.replaceState = replaceState
}

View File

@ -1,14 +1,17 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { claimManalink } from 'web/lib/firebase/api'
import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin } from 'web/lib/firebase/users'
import { firebaseLogin, getUser } from 'web/lib/firebase/users'
import { Row } from 'web/components/layout/row'
import { Button } from 'web/components/button'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { Manalink } from 'common/manalink'
export default function ClaimPage() {
const user = useUser()
@ -18,6 +21,8 @@ export default function ClaimPage() {
const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
useReferral(user, manalink)
if (!manalink) {
return <></>
}
@ -33,7 +38,19 @@ export default function ClaimPage() {
<div className="mx-auto max-w-xl px-2">
<Row className="items-center justify-between">
<Title text={`Claim M$${manalink.amount} mana`} />
<div className="my-auto">
<div className="my-auto"></div>
</Row>
<ManalinkCard info={info} />
{error && (
<section className="my-5 text-red-500">
<p>Failed to claim manalink.</p>
<p>{error}</p>
</section>
)}
<Row className="items-center">
<Button
onClick={async () => {
setClaiming(true)
@ -51,9 +68,7 @@ export default function ClaimPage() {
} catch (e) {
console.log(e)
const message =
e && e instanceof Object
? e.toString()
: 'An error occurred.'
e && e instanceof Object ? e.toString() : 'An error occurred.'
setError(message)
}
setClaiming(false)
@ -61,18 +76,20 @@ export default function ClaimPage() {
disabled={claiming}
size="lg"
>
{user ? 'Claim' : 'Login'}
{user ? `Claim M$${manalink.amount}` : 'Login to claim'}
</Button>
</div>
</Row>
<ManalinkCard info={info} />
{error && (
<section className="my-5 text-red-500">
<p>Failed to claim manalink.</p>
<p>{error}</p>
</section>
)}
</div>
</>
)
}
const useReferral = (user: User | undefined | null, manalink?: Manalink) => {
const [creator, setCreator] = useState<User | undefined>(undefined)
useEffect(() => {
if (manalink?.fromId) getUser(manalink.fromId).then(setCreator)
}, [manalink])
useSaveReferral(user, { defaultReferrer: creator?.username })
}

View File

@ -1,4 +1,9 @@
import { useState } from 'react'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
@ -16,12 +21,10 @@ import { UserLink } from 'web/components/user-page'
import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import { ManalinkCardFromView } from 'web/components/manalink-card'
import { Pagination } from 'web/components/pagination'
import { Manalink } from 'common/manalink'
dayjs.extend(customParseFormat)
import { REFERRAL_AMOUNT } from 'common/user'
const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/')
@ -64,8 +67,10 @@ export default function LinkPage() {
)}
</Row>
<p>
You can use manalinks to send mana to other people, even if they
don&apos;t yet have a Manifold account.
You can use manalinks to send mana (M$) to other people, even if they
don&apos;t yet have a Manifold account. Manalinks are also eligible
for the referral bonus. Invite a new user to Manifold and get M$
{REFERRAL_AMOUNT} if they sign up!
</p>
<Subtitle text="Your Manalinks" />
<ManalinksDisplay

BIN
web/public/welcome/charity.mp4 Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -18,6 +18,15 @@ module.exports = {
backgroundImage: {
'world-trading': "url('/world-trading-background.webp')",
},
colors: {
'greyscale-1': '#FBFBFF',
'greyscale-2': '#E7E7F4',
'greyscale-3': '#D8D8EB',
'greyscale-4': '#B1B1C7',
'greyscale-5': '#9191A7',
'greyscale-6': '#66667C',
'greyscale-7': '#111140',
},
typography: {
quoteless: {
css: {

View File

@ -5144,6 +5144,11 @@ dayjs@1.10.7:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
dayjs@1.11.4:
version "1.11.4"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e"
integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g==
debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"