Merge branch 'main' of https://github.com/marsteralex/manifold
This commit is contained in:
commit
b98a6caee3
|
@ -40,12 +40,14 @@ export type User = {
|
||||||
referredByContractId?: string
|
referredByContractId?: string
|
||||||
referredByGroupId?: string
|
referredByGroupId?: string
|
||||||
lastPingTime?: number
|
lastPingTime?: number
|
||||||
|
shouldShowWelcome?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000
|
||||||
// for sus users, i.e. multiple sign ups for same person
|
// for sus users, i.e. multiple sign ups for same person
|
||||||
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10
|
||||||
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
id: string // same as User.id
|
id: string // same as User.id
|
||||||
username: string // denormalized from User
|
username: string // denormalized from User
|
||||||
|
@ -55,6 +57,7 @@ export type PrivateUser = {
|
||||||
unsubscribedFromCommentEmails?: boolean
|
unsubscribedFromCommentEmails?: boolean
|
||||||
unsubscribedFromAnswerEmails?: boolean
|
unsubscribedFromAnswerEmails?: boolean
|
||||||
unsubscribedFromGenericEmails?: boolean
|
unsubscribedFromGenericEmails?: boolean
|
||||||
|
manaBonusEmailSent?: boolean
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
|
|
115
docs/docs/api.md
115
docs/docs/api.md
|
@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use
|
||||||
|
|
||||||
Requires no authorization.
|
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`
|
### `GET /v0/markets`
|
||||||
|
|
||||||
Lists all markets, ordered by creation date descending.
|
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,
|
answer. For numeric markets, this is a string representing the target bucket,
|
||||||
and an additional `value` parameter is required which is a number representing
|
and an additional `value` parameter is required which is a number representing
|
||||||
the target value. (Bet on numeric markets at your own peril.)
|
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:
|
Example request:
|
||||||
|
|
||||||
|
@ -581,12 +617,12 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
|
||||||
|
|
||||||
### `POST /v0/market/[marketId]/sell`
|
### `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:
|
Parameters:
|
||||||
|
|
||||||
- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric
|
- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only
|
||||||
bucket ID, depending on the market type.
|
own one kind of shares, you will sell that kind of shares.
|
||||||
- `shares`: Optional. The amount of shares to sell of the outcome given
|
- `shares`: Optional. The amount of shares to sell of the outcome given
|
||||||
above. If not provided, all the shares you own will be sold.
|
above. If not provided, all the shares you own will be sold.
|
||||||
|
|
||||||
|
@ -617,7 +653,7 @@ Requires no authorization.
|
||||||
|
|
||||||
- Example request
|
- 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[]`.
|
- Response type: A `Bet[]`.
|
||||||
|
|
||||||
|
@ -625,31 +661,60 @@ Requires no authorization.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
// Limit bet, partially filled.
|
||||||
{
|
{
|
||||||
"probAfter": 0.44418877319153904,
|
"isFilled": false,
|
||||||
"shares": -645.8346334931828,
|
"amount": 15.596681605353808,
|
||||||
|
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||||
|
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||||
|
"probBefore": 0.5730753474948571,
|
||||||
|
"isCancelled": false,
|
||||||
"outcome": "YES",
|
"outcome": "YES",
|
||||||
"contractId": "tgB1XmvFXZNhjr3xMNLp",
|
"fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 },
|
||||||
"sale": {
|
"shares": 31.193363210707616,
|
||||||
"betId": "RcOtarI3d1DUUTjiE0rx",
|
"limitProb": 0.5,
|
||||||
"amount": 474.9999999999998
|
"id": "yXB8lVbs86TKkhWA1FVi",
|
||||||
},
|
"loanAmount": 0,
|
||||||
"createdTime": 1644602886293,
|
"orderAmount": 100,
|
||||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
"probAfter": 0.5730753474948571,
|
||||||
"probBefore": 0.7229189477449224,
|
"createdTime": 1659482775970,
|
||||||
"id": "x9eNmCaqQeXW8AgJ8Zmp",
|
"fills": [
|
||||||
"amount": -499.9999999999998
|
{
|
||||||
|
"timestamp": 1659483249648,
|
||||||
|
"matchedBetId": "MfrMd5HTiGASDXzqibr7",
|
||||||
|
"amount": 15.596681605353808,
|
||||||
|
"shares": 31.193363210707616
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
// Normal bet (no limitProb specified).
|
||||||
{
|
{
|
||||||
"probAfter": 0.9901970375647697,
|
"shares": 17.350459904608414,
|
||||||
"contractId": "zdeaYVAfHlo9jKzWh57J",
|
"probBefore": 0.5304358279113885,
|
||||||
"outcome": "YES",
|
"isFilled": true,
|
||||||
"amount": 1,
|
"probAfter": 0.5730753474948571,
|
||||||
"id": "8PqxKYwXCcLYoXy2m2Nm",
|
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
|
||||||
"shares": 1.0049875638533763,
|
"amount": 10,
|
||||||
"userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2",
|
"contractId": "Tz5dA01GkK5QKiQfZeDL",
|
||||||
"probBefore": 0.9900000000000001,
|
"id": "1LPJHNz5oAX4K6YtJlP1",
|
||||||
"createdTime": 1644705818872
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
@ -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
|
- [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
|
- [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
|
## API / Dev
|
||||||
|
|
||||||
|
@ -21,3 +22,4 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
## Bots
|
## Bots
|
||||||
|
|
||||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
- [@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
|
||||||
|
|
|
@ -22,7 +22,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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
|
// User referral rules
|
||||||
allow update: if resource.data.id == request.auth.uid
|
allow update: if resource.data.id == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
|
|
@ -31,6 +31,7 @@
|
||||||
"@tiptap/extension-link": "2.0.0-beta.43",
|
"@tiptap/extension-link": "2.0.0-beta.43",
|
||||||
"@tiptap/extension-mention": "2.0.0-beta.102",
|
"@tiptap/extension-mention": "2.0.0-beta.102",
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.190",
|
"@tiptap/starter-kit": "2.0.0-beta.190",
|
||||||
|
"dayjs": "1.11.4",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "4.18.1",
|
"express": "4.18.1",
|
||||||
"firebase-admin": "10.0.0",
|
"firebase-admin": "10.0.0",
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { slugify } from '../../common/util/slugify'
|
import { slugify } from '../../common/util/slugify'
|
||||||
import { randomString } from '../../common/util/random'
|
import { randomString } from '../../common/util/random'
|
||||||
|
|
||||||
import { chargeUser } from './utils'
|
import { chargeUser, getContract } from './utils'
|
||||||
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
import { APIError, newEndpoint, validate, zTimestamp } from './api'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -28,11 +28,11 @@ import { Answer, getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||||
import { User } from '../../common/user'
|
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 { getPseudoProbability } from '../../common/pseudo-numeric'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
import { zip } from 'lodash'
|
import { uniq, zip } from 'lodash'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
|
||||||
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
const descScehma: z.ZodType<JSONContent> = z.lazy(() =>
|
||||||
z.intersection(
|
z.intersection(
|
||||||
|
@ -136,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
const slug = await getSlug(question)
|
const slug = await getSlug(question)
|
||||||
const contractRef = firestore.collection('contracts').doc()
|
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(
|
console.log(
|
||||||
'creating contract for',
|
'creating contract for',
|
||||||
user.username,
|
user.username,
|
||||||
|
@ -188,6 +167,33 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
await contractRef.create(contract)
|
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
|
const providerId = user.id
|
||||||
|
|
||||||
if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') {
|
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)
|
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 ?? []),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MANIFOLD_AVATAR_URL,
|
MANIFOLD_AVATAR_URL,
|
||||||
MANIFOLD_USERNAME,
|
MANIFOLD_USERNAME,
|
||||||
|
@ -24,7 +26,6 @@ import {
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||||
import { uniq } from 'lodash'
|
|
||||||
import {
|
import {
|
||||||
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
|
||||||
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 },
|
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
|
shouldShowWelcome: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
await firestore.collection('users').doc(auth.uid).create(user)
|
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 firestore.collection('private-users').doc(auth.uid).create(privateUser)
|
||||||
|
|
||||||
await sendWelcomeEmail(user, privateUser)
|
|
||||||
await addUserToDefaultGroups(user)
|
await addUserToDefaultGroups(user)
|
||||||
|
await sendWelcomeEmail(user, privateUser)
|
||||||
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
await track(auth.uid, 'create user', { username }, { ip: req.ip })
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
File diff suppressed because one or more lines are too long
738
functions/src/email-templates/creating-market.html
Normal file
738
functions/src/email-templates/creating-market.html
Normal 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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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! </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's weekly active
|
||||||
|
users.</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
class="text-build-content"
|
||||||
|
style="line-height: 23px; margin: 10px 0"
|
||||||
|
data-testid="3Q8BP69fq"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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>
|
|
@ -165,7 +165,6 @@ export const sendWelcomeEmail = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use manalinks to give out M$500
|
|
||||||
export const sendOneWeekBonusEmail = async (
|
export const sendOneWeekBonusEmail = async (
|
||||||
user: User,
|
user: User,
|
||||||
privateUser: PrivateUser
|
privateUser: PrivateUser
|
||||||
|
@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async (
|
||||||
|
|
||||||
await sendTemplateEmail(
|
await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
'Manifold one week anniversary gift',
|
'Manifold Markets one week anniversary gift',
|
||||||
'one-week',
|
'one-week',
|
||||||
{
|
{
|
||||||
name: firstName,
|
name: firstName,
|
||||||
unsubscribeLink,
|
unsubscribeLink,
|
||||||
manalink: '', // TODO
|
manalink: 'https://manifold.markets/link/lj4JbBvE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'David from Manifold <david@manifold.markets>',
|
from: 'David from Manifold <david@manifold.markets>',
|
||||||
|
|
18
functions/src/get-current-user.ts
Normal file
18
functions/src/get-current-user.ts
Normal 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()
|
|
@ -27,6 +27,25 @@ export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
|
|
||||||
// v2
|
// 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 { health } from './health'
|
||||||
import { transact } from './transact'
|
import { transact } from './transact'
|
||||||
import { changeuserinfo } from './change-user-info'
|
import { changeuserinfo } from './change-user-info'
|
||||||
|
@ -44,6 +63,7 @@ import { creategroup } from './create-group'
|
||||||
import { resolvemarket } from './resolve-market'
|
import { resolvemarket } from './resolve-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
|
||||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||||
return onRequest(opts, handler as any)
|
return onRequest(opts, handler as any)
|
||||||
|
@ -66,6 +86,7 @@ const resolveMarketFunction = toCloudFunction(resolvemarket)
|
||||||
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
const unsubscribeFunction = toCloudFunction(unsubscribe)
|
||||||
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
|
|
||||||
export {
|
export {
|
||||||
healthFunction as health,
|
healthFunction as health,
|
||||||
|
@ -86,4 +107,5 @@ export {
|
||||||
unsubscribeFunction as unsubscribe,
|
unsubscribeFunction as unsubscribe,
|
||||||
stripeWebhookFunction as stripewebhook,
|
stripeWebhookFunction as stripewebhook,
|
||||||
createCheckoutSessionFunction as createcheckoutsession,
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
|
getCurrentUserFunction as getcurrentuser,
|
||||||
}
|
}
|
||||||
|
|
42
functions/src/mana-bonus-email.ts
Normal file
42
functions/src/mana-bonus-email.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { Group } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
|
import { getContract } from './utils'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
export const onUpdateGroup = functions.firestore
|
export const onUpdateGroup = functions.firestore
|
||||||
|
@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore
|
||||||
const prevGroup = change.before.data() as Group
|
const prevGroup = change.before.data() as Group
|
||||||
const group = change.after.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)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore
|
||||||
.doc(group.id)
|
.doc(group.id)
|
||||||
.update({ mostRecentActivityTime: Date.now() })
|
.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
|
||||||
|
) ?? []),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
groupPayoutsByUser,
|
groupPayoutsByUser,
|
||||||
Payout,
|
Payout,
|
||||||
} from '../../common/payouts'
|
} from '../../common/payouts'
|
||||||
|
import { isAdmin } from '../../common/envs/constants'
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
import { removeUndefinedProps } from '../../common/util/object'
|
||||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
|
@ -69,8 +70,6 @@ const opts = { secrets: ['MAILGUN_KEY'] }
|
||||||
|
|
||||||
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
const { contractId } = validate(bodySchema, req.body)
|
const { contractId } = validate(bodySchema, req.body)
|
||||||
const userId = auth.uid
|
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const contractSnap = await contractDoc.get()
|
const contractSnap = await contractDoc.get()
|
||||||
if (!contractSnap.exists)
|
if (!contractSnap.exists)
|
||||||
|
@ -83,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
req.body
|
req.body
|
||||||
)
|
)
|
||||||
|
|
||||||
if (creatorId !== userId)
|
if (creatorId !== auth.uid && !isAdmin(auth.uid))
|
||||||
throw new APIError(403, 'User is not creator of contract')
|
throw new APIError(403, 'User is not creator of contract')
|
||||||
|
|
||||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||||
|
|
25
functions/src/scripts/backfill-group-ids.ts
Normal file
25
functions/src/scripts/backfill-group-ids.ts
Normal 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.`)
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { sumBy, uniq } from 'lodash'
|
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getValues, log } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
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 { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
@ -17,7 +17,7 @@ import { redeemShares } from './redeem-shares'
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
shares: z.number().optional(), // leave it out to sell all shares
|
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) => {
|
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.')
|
throw new APIError(400, 'Trading is closed.')
|
||||||
|
|
||||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
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)
|
let chosenOutcome: 'YES' | 'NO'
|
||||||
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
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
|
const sharesToSell = shares ?? maxShares
|
||||||
|
|
||||||
if (!floatingLesserEqual(sharesToSell, maxShares))
|
if (!floatingLesserEqual(sharesToSell, maxShares))
|
||||||
|
@ -63,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||||
soldShares,
|
soldShares,
|
||||||
outcome,
|
chosenOutcome,
|
||||||
contract,
|
contract,
|
||||||
prevLoanAmount,
|
prevLoanAmount,
|
||||||
unfilledBets
|
unfilledBets
|
||||||
|
|
|
@ -26,9 +26,10 @@ export const sendTemplateEmail = (
|
||||||
subject: string,
|
subject: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
templateData: Record<string, 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>',
|
from: options?.from ?? 'Manifold Markets <info@manifold.markets>',
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
|
@ -36,6 +37,7 @@ export const sendTemplateEmail = (
|
||||||
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
'h:X-Mailgun-Variables': JSON.stringify(templateData),
|
||||||
}
|
}
|
||||||
const mg = initMailgun()
|
const mg = initMailgun()
|
||||||
|
|
||||||
return mg.messages().send(data, (error) => {
|
return mg.messages().send(data, (error) => {
|
||||||
if (error) console.log('Error sending email', error)
|
if (error) console.log('Error sending email', error)
|
||||||
else console.log('Sent template email', templateId, to, subject)
|
else console.log('Sent template email', templateId, to, subject)
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { creategroup } from './create-group'
|
||||||
import { resolvemarket } from './resolve-market'
|
import { resolvemarket } from './resolve-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
|
import { getcurrentuser } from './get-current-user'
|
||||||
|
|
||||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
const app = express()
|
const app = express()
|
||||||
|
@ -62,6 +63,7 @@ addJsonEndpointRoute('/creategroup', creategroup)
|
||||||
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||||
|
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
|
|
||||||
app.listen(PORT)
|
app.listen(PORT)
|
||||||
|
|
|
@ -157,9 +157,7 @@ export function BetsList(props: {
|
||||||
(c) => contractsMetrics[c.id].netPayout
|
(c) => contractsMetrics[c.id].netPayout
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalPortfolio = currentNetInvestment + user.balance
|
const totalPnl = user.profitCached.allTime
|
||||||
|
|
||||||
const totalPnl = totalPortfolio - user.totalDeposits
|
|
||||||
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
const totalProfitPercent = (totalPnl / user.totalDeposits) * 100
|
||||||
const investedProfitPercent =
|
const investedProfitPercent =
|
||||||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||||
|
@ -354,7 +352,7 @@ function ContractBets(props: {
|
||||||
<LimitOrderTable
|
<LimitOrderTable
|
||||||
contract={contract}
|
contract={contract}
|
||||||
limitBets={limitBets}
|
limitBets={limitBets}
|
||||||
isYou={true}
|
isYou={isYourBets}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -39,8 +39,10 @@ export function Button(props: {
|
||||||
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
color === 'yellow' && 'bg-yellow-400 text-white hover:bg-yellow-500',
|
||||||
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
color === 'blue' && 'bg-blue-400 text-white hover:bg-blue-500',
|
||||||
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
color === 'indigo' && 'bg-indigo-500 text-white hover:bg-indigo-600',
|
||||||
color === 'gray' && 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
color === 'gray' &&
|
||||||
color === 'gray-white' && 'bg-white text-gray-500 hover:bg-gray-200',
|
'bg-greyscale-1 text-greyscale-7 hover:bg-greyscale-2',
|
||||||
|
color === 'gray-white' &&
|
||||||
|
'text-greyscale-6 hover:bg-greyscale-2 bg-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -15,8 +15,8 @@ export function PillButton(props: {
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
'cursor-pointer select-none whitespace-nowrap rounded-full',
|
||||||
selected
|
selected
|
||||||
? ['text-white', color ?? 'bg-gray-700']
|
? ['text-white', color ?? 'bg-greyscale-6']
|
||||||
: 'bg-gray-100 hover:bg-gray-200',
|
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
|
|
@ -1,26 +1,14 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import algoliasearch from 'algoliasearch/lite'
|
import algoliasearch from 'algoliasearch/lite'
|
||||||
import {
|
|
||||||
Configure,
|
|
||||||
InstantSearch,
|
|
||||||
SearchBox,
|
|
||||||
SortBy,
|
|
||||||
useInfiniteHits,
|
|
||||||
useSortBy,
|
|
||||||
} from 'react-instantsearch-hooks-web'
|
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import {
|
import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params'
|
||||||
Sort,
|
|
||||||
useInitialQueryAndSort,
|
|
||||||
useUpdateQueryAndSort,
|
|
||||||
} from '../hooks/use-sort-and-query-params'
|
|
||||||
import {
|
import {
|
||||||
ContractHighlightOptions,
|
ContractHighlightOptions,
|
||||||
ContractsGrid,
|
ContractsGrid,
|
||||||
} from './contract/contracts-list'
|
} from './contract/contracts-list'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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 { useMemberGroups } from 'web/hooks/use-group'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
import { Group, NEW_USER_GROUP_SLUGS } from 'common/group'
|
||||||
import { PillButton } from './buttons/pill-button'
|
import { PillButton } from './buttons/pill-button'
|
||||||
import { sortBy } from 'lodash'
|
import { range, sortBy } from 'lodash'
|
||||||
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
import { DEFAULT_CATEGORY_GROUPS } from 'common/categories'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
|
||||||
const searchClient = algoliasearch(
|
const searchClient = algoliasearch(
|
||||||
'GJQPAYENIF',
|
'GJQPAYENIF',
|
||||||
|
@ -39,17 +28,17 @@ const searchClient = algoliasearch(
|
||||||
)
|
)
|
||||||
|
|
||||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||||
|
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||||
|
|
||||||
const sortIndexes = [
|
const sortOptions = [
|
||||||
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
// { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
|
{ label: 'Trending', value: 'score' },
|
||||||
{ label: 'Most popular', value: indexPrefix + 'contracts-score' },
|
{ label: 'Most traded', value: 'most-traded' },
|
||||||
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
|
{ label: '24h volume', value: '24-hour-vol' },
|
||||||
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
|
{ label: 'Last updated', value: 'last-updated' },
|
||||||
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
|
{ label: 'Subsidy', value: 'liquidity' },
|
||||||
{ label: 'Subsidy', value: indexPrefix + 'contracts-liquidity' },
|
{ label: 'Close date', value: 'close-date' },
|
||||||
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' },
|
{ label: 'Resolve date', value: 'resolve-date' },
|
||||||
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
|
|
||||||
]
|
]
|
||||||
export const DEFAULT_SORT = 'score'
|
export const DEFAULT_SORT = 'score'
|
||||||
|
|
||||||
|
@ -108,77 +97,154 @@ export function ContractSearch(props: {
|
||||||
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
memberPillGroups.length > 0 ? memberPillGroups : defaultPillGroups
|
||||||
|
|
||||||
const follows = useFollows(user?.id)
|
const follows = useFollows(user?.id)
|
||||||
const { initialSort } = useInitialQueryAndSort(querySortOptions)
|
|
||||||
|
|
||||||
const sort = sortIndexes
|
const { shouldLoadFromStorage, defaultSort } = querySortOptions ?? {}
|
||||||
.map(({ value }) => value)
|
const { query, setQuery, sort, setSort } = useQueryAndSortParams({
|
||||||
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
|
defaultSort,
|
||||||
? initialSort
|
shouldLoadFromStorage,
|
||||||
: querySortOptions?.defaultSort ?? DEFAULT_SORT
|
})
|
||||||
|
|
||||||
const [filter, setFilter] = useState<filter>(
|
const [filter, setFilter] = useState<filter>(
|
||||||
querySortOptions?.defaultFilter ?? 'open'
|
querySortOptions?.defaultFilter ?? 'open'
|
||||||
)
|
)
|
||||||
const pillsEnabled = !additionalFilter
|
const pillsEnabled = !additionalFilter && !query
|
||||||
|
|
||||||
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
const [pillFilter, setPillFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const selectFilter = (pill: string | undefined) => () => {
|
const selectPill = (pill: string | undefined) => () => {
|
||||||
setPillFilter(pill)
|
setPillFilter(pill)
|
||||||
|
setPage(0)
|
||||||
track('select search category', { category: pill ?? 'all' })
|
track('select search category', { category: pill ?? 'all' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { filters, numericFilters } = useMemo(() => {
|
const additionalFilters = [
|
||||||
let filters = [
|
additionalFilter?.creatorId
|
||||||
filter === 'open' ? 'isResolved:false' : '',
|
? `creatorId:${additionalFilter.creatorId}`
|
||||||
filter === 'closed' ? 'isResolved:false' : '',
|
: '',
|
||||||
filter === 'resolved' ? 'isResolved:true' : '',
|
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
||||||
additionalFilter?.creatorId
|
additionalFilter?.groupSlug
|
||||||
? `creatorId:${additionalFilter.creatorId}`
|
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||||
: '',
|
: '',
|
||||||
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
|
]
|
||||||
additionalFilter?.groupSlug
|
const facetFilters = query
|
||||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
? additionalFilters
|
||||||
: '',
|
: [
|
||||||
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
...additionalFilters,
|
||||||
? `groupLinks.slug:${pillFilter}`
|
filter === 'open' ? 'isResolved:false' : '',
|
||||||
: '',
|
filter === 'closed' ? 'isResolved:false' : '',
|
||||||
pillFilter === 'personal'
|
filter === 'resolved' ? 'isResolved:true' : '',
|
||||||
? // Show contracts in groups that the user is a member of
|
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets'
|
||||||
memberGroupSlugs
|
? `groupLinks.slug:${pillFilter}`
|
||||||
.map((slug) => `groupLinks.slug:${slug}`)
|
: '',
|
||||||
// Show contracts created by users the user follows
|
pillFilter === 'personal'
|
||||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
? // Show contracts in groups that the user is a member of
|
||||||
// Show contracts bet on by users the user follows
|
memberGroupSlugs
|
||||||
.concat(
|
.map((slug) => `groupLinks.slug:${slug}`)
|
||||||
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
// Show contracts created by users the user follows
|
||||||
)
|
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
||||||
: '',
|
// Show contracts bet on by users the user follows
|
||||||
// Subtract contracts you bet on from For you.
|
.concat(
|
||||||
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
|
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
|
||||||
pillFilter === 'your-bets' && user
|
)
|
||||||
? // Show contracts bet on by the user
|
: '',
|
||||||
`uniqueBettorIds:${user.id}`
|
// Subtract contracts you bet on from For you.
|
||||||
: '',
|
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '',
|
||||||
].filter((f) => f)
|
pillFilter === 'your-bets' && user
|
||||||
// Hack to make Algolia work.
|
? // Show contracts bet on by the user
|
||||||
filters = ['', ...filters]
|
`uniqueBettorIds:${user.id}`
|
||||||
|
: '',
|
||||||
|
].filter((f) => f)
|
||||||
|
|
||||||
const numericFilters = [
|
const numericFilters = query
|
||||||
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
? []
|
||||||
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
: [
|
||||||
].filter((f) => f)
|
filter === 'open' ? `closeTime > ${Date.now()}` : '',
|
||||||
|
filter === 'closed' ? `closeTime <= ${Date.now()}` : '',
|
||||||
return { filters, numericFilters }
|
].filter((f) => f)
|
||||||
}, [
|
|
||||||
filter,
|
|
||||||
Object.values(additionalFilter ?? {}).join(','),
|
|
||||||
memberGroupSlugs.join(','),
|
|
||||||
(follows ?? []).join(','),
|
|
||||||
pillFilter,
|
|
||||||
])
|
|
||||||
|
|
||||||
const indexName = `${indexPrefix}contracts-${sort}`
|
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) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return (
|
return (
|
||||||
|
@ -190,44 +256,40 @@ export function ContractSearch(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstantSearch searchClient={searchClient} indexName={indexName}>
|
<Col>
|
||||||
<Row className="gap-1 sm:gap-2">
|
<Row className="gap-1 sm:gap-2">
|
||||||
<SearchBox
|
<input
|
||||||
className="flex-1"
|
type="text"
|
||||||
placeholder={showPlaceHolder ? `Search ${filter} contracts` : ''}
|
value={query}
|
||||||
classNames={{
|
onChange={(e) => updateQuery(e.target.value)}
|
||||||
form: 'before:top-6',
|
placeholder={showPlaceHolder ? `Search ${filter} markets` : ''}
|
||||||
input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
|
className="input input-bordered w-full"
|
||||||
resetIcon: 'mt-2 hidden sm:flex',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/*// TODO track WHICH filter users are using*/}
|
{!query && (
|
||||||
<select
|
<select
|
||||||
className="!select !select-bordered"
|
className="select select-bordered"
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value as filter)}
|
onChange={(e) => selectFilter(e.target.value as filter)}
|
||||||
onBlur={trackCallback('select search filter', { filter })}
|
>
|
||||||
>
|
<option value="open">Open</option>
|
||||||
<option value="open">Open</option>
|
<option value="closed">Closed</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="all">All</option>
|
||||||
<option value="all">All</option>
|
</select>
|
||||||
</select>
|
)}
|
||||||
{!hideOrderSelector && (
|
{!hideOrderSelector && !query && (
|
||||||
<SortBy
|
<select
|
||||||
items={sortIndexes}
|
className="select select-bordered"
|
||||||
classNames={{
|
value={sort}
|
||||||
select: '!select !select-bordered',
|
onChange={(e) => selectSort(e.target.value as Sort)}
|
||||||
}}
|
>
|
||||||
onBlur={trackCallback('select search sort', { sort })}
|
{sortOptions.map((option) => (
|
||||||
/>
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
<Configure
|
|
||||||
facetFilters={filters}
|
|
||||||
numericFilters={numericFilters}
|
|
||||||
// Page resets on filters change.
|
|
||||||
page={0}
|
|
||||||
/>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Spacer h={3} />
|
<Spacer h={3} />
|
||||||
|
@ -237,14 +299,14 @@ export function ContractSearch(props: {
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'all'}
|
key={'all'}
|
||||||
selected={pillFilter === undefined}
|
selected={pillFilter === undefined}
|
||||||
onSelect={selectFilter(undefined)}
|
onSelect={selectPill(undefined)}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</PillButton>
|
</PillButton>
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'personal'}
|
key={'personal'}
|
||||||
selected={pillFilter === 'personal'}
|
selected={pillFilter === 'personal'}
|
||||||
onSelect={selectFilter('personal')}
|
onSelect={selectPill('personal')}
|
||||||
>
|
>
|
||||||
{user ? 'For you' : 'Featured'}
|
{user ? 'For you' : 'Featured'}
|
||||||
</PillButton>
|
</PillButton>
|
||||||
|
@ -253,7 +315,7 @@ export function ContractSearch(props: {
|
||||||
<PillButton
|
<PillButton
|
||||||
key={'your-bets'}
|
key={'your-bets'}
|
||||||
selected={pillFilter === 'your-bets'}
|
selected={pillFilter === 'your-bets'}
|
||||||
onSelect={selectFilter('your-bets')}
|
onSelect={selectPill('your-bets')}
|
||||||
>
|
>
|
||||||
Your bets
|
Your bets
|
||||||
</PillButton>
|
</PillButton>
|
||||||
|
@ -264,7 +326,7 @@ export function ContractSearch(props: {
|
||||||
<PillButton
|
<PillButton
|
||||||
key={slug}
|
key={slug}
|
||||||
selected={pillFilter === slug}
|
selected={pillFilter === slug}
|
||||||
onSelect={selectFilter(slug)}
|
onSelect={selectPill(slug)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</PillButton>
|
</PillButton>
|
||||||
|
@ -280,103 +342,17 @@ export function ContractSearch(props: {
|
||||||
memberGroupSlugs.length === 0 ? (
|
memberGroupSlugs.length === 0 ? (
|
||||||
<>You're not following anyone, nor in any of your own groups yet.</>
|
<>You're not following anyone, nor in any of your own groups yet.</>
|
||||||
) : (
|
) : (
|
||||||
<ContractSearchInner
|
<ContractsGrid
|
||||||
querySortOptions={querySortOptions}
|
contracts={contracts}
|
||||||
|
loadMore={loadMore}
|
||||||
|
hasMore={true}
|
||||||
|
showTime={showTime}
|
||||||
onContractClick={onContractClick}
|
onContractClick={onContractClick}
|
||||||
overrideGridClassName={overrideGridClassName}
|
overrideGridClassName={overrideGridClassName}
|
||||||
excludeContractIds={additionalFilter?.excludeContractIds}
|
|
||||||
highlightOptions={highlightOptions}
|
highlightOptions={highlightOptions}
|
||||||
cardHideOptions={cardHideOptions}
|
cardHideOptions={cardHideOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InstantSearch>
|
</Col>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
showTime={showTime}
|
|
||||||
onContractClick={onContractClick}
|
|
||||||
overrideGridClassName={overrideGridClassName}
|
|
||||||
highlightOptions={highlightOptions}
|
|
||||||
cardHideOptions={cardHideOptions}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { getMappedValue } from 'common/pseudo-numeric'
|
||||||
|
|
||||||
export function ContractCard(props: {
|
export function ContractCard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -115,7 +115,8 @@ export function ContractCard(props: {
|
||||||
{question}
|
{question}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' &&
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
(resolution ? (
|
(resolution ? (
|
||||||
<FreeResponseOutcomeLabel
|
<FreeResponseOutcomeLabel
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -158,7 +159,8 @@ export function ContractCard(props: {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'FREE_RESPONSE' && (
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') && (
|
||||||
<FreeResponseResolutionOrChance
|
<FreeResponseResolutionOrChance
|
||||||
className="self-end text-gray-600"
|
className="self-end text-gray-600"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -210,7 +212,7 @@ export function BinaryResolutionOrChance(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FreeResponseTopAnswer(props: {
|
function FreeResponseTopAnswer(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
truncate: 'short' | 'long' | 'none'
|
truncate: 'short' | 'long' | 'none'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
@ -315,6 +317,12 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
||||||
const { resolution, resolutionValue, resolutionProbability } = contract
|
const { resolution, resolutionValue, resolutionProbability } = contract
|
||||||
const textColor = `text-blue-400`
|
const textColor = `text-blue-400`
|
||||||
|
|
||||||
|
const value = resolution
|
||||||
|
? resolutionValue
|
||||||
|
? resolutionValue
|
||||||
|
: getMappedValue(contract)(resolutionProbability ?? 0)
|
||||||
|
: getMappedValue(contract)(getProbability(contract))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
|
||||||
{resolution ? (
|
{resolution ? (
|
||||||
|
@ -324,20 +332,21 @@ export function PseudoNumericResolutionOrExpectation(props: {
|
||||||
{resolution === 'CANCEL' ? (
|
{resolution === 'CANCEL' ? (
|
||||||
<CancelLabel />
|
<CancelLabel />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-blue-400">
|
<div
|
||||||
{resolutionValue
|
className={clsx('tooltip', textColor)}
|
||||||
? formatLargeNumber(resolutionValue)
|
data-tip={value.toFixed(2)}
|
||||||
: formatNumericProbability(
|
>
|
||||||
resolutionProbability ?? 0,
|
{formatLargeNumber(value)}
|
||||||
contract
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={clsx('text-3xl', textColor)}>
|
<div
|
||||||
{formatNumericProbability(getProbability(contract), contract)}
|
className={clsx('tooltip text-3xl', textColor)}
|
||||||
|
data-tip={value.toFixed(2)}
|
||||||
|
>
|
||||||
|
{formatLargeNumber(value)}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('text-base', textColor)}>expected</div>
|
<div className={clsx('text-base', textColor)}>expected</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -23,6 +23,9 @@ export function ContractTabs(props: {
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
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
|
// Load comments here, so the badge count will be correct
|
||||||
const updatedComments = useComments(contract.id)
|
const updatedComments = useComments(contract.id)
|
||||||
|
@ -99,7 +102,7 @@ export function ContractTabs(props: {
|
||||||
content: commentActivity,
|
content: commentActivity,
|
||||||
badge: `${comments.length}`,
|
badge: `${comments.length}`,
|
||||||
},
|
},
|
||||||
{ title: 'Bets', content: betActivity, badge: `${bets.length}` },
|
{ title: 'Bets', content: betActivity, badge: `${visibleBets.length}` },
|
||||||
...(!user || !userBets?.length
|
...(!user || !userBets?.length
|
||||||
? []
|
? []
|
||||||
: [{ title: 'Your bets', content: yourTrades }]),
|
: [{ title: 'Your bets', content: yourTrades }]),
|
||||||
|
|
|
@ -49,6 +49,10 @@ function duplicateContractHref(contract: Contract) {
|
||||||
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
params.initValue = getMappedValue(contract)(contract.initialProbability)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contract.groupLinks && contract.groupLinks.length > 0) {
|
||||||
|
params.groupId = contract.groupLinks[0].groupId
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`/create?` +
|
`/create?` +
|
||||||
Object.entries(params)
|
Object.entries(params)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Button } from 'web/components/button'
|
||||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
|
canModifyGroupContracts,
|
||||||
removeContractFromGroup,
|
removeContractFromGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
@ -57,11 +58,11 @@ export function ContractGroupsList(props: {
|
||||||
<Row className="line-clamp-1 items-center gap-2">
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
<GroupLinkItem group={group} />
|
<GroupLinkItem group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
{user && group.memberIds.includes(user.id) && (
|
{user && canModifyGroupContracts(group, user.id) && (
|
||||||
<Button
|
<Button
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
size={'xs'}
|
size={'xs'}
|
||||||
onClick={() => removeContractFromGroup(group, contract)}
|
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4 text-gray-500" />
|
<XIcon className="h-4 w-4 text-gray-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -46,7 +46,7 @@ export function CreateGroupButton(props: {
|
||||||
const newGroup = {
|
const newGroup = {
|
||||||
name: groupName,
|
name: groupName,
|
||||||
memberIds: memberUsers.map((user) => user.id),
|
memberIds: memberUsers.map((user) => user.id),
|
||||||
anyoneCanJoin: false,
|
anyoneCanJoin: true,
|
||||||
}
|
}
|
||||||
const result = await createGroup(newGroup).catch((e) => {
|
const result = await createGroup(newGroup).catch((e) => {
|
||||||
const errorDetails = e.details[0]
|
const errorDetails = e.details[0]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Col } from 'web/components/layout/col'
|
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 React, { useEffect, memo, useState, useMemo } from 'react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
|
@ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper'
|
||||||
import { sum } from 'lodash'
|
import { sum } from 'lodash'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
|
import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline'
|
||||||
|
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||||
|
|
||||||
export function GroupChat(props: {
|
export function GroupChat(props: {
|
||||||
messages: Comment[]
|
messages: Comment[]
|
||||||
|
@ -44,6 +47,13 @@ export function GroupChat(props: {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMember = user && group.memberIds.includes(user?.id)
|
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(() => {
|
useMemo(() => {
|
||||||
// Group messages with createdTime within 2 minutes of each other.
|
// Group messages with createdTime within 2 minutes of each other.
|
||||||
const tempMessages = []
|
const tempMessages = []
|
||||||
|
@ -70,9 +80,10 @@ export function GroupChat(props: {
|
||||||
}, [scrollToMessageRef])
|
}, [scrollToMessageRef])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSubmitting)
|
if (scrollToBottomRef)
|
||||||
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
|
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
|
||||||
}, [scrollToBottomRef, isSubmitting])
|
// Must also listen to groupedMessages as they update the height of the messaging window
|
||||||
|
}, [scrollToBottomRef, groupedMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const elementInUrl = router.asPath.split('#')[1]
|
const elementInUrl = router.asPath.split('#')[1]
|
||||||
|
@ -81,6 +92,11 @@ export function GroupChat(props: {
|
||||||
}
|
}
|
||||||
}, [messages, router.asPath])
|
}, [messages, router.asPath])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// is mobile?
|
||||||
|
if (inputRef && width && width > 720) inputRef.focus()
|
||||||
|
}, [inputRef, width])
|
||||||
|
|
||||||
function onReplyClick(comment: Comment) {
|
function onReplyClick(comment: Comment) {
|
||||||
setReplyToUsername(comment.userUsername)
|
setReplyToUsername(comment.userUsername)
|
||||||
}
|
}
|
||||||
|
@ -98,18 +114,6 @@ export function GroupChat(props: {
|
||||||
setReplyToUsername('')
|
setReplyToUsername('')
|
||||||
inputRef?.focus()
|
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 (
|
return (
|
||||||
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
|
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
|
||||||
|
@ -140,7 +144,7 @@ export function GroupChat(props: {
|
||||||
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
No messages yet. Why not{isMember ? ` ` : ' join and '}
|
||||||
<button
|
<button
|
||||||
className={'cursor-pointer font-bold text-gray-700'}
|
className={'cursor-pointer font-bold text-gray-700'}
|
||||||
onClick={() => focusInput()}
|
onClick={() => inputRef?.focus()}
|
||||||
>
|
>
|
||||||
add one?
|
add one?
|
||||||
</button>
|
</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: {
|
const GroupMessage = memo(function GroupMessage_(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
comment: Comment
|
comment: Comment
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||||
import { useState } from 'react'
|
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 { User } from 'common/user'
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
|
||||||
|
@ -27,10 +27,15 @@ export function GroupSelector(props: {
|
||||||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||||
const { showSelector, showLabel, ignoreGroupIds } = options
|
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const memberGroups = (useMemberGroups(creator?.id) ?? []).filter(
|
const openGroups = useOpenGroups()
|
||||||
(group) => !ignoreGroupIds?.includes(group.id)
|
const availableGroups = openGroups
|
||||||
)
|
.concat(
|
||||||
const filteredGroups = memberGroups.filter((group) =>
|
(useMemberGroups(creator?.id) ?? []).filter(
|
||||||
|
(g) => !openGroups.map((og) => og.id).includes(g.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((group) => !ignoreGroupIds?.includes(group.id))
|
||||||
|
const filteredGroups = availableGroups.filter((group) =>
|
||||||
searchInAny(query, group.name)
|
searchInAny(query, group.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { QrcodeIcon } from '@heroicons/react/outline'
|
||||||
|
import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import { fromNow } from 'web/lib/util/time'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Claim, Manalink } from 'common/manalink'
|
import { Claim, Manalink } from 'common/manalink'
|
||||||
import { useState } from 'react'
|
|
||||||
import { ShareIconButton } from './share-icon-button'
|
import { ShareIconButton } from './share-icon-button'
|
||||||
import { DotsHorizontalIcon } from '@heroicons/react/solid'
|
|
||||||
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
|
import { contractDetailsButtonClassName } from './contract/contract-info-dialog'
|
||||||
import { useUserById } from 'web/hooks/use-user'
|
import { useUserById } from 'web/hooks/use-user'
|
||||||
import getManalinkUrl from 'web/get-manalink-url'
|
import getManalinkUrl from 'web/get-manalink-url'
|
||||||
|
|
||||||
export type ManalinkInfo = {
|
export type ManalinkInfo = {
|
||||||
expiresTime: number | null
|
expiresTime: number | null
|
||||||
maxUses: number | null
|
maxUses: number | null
|
||||||
|
@ -78,7 +81,9 @@ export function ManalinkCardFromView(props: {
|
||||||
const { className, link, highlightedSlug } = props
|
const { className, link, highlightedSlug } = props
|
||||||
const { message, amount, expiresTime, maxUses, claims } = link
|
const { message, amount, expiresTime, maxUses, claims } = link
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${200}x${200}&data=${getManalinkUrl(
|
||||||
|
link.slug
|
||||||
|
)}`
|
||||||
return (
|
return (
|
||||||
<Col>
|
<Col>
|
||||||
<Col
|
<Col
|
||||||
|
@ -127,6 +132,14 @@ export function ManalinkCardFromView(props: {
|
||||||
>
|
>
|
||||||
{formatMoney(amount)}
|
{formatMoney(amount)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = qrUrl)}
|
||||||
|
className={clsx(contractDetailsButtonClassName)}
|
||||||
|
>
|
||||||
|
<QrcodeIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<ShareIconButton
|
<ShareIconButton
|
||||||
toastClassName={'-left-48 min-w-[250%]'}
|
toastClassName={'-left-48 min-w-[250%]'}
|
||||||
buttonClassName={'transition-colors'}
|
buttonClassName={'transition-colors'}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import dayjs from 'dayjs'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
import { getManalinkUrl } from 'web/pages/links'
|
import { getManalinkUrl } from 'web/pages/links'
|
||||||
import { DuplicateIcon } from '@heroicons/react/outline'
|
import { DuplicateIcon } from '@heroicons/react/outline'
|
||||||
|
import { QRCode } from '../qr-code'
|
||||||
|
|
||||||
export function CreateLinksButton(props: {
|
export function CreateLinksButton(props: {
|
||||||
user: User
|
user: User
|
||||||
|
@ -98,6 +99,8 @@ function CreateManalinkForm(props: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = getManalinkUrl(highlightedSlug)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!finishedCreating && (
|
{!finishedCreating && (
|
||||||
|
@ -199,17 +202,17 @@ function CreateManalinkForm(props: {
|
||||||
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
|
copyPressed ? 'bg-indigo-50 text-indigo-500 transition-none' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full select-text truncate">
|
<div className="w-full select-text truncate">{url}</div>
|
||||||
{getManalinkUrl(highlightedSlug)}
|
|
||||||
</div>
|
|
||||||
<DuplicateIcon
|
<DuplicateIcon
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(getManalinkUrl(highlightedSlug))
|
navigator.clipboard.writeText(url)
|
||||||
setCopyPressed(true)
|
setCopyPressed(true)
|
||||||
}}
|
}}
|
||||||
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
|
className="my-auto ml-2 h-5 w-5 cursor-pointer transition hover:opacity-50"
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<QRCode url={url} className="self-center" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo'
|
||||||
import { MenuButton } from './menu'
|
import { MenuButton } from './menu'
|
||||||
import { ProfileSummary } from './profile-menu'
|
import { ProfileSummary } from './profile-menu'
|
||||||
import NotificationsIcon from 'web/components/notifications-icon'
|
import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { 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 { Group, GROUP_CHAT_SLUG } from 'common/group'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
|
||||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
|
@ -216,7 +215,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
) ?? []
|
) ?? []
|
||||||
).map((group: Group) => ({
|
).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
|
href: `${groupPath(group.slug)}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -294,30 +293,22 @@ function GroupsList(props: {
|
||||||
memberItems.length > 0 ? memberItems.length : undefined
|
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 { height } = useWindowSize()
|
||||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||||
const remainingHeight =
|
const remainingHeight =
|
||||||
(height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0)
|
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
|
@ -332,19 +323,19 @@ function GroupsList(props: {
|
||||||
>
|
>
|
||||||
{memberItems.map((item) => (
|
{memberItems.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.href}
|
href={
|
||||||
href={item.href}
|
item.href +
|
||||||
|
(notifIsForThisItem(item.href) ? '/' + GROUP_CHAT_SLUG : '')
|
||||||
|
}
|
||||||
|
key={item.name}
|
||||||
|
onClick={trackCallback('sidebar: ' + item.name)}
|
||||||
className={clsx(
|
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',
|
'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(
|
notifIsForThisItem(item.href) && 'font-bold'
|
||||||
(n) =>
|
|
||||||
!n.isSeen &&
|
|
||||||
(n.isSeenOnHref === item.href ||
|
|
||||||
n.isSeenOnHref === item.href.replace('/chat', ''))
|
|
||||||
) && 'font-bold'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{item.name}</span>
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
173
web/components/onboarding/welcome.tsx
Normal file
173
web/components/onboarding/welcome.tsx
Normal 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, you’ll 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, we’ve sent you{' '}
|
||||||
|
<span className="font-normal text-indigo-700">M$1000 Mana</span>{' '}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { Spacer } from './layout/spacer'
|
||||||
|
|
||||||
export function Pagination(props: {
|
export function Pagination(props: {
|
||||||
page: number
|
page: number
|
||||||
|
@ -23,6 +24,8 @@ export function Pagination(props: {
|
||||||
|
|
||||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
||||||
|
|
||||||
|
if (maxPage === 0) return <Spacer h={4} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|
16
web/components/qr-code.tsx
Normal file
16
web/components/qr-code.tsx
Normal 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} />
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ export function Title(props: { text: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
className={clsx(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -214,6 +214,10 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
|
{currentUser &&
|
||||||
|
['ian', 'Austin', 'SG', 'JamesGrugett'].includes(
|
||||||
|
currentUser.username
|
||||||
|
) && <ReferralsButton user={user} />}
|
||||||
<GroupsButton user={user} />
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
listenForGroup,
|
listenForGroup,
|
||||||
listenForGroups,
|
listenForGroups,
|
||||||
listenForMemberGroups,
|
listenForMemberGroups,
|
||||||
|
listenForOpenGroups,
|
||||||
listGroups,
|
listGroups,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser, getUsers } from 'web/lib/firebase/users'
|
import { getUser, getUsers } from 'web/lib/firebase/users'
|
||||||
|
@ -32,6 +33,16 @@ export const useGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useOpenGroups = () => {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return listenForOpenGroups(setGroups)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (
|
export const useMemberGroups = (
|
||||||
userId: string | null | undefined,
|
userId: string | null | undefined,
|
||||||
options?: { withChatEnabled: boolean },
|
options?: { withChatEnabled: boolean },
|
||||||
|
|
|
@ -18,10 +18,14 @@ export const useSaveReferral = (
|
||||||
referrer?: string
|
referrer?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualReferrer = referrer || options?.defaultReferrer
|
const referrerOrDefault = referrer || options?.defaultReferrer
|
||||||
|
|
||||||
if (!user && router.isReady && actualReferrer) {
|
if (!user && router.isReady && referrerOrDefault) {
|
||||||
writeReferralInfo(actualReferrer, options?.contractId, options?.groupId)
|
writeReferralInfo(referrerOrDefault, {
|
||||||
|
contractId: options?.contractId,
|
||||||
|
overwriteReferralUsername: referrer,
|
||||||
|
groupId: options?.groupId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [user, router, options])
|
}, [user, router, options])
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import { defaults, debounce } from 'lodash'
|
import { defaults, debounce } from 'lodash'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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'
|
import { DEFAULT_SORT } from 'web/components/contract-search'
|
||||||
|
|
||||||
const MARKETS_SORT = 'markets_sort'
|
const MARKETS_SORT = 'markets_sort'
|
||||||
|
@ -74,51 +72,71 @@ export function useInitialQueryAndSort(options?: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateQueryAndSort(props: {
|
export function useQueryAndSortParams(options?: {
|
||||||
shouldLoadFromStorage: boolean
|
defaultSort?: Sort
|
||||||
|
shouldLoadFromStorage?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { shouldLoadFromStorage } = props
|
const { defaultSort = DEFAULT_SORT, shouldLoadFromStorage = true } =
|
||||||
|
options ?? {}
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { s: sort, q: query } = router.query as {
|
||||||
|
q?: string
|
||||||
|
s?: Sort
|
||||||
|
}
|
||||||
|
|
||||||
const setSort = (sort: Sort | undefined) => {
|
const setSort = (sort: Sort | undefined) => {
|
||||||
if (sort !== router.query.s) {
|
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
||||||
router.query.s = sort
|
shallow: true,
|
||||||
router.replace({ query: { ...router.query, s: sort } }, undefined, {
|
})
|
||||||
shallow: true,
|
if (shouldLoadFromStorage) {
|
||||||
})
|
localStorage.setItem(MARKETS_SORT, sort || '')
|
||||||
if (shouldLoadFromStorage) {
|
|
||||||
localStorage.setItem(MARKETS_SORT, sort || '')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { query, refine } = useSearchBox()
|
const [queryState, setQueryState] = useState(query)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQueryState(query)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
// Debounce router query update.
|
// Debounce router query update.
|
||||||
const pushQuery = useMemo(
|
const pushQuery = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((query: string | undefined) => {
|
debounce((query: string | undefined) => {
|
||||||
if (query) {
|
const queryObj = { ...router.query, q: query }
|
||||||
router.query.q = query
|
if (!query) delete queryObj.q
|
||||||
} else {
|
router.replace({ query: queryObj }, undefined, {
|
||||||
delete router.query.q
|
|
||||||
}
|
|
||||||
router.replace({ query: router.query }, undefined, {
|
|
||||||
shallow: true,
|
shallow: true,
|
||||||
})
|
})
|
||||||
track('search', { query })
|
}, 100),
|
||||||
}, 500),
|
|
||||||
[router]
|
[router]
|
||||||
)
|
)
|
||||||
|
|
||||||
const setQuery = (query: string | undefined) => {
|
const setQuery = (query: string | undefined) => {
|
||||||
refine(query ?? '')
|
setQueryState(query)
|
||||||
pushQuery(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 {
|
return {
|
||||||
|
sort: sort ?? defaultSort,
|
||||||
|
query: queryState ?? '',
|
||||||
setSort,
|
setSort,
|
||||||
setQuery,
|
setQuery,
|
||||||
query,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,3 +80,7 @@ export function claimManalink(params: any) {
|
||||||
export function createGroup(params: any) {
|
export function createGroup(params: any) {
|
||||||
return call(getFunctionUrl('creategroup'), 'POST', params)
|
return call(getFunctionUrl('creategroup'), 'POST', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentUser(params: any) {
|
||||||
|
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
|
||||||
|
}
|
||||||
|
|
|
@ -81,6 +81,7 @@ export async function createCommentOnGroup(
|
||||||
function getCommentsCollection(contractId: string) {
|
function getCommentsCollection(contractId: string) {
|
||||||
return collection(db, 'contracts', contractId, 'comments')
|
return collection(db, 'contracts', contractId, 'comments')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCommentsOnGroupCollection(groupId: string) {
|
function getCommentsOnGroupCollection(groupId: string) {
|
||||||
return collection(db, 'groups', groupId, 'comments')
|
return collection(db, 'groups', groupId, 'comments')
|
||||||
}
|
}
|
||||||
|
@ -91,6 +92,14 @@ export async function listAllComments(contractId: string) {
|
||||||
return comments
|
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(
|
export function listenForCommentsOnContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setComments: (comments: Comment[]) => void
|
setComments: (comments: Comment[]) => void
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, uniq } from 'lodash'
|
import { sortBy, uniq } from 'lodash'
|
||||||
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||||
import { updateContract } from './contracts'
|
|
||||||
import {
|
import {
|
||||||
coll,
|
coll,
|
||||||
getValue,
|
getValue,
|
||||||
|
@ -17,6 +16,7 @@ import {
|
||||||
listenForValues,
|
listenForValues,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
import { updateContract } from 'web/lib/firebase/contracts'
|
||||||
|
|
||||||
export const groups = coll<Group>('groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
|
||||||
|
@ -52,6 +52,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groups, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||||
|
return listenForValues(
|
||||||
|
query(groups, where('anyoneCanJoin', '==', true)),
|
||||||
|
setGroups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function getGroup(groupId: string) {
|
export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groups, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
@ -129,23 +136,23 @@ export async function addContractToGroup(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
if (!contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
if (!canModifyGroupContracts(group, userId)) return
|
||||||
const newGroupLinks = [
|
const newGroupLinks = [
|
||||||
...(contract.groupLinks ?? []),
|
...(contract.groupLinks ?? []),
|
||||||
{
|
{
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
userId,
|
userId,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
} as GroupLink,
|
} 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,
|
||||||
|
})
|
||||||
|
|
||||||
await updateContract(contract.id, {
|
|
||||||
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
|
|
||||||
groupLinks: newGroupLinks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!group.contractIds.includes(contract.id)) {
|
if (!group.contractIds.includes(contract.id)) {
|
||||||
return await updateGroup(group, {
|
return await updateGroup(group, {
|
||||||
contractIds: uniq([...group.contractIds, contract.id]),
|
contractIds: uniq([...group.contractIds, contract.id]),
|
||||||
|
@ -160,8 +167,11 @@ export async function addContractToGroup(
|
||||||
|
|
||||||
export async function removeContractFromGroup(
|
export async function removeContractFromGroup(
|
||||||
group: Group,
|
group: Group,
|
||||||
contract: Contract
|
contract: Contract,
|
||||||
|
userId: string
|
||||||
) {
|
) {
|
||||||
|
if (!canModifyGroupContracts(group, userId)) return
|
||||||
|
|
||||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||||
const newGroupLinks = contract.groupLinks?.filter(
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
(link) => link.slug !== group.slug
|
(link) => link.slug !== group.slug
|
||||||
|
@ -186,29 +196,10 @@ export async function removeContractFromGroup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setContractGroupLinks(
|
export function canModifyGroupContracts(group: Group, userId: string) {
|
||||||
group: Group,
|
return (
|
||||||
contractId: string,
|
group.creatorId === userId ||
|
||||||
userId: string
|
group.memberIds.includes(userId) ||
|
||||||
) {
|
group.anyoneCanJoin
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,22 +96,25 @@ const CACHED_REFERRAL_GROUP_ID_KEY = 'CACHED_REFERRAL_GROUP_KEY'
|
||||||
|
|
||||||
export function writeReferralInfo(
|
export function writeReferralInfo(
|
||||||
defaultReferrerUsername: string,
|
defaultReferrerUsername: string,
|
||||||
contractId?: string,
|
otherOptions?: {
|
||||||
referralUsername?: string,
|
contractId?: string
|
||||||
groupId?: string
|
overwriteReferralUsername?: string
|
||||||
|
groupId?: string
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const local = safeLocalStorage()
|
const local = safeLocalStorage()
|
||||||
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
|
const cachedReferralUser = local?.getItem(CACHED_REFERRAL_USERNAME_KEY)
|
||||||
|
const { contractId, overwriteReferralUsername, groupId } = otherOptions || {}
|
||||||
// Write the first referral username we see.
|
// Write the first referral username we see.
|
||||||
if (!cachedReferralUser)
|
if (!cachedReferralUser)
|
||||||
local?.setItem(
|
local?.setItem(
|
||||||
CACHED_REFERRAL_USERNAME_KEY,
|
CACHED_REFERRAL_USERNAME_KEY,
|
||||||
referralUsername || defaultReferrerUsername
|
overwriteReferralUsername || defaultReferrerUsername
|
||||||
)
|
)
|
||||||
|
|
||||||
// If an explicit referral query is passed, overwrite the cached referral username.
|
// If an explicit referral query is passed, overwrite the cached referral username.
|
||||||
if (referralUsername)
|
if (overwriteReferralUsername)
|
||||||
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, referralUsername)
|
local?.setItem(CACHED_REFERRAL_USERNAME_KEY, overwriteReferralUsername)
|
||||||
|
|
||||||
// Always write the most recent explicit group invite query value
|
// Always write the most recent explicit group invite query value
|
||||||
if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)
|
if (groupId) local?.setItem(CACHED_REFERRAL_GROUP_ID_KEY, groupId)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Script from 'next/script'
|
||||||
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
|
import { usePreserveScroll } from 'web/hooks/use-preserve-scroll'
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query'
|
import { QueryClient, QueryClientProvider } from 'react-query'
|
||||||
import { AuthProvider } from 'web/components/auth-context'
|
import { AuthProvider } from 'web/components/auth-context'
|
||||||
|
import Welcome from 'web/components/onboarding/welcome'
|
||||||
|
|
||||||
function firstLine(msg: string) {
|
function firstLine(msg: string) {
|
||||||
return msg.replace(/\r?\n.*/s, '')
|
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"
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Welcome {...pageProps} />
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
18
web/pages/api/v0/group/[slug].ts
Normal file
18
web/pages/api/v0/group/[slug].ts
Normal 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)
|
||||||
|
}
|
18
web/pages/api/v0/group/by-id/[id].ts
Normal file
18
web/pages/api/v0/group/by-id/[id].ts
Normal 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)
|
||||||
|
}
|
15
web/pages/api/v0/groups.ts
Normal file
15
web/pages/api/v0/groups.ts
Normal 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)
|
||||||
|
}
|
23
web/pages/api/v0/market/[id]/lite.ts
Normal file
23
web/pages/api/v0/market/[id]/lite.ts
Normal 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
16
web/pages/api/v0/me.ts
Normal 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.' })
|
||||||
|
}
|
||||||
|
}
|
|
@ -102,7 +102,7 @@ export default function ContractSearchFirestore(props: {
|
||||||
>
|
>
|
||||||
<option value="newest">Newest</option>
|
<option value="newest">Newest</option>
|
||||||
<option value="oldest">Oldest</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="most-traded">Most traded</option>
|
||||||
<option value="24-hour-vol">24h volume</option>
|
<option value="24-hour-vol">24h volume</option>
|
||||||
<option value="close-date">Closing soon</option>
|
<option value="close-date">Closing soon</option>
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
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 { Group } from 'common/group'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||||
|
@ -122,7 +122,7 @@ export function NewContract(props: {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (groupId && creator)
|
if (groupId && creator)
|
||||||
getGroup(groupId).then((group) => {
|
getGroup(groupId).then((group) => {
|
||||||
if (group && group.memberIds.includes(creator.id)) {
|
if (group && canModifyGroupContracts(group, creator.id)) {
|
||||||
setSelectedGroup(group)
|
setSelectedGroup(group)
|
||||||
setShowGroupSelector(false)
|
setShowGroupSelector(false)
|
||||||
}
|
}
|
||||||
|
@ -239,10 +239,6 @@ export function NewContract(props: {
|
||||||
selectedGroup: selectedGroup?.id,
|
selectedGroup: selectedGroup?.id,
|
||||||
isFree: false,
|
isFree: false,
|
||||||
})
|
})
|
||||||
if (result && selectedGroup) {
|
|
||||||
await setContractGroupLinks(selectedGroup, result.id, creator.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
await router.push(contractPath(result as Contract))
|
await router.push(contractPath(result as Contract))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('error creating contract', e, (e as any).details)
|
console.error('error creating contract', e, (e as any).details)
|
||||||
|
@ -477,6 +473,8 @@ export function NewContract(props: {
|
||||||
{isSubmitting ? 'Creating...' : 'Create question'}
|
{isSubmitting ? 'Creating...' : 'Create question'}
|
||||||
</button>
|
</button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Spacer h={6} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
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 { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { scoreCreators, scoreTraders } from 'common/scoring'
|
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 { Tabs } from 'web/components/layout/tabs'
|
||||||
import { CreateQuestionButton } from 'web/components/create-question-button'
|
import { CreateQuestionButton } from 'web/components/create-question-button'
|
||||||
import React, { useState } from 'react'
|
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 { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
|
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 { useTipTxns } from 'web/hooks/use-tip-txns'
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
import { CopyLinkButton } from 'web/components/copy-link-button'
|
import { CopyLinkButton } from 'web/components/copy-link-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
|
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||||
|
import { Comment } from 'common/comment'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
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(
|
const bets = await Promise.all(
|
||||||
contracts.map((contract: Contract) => listAllBets(contract.id))
|
contracts.map((contract: Contract) => listAllBets(contract.id))
|
||||||
)
|
)
|
||||||
|
const messages = group && (await listAllCommentsOnGroup(group.id))
|
||||||
|
|
||||||
const creatorScores = scoreCreators(contracts)
|
const creatorScores = scoreCreators(contracts)
|
||||||
const traderScores = scoreTraders(contracts, bets)
|
const traderScores = scoreTraders(contracts, bets)
|
||||||
|
@ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
|
||||||
topTraders,
|
topTraders,
|
||||||
creatorScores,
|
creatorScores,
|
||||||
topCreators,
|
topCreators,
|
||||||
|
messages,
|
||||||
},
|
},
|
||||||
|
|
||||||
revalidate: 60, // regenerate after a minute
|
revalidate: 60, // regenerate after a minute
|
||||||
|
@ -123,6 +126,7 @@ export default function GroupPage(props: {
|
||||||
topTraders: User[]
|
topTraders: User[]
|
||||||
creatorScores: { [userId: string]: number }
|
creatorScores: { [userId: string]: number }
|
||||||
topCreators: User[]
|
topCreators: User[]
|
||||||
|
messages: Comment[]
|
||||||
}) {
|
}) {
|
||||||
props = usePropz(props, getStaticPropz) ?? {
|
props = usePropz(props, getStaticPropz) ?? {
|
||||||
group: null,
|
group: null,
|
||||||
|
@ -132,6 +136,7 @@ export default function GroupPage(props: {
|
||||||
topTraders: [],
|
topTraders: [],
|
||||||
creatorScores: {},
|
creatorScores: {},
|
||||||
topCreators: [],
|
topCreators: [],
|
||||||
|
messages: [],
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
creator,
|
creator,
|
||||||
|
@ -149,19 +154,18 @@ export default function GroupPage(props: {
|
||||||
const group = useGroup(props.group?.id) ?? props.group
|
const group = useGroup(props.group?.id) ?? props.group
|
||||||
const tips = useTipTxns({ groupId: group?.id })
|
const tips = useTipTxns({ groupId: group?.id })
|
||||||
|
|
||||||
const messages = useCommentsOnGroup(group?.id)
|
const messages = useCommentsOnGroup(group?.id) ?? props.messages
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
const privateUser = usePrivateUser(user?.id)
|
||||||
|
|
||||||
useSaveReferral(user, {
|
useSaveReferral(user, {
|
||||||
defaultReferrer: creator.username,
|
defaultReferrer: creator.username,
|
||||||
groupId: group?.id,
|
groupId: group?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
const chatDisabled = !group || group.chatDisabled
|
const chatDisabled = !group || group.chatDisabled
|
||||||
const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280
|
const showChatBubble = !chatDisabled
|
||||||
const showChatTab = !chatDisabled && !showChatSidebar
|
|
||||||
|
|
||||||
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
|
@ -195,16 +199,6 @@ export default function GroupPage(props: {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
|
|
||||||
const chatTab = (
|
|
||||||
<Col className="">
|
|
||||||
{messages ? (
|
|
||||||
<GroupChat messages={messages} user={user} group={group} tips={tips} />
|
|
||||||
) : (
|
|
||||||
<LoadingIndicator />
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
|
|
||||||
const questionsTab = (
|
const questionsTab = (
|
||||||
<ContractSearch
|
<ContractSearch
|
||||||
querySortOptions={{
|
querySortOptions={{
|
||||||
|
@ -217,15 +211,6 @@ export default function GroupPage(props: {
|
||||||
)
|
)
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
...(!showChatTab
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
title: 'Chat',
|
|
||||||
content: chatTab,
|
|
||||||
href: groupPath(group.slug, GROUP_CHAT_SLUG),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{
|
{
|
||||||
title: 'Markets',
|
title: 'Markets',
|
||||||
content: questionsTab,
|
content: questionsTab,
|
||||||
|
@ -242,20 +227,17 @@ export default function GroupPage(props: {
|
||||||
href: groupPath(group.slug, 'about'),
|
href: groupPath(group.slug, 'about'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
|
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page>
|
||||||
rightSidebar={showChatSidebar ? chatTab : undefined}
|
|
||||||
rightSidebarClassName={showChatSidebar ? '!top-0' : ''}
|
|
||||||
className={showChatSidebar ? '!max-w-7xl !pb-0' : ''}
|
|
||||||
>
|
|
||||||
<SEO
|
<SEO
|
||||||
title={group.name}
|
title={group.name}
|
||||||
description={`Created by ${creator.name}. ${group.about}`}
|
description={`Created by ${creator.name}. ${group.about}`}
|
||||||
url={groupPath(group.slug)}
|
url={groupPath(group.slug)}
|
||||||
/>
|
/>
|
||||||
<Col className="px-3">
|
<Col className="relative px-3">
|
||||||
<Row className={'items-center justify-between gap-4'}>
|
<Row className={'items-center justify-between gap-4'}>
|
||||||
<div className={'sm:mb-1'}>
|
<div className={'sm:mb-1'}>
|
||||||
<div
|
<div
|
||||||
|
@ -282,6 +264,15 @@ export default function GroupPage(props: {
|
||||||
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
defaultIndex={tabIndex > 0 ? tabIndex : 0}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
|
{showChatBubble && (
|
||||||
|
<GroupChatInBubble
|
||||||
|
group={group}
|
||||||
|
user={user}
|
||||||
|
privateUser={privateUser}
|
||||||
|
tips={tips}
|
||||||
|
messages={messages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,11 +81,18 @@ const useContractPage = () => {
|
||||||
if (!username || !contractSlug) setContract(undefined)
|
if (!username || !contractSlug) setContract(undefined)
|
||||||
else {
|
else {
|
||||||
// Show contract if route is to a contract: '/[username]/[contractSlug]'.
|
// 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
|
const { pushState, replaceState } = window.history
|
||||||
|
|
||||||
window.history.pushState = function () {
|
window.history.pushState = function () {
|
||||||
|
@ -101,6 +108,7 @@ const useContractPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
removeEventListener('popstate', updateContract)
|
||||||
window.history.pushState = pushState
|
window.history.pushState = pushState
|
||||||
window.history.replaceState = replaceState
|
window.history.replaceState = replaceState
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { claimManalink } from 'web/lib/firebase/api'
|
import { claimManalink } from 'web/lib/firebase/api'
|
||||||
import { useManalink } from 'web/lib/firebase/manalinks'
|
import { useManalink } from 'web/lib/firebase/manalinks'
|
||||||
import { ManalinkCard } from 'web/components/manalink-card'
|
import { ManalinkCard } from 'web/components/manalink-card'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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 { Row } from 'web/components/layout/row'
|
||||||
import { Button } from 'web/components/button'
|
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() {
|
export default function ClaimPage() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -18,6 +21,8 @@ export default function ClaimPage() {
|
||||||
const [claiming, setClaiming] = useState(false)
|
const [claiming, setClaiming] = useState(false)
|
||||||
const [error, setError] = useState<string | undefined>(undefined)
|
const [error, setError] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
useReferral(user, manalink)
|
||||||
|
|
||||||
if (!manalink) {
|
if (!manalink) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
@ -33,46 +38,58 @@ export default function ClaimPage() {
|
||||||
<div className="mx-auto max-w-xl px-2">
|
<div className="mx-auto max-w-xl px-2">
|
||||||
<Row className="items-center justify-between">
|
<Row className="items-center justify-between">
|
||||||
<Title text={`Claim M$${manalink.amount} mana`} />
|
<Title text={`Claim M$${manalink.amount} mana`} />
|
||||||
<div className="my-auto">
|
<div className="my-auto"></div>
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
setClaiming(true)
|
|
||||||
try {
|
|
||||||
if (user == null) {
|
|
||||||
await firebaseLogin()
|
|
||||||
setClaiming(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (user?.id == manalink.fromId) {
|
|
||||||
throw new Error("You can't claim your own manalink.")
|
|
||||||
}
|
|
||||||
await claimManalink({ slug: manalink.slug })
|
|
||||||
user && router.push(`/${user.username}?claimed-mana=yes`)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
const message =
|
|
||||||
e && e instanceof Object
|
|
||||||
? e.toString()
|
|
||||||
: 'An error occurred.'
|
|
||||||
setError(message)
|
|
||||||
}
|
|
||||||
setClaiming(false)
|
|
||||||
}}
|
|
||||||
disabled={claiming}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{user ? 'Claim' : 'Login'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<ManalinkCard info={info} />
|
<ManalinkCard info={info} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<section className="my-5 text-red-500">
|
<section className="my-5 text-red-500">
|
||||||
<p>Failed to claim manalink.</p>
|
<p>Failed to claim manalink.</p>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Row className="items-center">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setClaiming(true)
|
||||||
|
try {
|
||||||
|
if (user == null) {
|
||||||
|
await firebaseLogin()
|
||||||
|
setClaiming(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user?.id == manalink.fromId) {
|
||||||
|
throw new Error("You can't claim your own manalink.")
|
||||||
|
}
|
||||||
|
await claimManalink({ slug: manalink.slug })
|
||||||
|
user && router.push(`/${user.username}?claimed-mana=yes`)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
const message =
|
||||||
|
e && e instanceof Object ? e.toString() : 'An error occurred.'
|
||||||
|
setError(message)
|
||||||
|
}
|
||||||
|
setClaiming(false)
|
||||||
|
}}
|
||||||
|
disabled={claiming}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{user ? `Claim M$${manalink.amount}` : 'Login to claim'}
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
</div>
|
</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 })
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||||
|
dayjs.extend(customParseFormat)
|
||||||
|
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
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 { CreateLinksButton } from 'web/components/manalinks/create-links-button'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
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 { ManalinkCardFromView } from 'web/components/manalink-card'
|
||||||
import { Pagination } from 'web/components/pagination'
|
import { Pagination } from 'web/components/pagination'
|
||||||
import { Manalink } from 'common/manalink'
|
import { Manalink } from 'common/manalink'
|
||||||
dayjs.extend(customParseFormat)
|
import { REFERRAL_AMOUNT } from 'common/user'
|
||||||
|
|
||||||
const LINKS_PER_PAGE = 24
|
const LINKS_PER_PAGE = 24
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/')
|
export const getServerSideProps = redirectIfLoggedOut('/')
|
||||||
|
@ -64,8 +67,10 @@ export default function LinkPage() {
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<p>
|
<p>
|
||||||
You can use manalinks to send mana to other people, even if they
|
You can use manalinks to send mana (M$) to other people, even if they
|
||||||
don't yet have a Manifold account.
|
don'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>
|
</p>
|
||||||
<Subtitle text="Your Manalinks" />
|
<Subtitle text="Your Manalinks" />
|
||||||
<ManalinksDisplay
|
<ManalinksDisplay
|
||||||
|
|
BIN
web/public/welcome/charity.mp4
Executable file
BIN
web/public/welcome/charity.mp4
Executable file
Binary file not shown.
BIN
web/public/welcome/mana-example.mp4
Executable file
BIN
web/public/welcome/mana-example.mp4
Executable file
Binary file not shown.
BIN
web/public/welcome/manipurple.png
Normal file
BIN
web/public/welcome/manipurple.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
web/public/welcome/treasure.png
Normal file
BIN
web/public/welcome/treasure.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
|
@ -18,6 +18,15 @@ module.exports = {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'world-trading': "url('/world-trading-background.webp')",
|
'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: {
|
typography: {
|
||||||
quoteless: {
|
quoteless: {
|
||||||
css: {
|
css: {
|
||||||
|
|
|
@ -5144,6 +5144,11 @@ dayjs@1.10.7:
|
||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
|
||||||
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
|
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:
|
debug@2, debug@2.6.9, debug@^2.6.0, debug@^2.6.9:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user