d9f57b7daa
* Tweak limit order UI and fix button * Style all follow/unfollow buttons blue also get rid of highlight-blue button * remove all other uses of 'btn' * Style group follow button like user follow * lint and format
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import { LinkIcon } from '@heroicons/react/solid'
|
|
import clsx from 'clsx'
|
|
import { PrivateUser, User } from 'common/user'
|
|
import { MouseEventHandler, ReactNode, useEffect, useState } from 'react'
|
|
|
|
import toast from 'react-hot-toast'
|
|
import { Button } from 'web/components/button'
|
|
import { Col } from 'web/components/layout/col'
|
|
import { Row } from 'web/components/layout/row'
|
|
import { Spacer } from 'web/components/layout/spacer'
|
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
import { ManifoldLogo } from 'web/components/nav/manifold-logo'
|
|
import { Page } from 'web/components/page'
|
|
import { SEO } from 'web/components/SEO'
|
|
import { Title } from 'web/components/title'
|
|
import { useSaveReferral } from 'web/hooks/use-save-referral'
|
|
import { useTracking } from 'web/hooks/use-tracking'
|
|
import { usePrivateUser, useUser } from 'web/hooks/use-user'
|
|
import { firebaseLogin, updatePrivateUser } from 'web/lib/firebase/users'
|
|
import { track } from 'web/lib/service/analytics'
|
|
import {
|
|
getDockURLForUser,
|
|
getOverlayURLForUser,
|
|
linkTwitchAccountRedirect,
|
|
updateBotEnabledForUser,
|
|
} from 'web/lib/twitch/link-twitch-account'
|
|
import { copyToClipboard } from 'web/lib/util/copy'
|
|
|
|
function ButtonGetStarted(props: {
|
|
user?: User | null
|
|
privateUser?: PrivateUser | null
|
|
buttonClass?: string
|
|
spinnerClass?: string
|
|
}) {
|
|
const { user, privateUser, buttonClass, spinnerClass } = props
|
|
|
|
const [isLoading, setLoading] = useState(false)
|
|
|
|
const needsRelink =
|
|
privateUser?.twitchInfo?.twitchName &&
|
|
privateUser?.twitchInfo?.needsRelinking
|
|
|
|
const [waitingForUser, setWaitingForUser] = useState(false)
|
|
useEffect(() => {
|
|
if (waitingForUser && user && privateUser) {
|
|
setWaitingForUser(false)
|
|
|
|
if (privateUser.twitchInfo?.twitchName) return // If we've already linked Twitch, no need to do so again
|
|
|
|
setLoading(true)
|
|
|
|
linkTwitchAccountRedirect(user, privateUser).then(() => {
|
|
setLoading(false)
|
|
})
|
|
}
|
|
}, [user, privateUser, waitingForUser])
|
|
|
|
const callback =
|
|
user && privateUser
|
|
? () => linkTwitchAccountRedirect(user, privateUser)
|
|
: async () => {
|
|
await firebaseLogin()
|
|
setWaitingForUser(true)
|
|
}
|
|
|
|
const getStarted = async () => {
|
|
try {
|
|
setLoading(true)
|
|
|
|
const promise = callback()
|
|
track('twitch page button click')
|
|
await promise
|
|
} catch (e) {
|
|
console.error(e)
|
|
toast.error('Failed to sign up. Please try again later.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
return isLoading ? (
|
|
<LoadingIndicator
|
|
spinnerClassName={clsx('!w-11 !h-11 my-4', spinnerClass)}
|
|
/>
|
|
) : (
|
|
<Button
|
|
size="xl"
|
|
color={needsRelink ? 'red' : 'gradient'}
|
|
className={clsx('my-4 self-center !px-16', buttonClass)}
|
|
onClick={getStarted}
|
|
>
|
|
{needsRelink ? 'API key updated: relink Twitch' : 'Start playing'}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function TwitchPlaysManifoldMarkets(props: {
|
|
user?: User | null
|
|
privateUser?: PrivateUser | null
|
|
}) {
|
|
const { user, privateUser } = props
|
|
|
|
const twitchInfo = privateUser?.twitchInfo
|
|
const twitchUser = twitchInfo?.twitchName
|
|
|
|
return (
|
|
<div>
|
|
<Row className="mb-4">
|
|
<img
|
|
src="/twitch-glitch.svg"
|
|
className="mb-[0.4rem] mr-4 inline h-10 w-10"
|
|
></img>
|
|
<Title
|
|
text={'Twitch plays Manifold Markets'}
|
|
className={'!-my-0 md:block'}
|
|
/>
|
|
</Row>
|
|
<Col className="mb-4 gap-4">
|
|
Start betting on Twitch now by linking your account and typing commands
|
|
in chat!
|
|
{twitchUser && !twitchInfo.needsRelinking ? (
|
|
<Button
|
|
size="xl"
|
|
color="green"
|
|
className="my-4 self-center !border-none"
|
|
>
|
|
Account connected: {twitchUser}
|
|
</Button>
|
|
) : (
|
|
<ButtonGetStarted user={user} privateUser={privateUser} />
|
|
)}
|
|
</Col>
|
|
<Col className="gap-4">
|
|
<Subtitle text="How it works" />
|
|
<div>
|
|
Similar to Twitch channel point predictions, Manifold Markets allows
|
|
you to create a play-money betting market on any question you like and
|
|
feature it in your stream.
|
|
</div>
|
|
<div>
|
|
The key difference is that Manifold's questions function more like a
|
|
stock market and viewers can buy and sell shares over the course of
|
|
the event and not just at the start. The market will eventually
|
|
resolve to yes or no at which point the winning shareholders will
|
|
receive their profit.
|
|
</div>
|
|
<div>
|
|
Instead of Twitch channel points we use our own play money, mana (M$).
|
|
All viewers start with M$1,000 and can earn more for free by betting
|
|
well. Just like channel points, mana cannot be converted to real
|
|
money.
|
|
</div>
|
|
</Col>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Subtitle(props: { text: string }) {
|
|
const { text } = props
|
|
return <div className="text-2xl">{text}</div>
|
|
}
|
|
|
|
function Command(props: { command: string; desc: string }) {
|
|
const { command, desc } = props
|
|
return (
|
|
<div>
|
|
<p className="inline font-bold">{'!' + command}</p>
|
|
{' - '}
|
|
<p className="inline">{desc}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TwitchChatCommands() {
|
|
return (
|
|
<div>
|
|
<Title text="Twitch Chat Commands" className="md:block" />
|
|
<Col className="gap-4">
|
|
<Subtitle text="For Chat" />
|
|
<Command
|
|
command="y#"
|
|
desc="Bets # amount of M$ on yes, for example !y20 would bet M$20 on yes."
|
|
/>
|
|
<Command
|
|
command="n#"
|
|
desc="Bets # amount of M$ on no, for example !n30 would bet M$30 on no."
|
|
/>
|
|
<Command
|
|
command="sell"
|
|
desc="Sells all shares you own. Using this command causes you to
|
|
cash out early based on the current probability.
|
|
Shares will always be worth the most if you wait for a favourable resolution. But, selling allows you to lower risk, or trade throughout the event which can maximise earnings."
|
|
/>
|
|
<Command
|
|
command="position"
|
|
desc="Shows how many shares you own in the current market and what your fixed payout is."
|
|
/>
|
|
<Command command="balance" desc="Shows how much M$ your account has." />
|
|
|
|
<div className="mb-4" />
|
|
|
|
<Subtitle text="For Mods/Streamer" />
|
|
|
|
<div>
|
|
We recommend streamers sharing the link to the control dock with their
|
|
mods. Alternatively, chat commands can be used to control markets.{' '}
|
|
</div>
|
|
|
|
<Command
|
|
command="create [question]"
|
|
desc="Creates and features a question. Be careful, this will replace any question that is currently featured."
|
|
/>
|
|
<Command command="resolve yes" desc="Resolves the market as 'Yes'." />
|
|
<Command command="resolve no" desc="Resolves the market as 'No'." />
|
|
<Command
|
|
command="resolve na"
|
|
desc="Cancels the market and refunds everyone their mana."
|
|
/>
|
|
<Command
|
|
command="unfeature"
|
|
desc="Unfeatures the market. The market will still be open on our site and available to be refeatured again. If you plan to never interact with a market again we recommend resolving to N/A and not this command."
|
|
/>
|
|
</Col>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BotSetupStep(props: {
|
|
stepNum: number
|
|
buttonName?: string
|
|
buttonOnClick?: MouseEventHandler
|
|
overrideButton?: ReactNode
|
|
children: ReactNode
|
|
}) {
|
|
const { stepNum, buttonName, buttonOnClick, overrideButton, children } = props
|
|
return (
|
|
<Col className="flex-1">
|
|
{(overrideButton || buttonName) && (
|
|
<>
|
|
{overrideButton ?? (
|
|
<Button
|
|
size={'md'}
|
|
color={'green'}
|
|
className="!border-none"
|
|
onClick={buttonOnClick}
|
|
>
|
|
{buttonName}
|
|
</Button>
|
|
)}
|
|
<Spacer h={4} />
|
|
</>
|
|
)}
|
|
<div>
|
|
<p className="inline font-bold">Step {stepNum}. </p>
|
|
{children}
|
|
</div>
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
function CopyLinkButton(props: { link: string; text: string }) {
|
|
const { link, text } = props
|
|
const toastTheme = {
|
|
className: '!bg-primary !text-white',
|
|
icon: <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />,
|
|
}
|
|
const copyLinkCallback = async () => {
|
|
copyToClipboard(link)
|
|
toast.success(text + ' copied', toastTheme)
|
|
}
|
|
return (
|
|
<a href={link} onClick={(e) => e.preventDefault()}>
|
|
<Button
|
|
size={'md'}
|
|
color={'green'}
|
|
className="w-full !border-none"
|
|
onClick={copyLinkCallback}
|
|
>
|
|
{text}
|
|
</Button>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function BotConnectButton(props: {
|
|
privateUser: PrivateUser | null | undefined
|
|
}) {
|
|
const { privateUser } = props
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const updateBotConnected = (connected: boolean) => async () => {
|
|
if (!privateUser) return
|
|
const twitchInfo = privateUser.twitchInfo
|
|
if (!twitchInfo) return
|
|
|
|
const error = connected
|
|
? 'Failed to add bot to your channel'
|
|
: 'Failed to remove bot from your channel'
|
|
const success = connected
|
|
? 'Added bot to your channel'
|
|
: 'Removed bot from your channel'
|
|
|
|
setLoading(true)
|
|
toast.promise(
|
|
updateBotEnabledForUser(privateUser, connected)
|
|
.then(() =>
|
|
updatePrivateUser(privateUser.id, {
|
|
twitchInfo: { ...twitchInfo, botEnabled: connected },
|
|
})
|
|
)
|
|
.finally(() => setLoading(false)),
|
|
{ loading: 'Updating bot settings...', error, success },
|
|
{
|
|
loading: {
|
|
className: '!max-w-sm',
|
|
},
|
|
success: {
|
|
className:
|
|
'!bg-primary !transition-all !duration-500 !text-white !max-w-sm',
|
|
},
|
|
error: {
|
|
className:
|
|
'!bg-red-400 !transition-all !duration-500 !text-white !max-w-sm',
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{privateUser?.twitchInfo?.botEnabled ? (
|
|
<Button
|
|
color="red"
|
|
onClick={updateBotConnected(false)}
|
|
loading={loading}
|
|
>
|
|
Remove bot from channel
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
color="green"
|
|
onClick={updateBotConnected(true)}
|
|
loading={loading}
|
|
className="border-none"
|
|
>
|
|
Add bot to your channel
|
|
</Button>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SetUpBot(props: {
|
|
user?: User | null
|
|
privateUser?: PrivateUser | null
|
|
}) {
|
|
const { user, privateUser } = props
|
|
const twitchLinked =
|
|
privateUser &&
|
|
privateUser?.twitchInfo?.twitchName &&
|
|
!privateUser?.twitchInfo?.needsRelinking
|
|
? true
|
|
: undefined
|
|
|
|
return (
|
|
<>
|
|
<Title
|
|
text={'Set up the bot for your own stream'}
|
|
className={'!mb-0 md:block'}
|
|
/>
|
|
<Col className="gap-4">
|
|
<img
|
|
src="/twitch-bot-obs-screenshot.jpg"
|
|
className="rounded-md border-t border-l border-r shadow-md"
|
|
></img>
|
|
To add the bot to your stream make sure you have logged in then follow
|
|
the steps below.
|
|
{twitchLinked && privateUser ? (
|
|
<div className="flex flex-col gap-6 sm:flex-row">
|
|
<BotSetupStep
|
|
stepNum={1}
|
|
overrideButton={
|
|
twitchLinked && <BotConnectButton privateUser={privateUser} />
|
|
}
|
|
>
|
|
Use the button above to add the bot to your channel. Then mod it
|
|
by typing in your Twitch chat: <b>/mod ManifoldBot</b>
|
|
<br />
|
|
If the bot is not modded it will not be able to respond to
|
|
commands properly.
|
|
</BotSetupStep>
|
|
<BotSetupStep
|
|
stepNum={2}
|
|
overrideButton={
|
|
<CopyLinkButton
|
|
link={getOverlayURLForUser(privateUser)}
|
|
text={'Overlay link'}
|
|
/>
|
|
}
|
|
>
|
|
Create a new browser source in your streaming software such as
|
|
OBS. Paste in the above link and type in the desired size. We
|
|
recommend 450x375.
|
|
</BotSetupStep>
|
|
<BotSetupStep
|
|
stepNum={3}
|
|
overrideButton={
|
|
<CopyLinkButton
|
|
link={getDockURLForUser(privateUser)}
|
|
text={'Control dock link'}
|
|
/>
|
|
}
|
|
>
|
|
The bot can be controlled entirely through chat. But we made an
|
|
easy to use control panel. Share the link with your mods or embed
|
|
it into your OBS as a custom dock.
|
|
</BotSetupStep>
|
|
</div>
|
|
) : (
|
|
<ButtonGetStarted
|
|
user={user}
|
|
privateUser={privateUser}
|
|
buttonClass={'!my-0'}
|
|
spinnerClass={'!my-0'}
|
|
/>
|
|
)}
|
|
<div>
|
|
Need help? Contact SirSalty#5770 in Discord or email
|
|
david@manifold.markets
|
|
</div>
|
|
</Col>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default function TwitchLandingPage() {
|
|
useSaveReferral()
|
|
useTracking('view twitch landing page')
|
|
|
|
const user = useUser()
|
|
const privateUser = usePrivateUser()
|
|
|
|
return (
|
|
<Page>
|
|
<SEO
|
|
title="Manifold Markets on Twitch"
|
|
description="Get more out of Twitch with play-money betting markets."
|
|
/>
|
|
<div className="px-4 pt-2 md:mt-0 lg:hidden">
|
|
<ManifoldLogo />
|
|
</div>
|
|
|
|
<Col className="max-w-3xl gap-8 rounded bg-white p-4 text-gray-600 shadow-md sm:mx-auto sm:p-10">
|
|
<TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} />
|
|
<TwitchChatCommands />
|
|
<SetUpBot user={user} privateUser={privateUser} />
|
|
</Col>
|
|
</Page>
|
|
)
|
|
}
|