diff --git a/web/components/profile/twitch-panel.tsx b/web/components/profile/twitch-panel.tsx index b284b242..a37b21dc 100644 --- a/web/components/profile/twitch-panel.tsx +++ b/web/components/profile/twitch-panel.tsx @@ -6,38 +6,101 @@ import { LinkIcon } from '@heroicons/react/solid' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { updatePrivateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' +import { + linkTwitchAccountRedirect, + updateBotEnabledForUser, +} from 'web/lib/twitch/link-twitch-account' import { copyToClipboard } from 'web/lib/util/copy' import { Button, ColorType } from './../button' import { Row } from './../layout/row' import { LoadingIndicator } from './../loading-indicator' +import { PrivateUser } from 'common/user' function BouncyButton(props: { children: ReactNode onClick?: MouseEventHandler<any> color?: ColorType + className?: string }) { - const { children, onClick, color } = props + const { children, onClick, color, className } = props return ( <Button color={color} size="lg" onClick={onClick} - className="btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case" + className={clsx( + 'btn h-[inherit] flex-shrink-[inherit] border-none font-normal normal-case', + className + )} > {children} </Button> ) } +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 }, + }) + ), + { loading: 'Updating bot settings...', error, success } + ) + try { + } finally { + setLoading(false) + } + } + + return ( + <> + {privateUser?.twitchInfo?.botEnabled ? ( + <BouncyButton + color="red" + onClick={updateBotConnected(false)} + className={clsx(loading && 'btn-disabled')} + > + Remove bot from your channel + </BouncyButton> + ) : ( + <BouncyButton + color="green" + onClick={updateBotConnected(true)} + className={clsx(loading && 'btn-disabled')} + > + Add bot to your channel + </BouncyButton> + )} + </> + ) +} + export function TwitchPanel() { const user = useUser() const privateUser = usePrivateUser() const twitchInfo = privateUser?.twitchInfo - const twitchName = privateUser?.twitchInfo?.twitchName - const twitchToken = privateUser?.twitchInfo?.controlToken - const twitchBotConnected = privateUser?.twitchInfo?.botEnabled + const twitchName = twitchInfo?.twitchName + const twitchToken = twitchInfo?.controlToken const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" /> @@ -55,13 +118,6 @@ export function TwitchPanel() { }) } - const updateBotConnected = (connected: boolean) => async () => { - if (user && twitchInfo) { - twitchInfo.botEnabled = connected - await updatePrivateUser(user.id, { twitchInfo }) - } - } - const [twitchLoading, setTwitchLoading] = useState(false) const createLink = async () => { @@ -115,17 +171,12 @@ export function TwitchPanel() { <BouncyButton color="indigo" onClick={copyDockLink}> Copy dock link </BouncyButton> - {twitchBotConnected ? ( - <BouncyButton color="red" onClick={updateBotConnected(false)}> - Remove bot from your channel - </BouncyButton> - ) : ( - <BouncyButton color="green" onClick={updateBotConnected(true)}> - Add bot to your channel - </BouncyButton> - )} </div> </div> + <div className="mt-4" /> + <div className="flex w-full"> + <BotConnectButton privateUser={privateUser} /> + </div> </div> )} </> diff --git a/web/lib/twitch/link-twitch-account.ts b/web/lib/twitch/link-twitch-account.ts index 36fb12b5..71bc847d 100644 --- a/web/lib/twitch/link-twitch-account.ts +++ b/web/lib/twitch/link-twitch-account.ts @@ -3,29 +3,33 @@ import { generateNewApiKey } from '../api/api-key' const TWITCH_BOT_PUBLIC_URL = 'https://king-prawn-app-5btyw.ondigitalocean.app' // TODO: Add this to env config appropriately +async function postToBot(url: string, body: unknown) { + const result = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const json = await result.json() + if (!result.ok) { + throw new Error(json.message) + } else { + return json + } +} + export async function initLinkTwitchAccount( manifoldUserID: string, manifoldUserAPIKey: string ): Promise<[string, Promise<{ twitchName: string; controlToken: string }>]> { - const response = await fetch(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - manifoldID: manifoldUserID, - apiKey: manifoldUserAPIKey, - redirectURL: window.location.href, - }), + const response = await postToBot(`${TWITCH_BOT_PUBLIC_URL}/api/linkInit`, { + manifoldID: manifoldUserID, + apiKey: manifoldUserAPIKey, + redirectURL: window.location.href, }) - const responseData = await response.json() - if (!response.ok) { - throw new Error(responseData.message) - } const responseFetch = fetch( `${TWITCH_BOT_PUBLIC_URL}/api/linkResult?userID=${manifoldUserID}` ) - return [responseData.twitchAuthURL, responseFetch.then((r) => r.json())] + return [response.twitchAuthURL, responseFetch.then((r) => r.json())] } export async function linkTwitchAccountRedirect( @@ -39,3 +43,22 @@ export async function linkTwitchAccountRedirect( window.location.href = twitchAuthURL } + +export async function updateBotEnabledForUser( + privateUser: PrivateUser, + botEnabled: boolean +) { + if (botEnabled) { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/registerchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } else { + return postToBot(`${TWITCH_BOT_PUBLIC_URL}/unregisterchanneltwitch`, { + apiKey: privateUser.apiKey, + }).then((r) => { + if (!r.success) throw new Error(r.message) + }) + } +} diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index 6b70b5d2..44a63b2d 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' - +import { useRouter } from 'next/router' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' import { SEO } from 'web/components/SEO' @@ -64,6 +64,7 @@ function EditUserField(props: { export default function ProfilePage(props: { auth: { user: User; privateUser: PrivateUser } }) { + const router = useRouter() const { user, privateUser } = props.auth const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarLoading, setAvatarLoading] = useState(false) @@ -237,8 +238,7 @@ export default function ProfilePage(props: { </button> </div> </div> - - <TwitchPanel /> + {router.query.twitch && <TwitchPanel />} </Col> </Col> </Page> diff --git a/web/pages/twitch.tsx b/web/pages/twitch.tsx index 7ca892e8..a21c1105 100644 --- a/web/pages/twitch.tsx +++ b/web/pages/twitch.tsx @@ -1,29 +1,34 @@ +import { PrivateUser, User } from 'common/user' +import Link from 'next/link' import { useState } from 'react' -import { Page } from 'web/components/page' +import toast from 'react-hot-toast' +import { Button } from 'web/components/button' import { Col } from 'web/components/layout/col' -import { ManifoldLogo } from 'web/components/nav/manifold-logo' -import { useSaveReferral } from 'web/hooks/use-save-referral' -import { SEO } from 'web/components/SEO' +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, getUserAndPrivateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' -import { Button } from 'web/components/button' -import { useTracking } from 'web/hooks/use-tracking' import { linkTwitchAccountRedirect } from 'web/lib/twitch/link-twitch-account' -import { usePrivateUser, useUser } from 'web/hooks/use-user' -import { LoadingIndicator } from 'web/components/loading-indicator' -import toast from 'react-hot-toast' -export default function TwitchLandingPage() { - useSaveReferral() - useTracking('view twitch landing page') +function TwitchPlaysManifoldMarkets(props: { + user?: User | null + privateUser?: PrivateUser | null +}) { + const { user, privateUser } = props - const user = useUser() - const privateUser = usePrivateUser() const twitchUser = privateUser?.twitchInfo?.twitchName + const [isLoading, setLoading] = useState(false) + const callback = user && privateUser ? () => linkTwitchAccountRedirect(user, privateUser) @@ -37,8 +42,6 @@ export default function TwitchLandingPage() { await linkTwitchAccountRedirect(user, privateUser) } - const [isLoading, setLoading] = useState(false) - const getStarted = async () => { try { setLoading(true) @@ -53,6 +56,191 @@ export default function TwitchLandingPage() { } } + 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="gap-4"> + <div> + Similar to Twitch channel point predictions, Manifold Markets allows + you to create and feature on stream any question you like with users + predicting to earn play money. + </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> + Start playing now by logging in with Google and typing commands in chat! + {twitchUser ? ( + <Button size="xl" color="green" className="btn-disabled self-center"> + Account connected: {twitchUser} + </Button> + ) : isLoading ? ( + <LoadingIndicator spinnerClassName="!w-11 !h-11" /> + ) : ( + <Button + size="xl" + color="gradient" + className="my-4 self-center !px-16" + onClick={getStarted} + > + Start playing + </Button> + )} + <div> + Instead of Twitch channel points we use our play money, mana (m$). All + viewers start with M$1000 and more can be earned for free and then{' '} + <Link href="/charity" className="underline"> + donated to a charity + </Link>{' '} + of their choice at no cost! + </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="bet yes#" desc="Bets a # of Mana on yes." /> + <Command command="bet no#" desc="Bets a # of Mana on no." /> + <Command + command="sell" + desc="Sells all shares you own. Using this command causes you to + cash out early before the market resolves. This could be profitable + (if the probability has moved towards the direction you bet) or cause + a loss, although at least you keep some Mana. For maximum profit (but + also risk) it is better to not sell and wait for a favourable + resolution." + /> + <Command command="balance" desc="Shows how much Mana you own." /> + <Command command="allin yes" desc="Bets your entire balance on yes." /> + <Command command="allin no" desc="Bets your entire balance on no." /> + + <Subtitle text="For Mods/Streamer" /> + <Command + command="create <question>" + desc="Creates and features the question. Be careful... this will override 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 n/a" + desc="Resolves the market as 'N/A' and refunds everyone their Mana." + /> + </Col> + </div> + ) +} + +function BotSetupStep(props: { + stepNum: number + buttonName?: string + text: string +}) { + const { stepNum, buttonName, text } = props + return ( + <Col className="flex-1"> + {buttonName && ( + <> + <Button color="green">{buttonName}</Button> + <Spacer h={4} /> + </> + )} + <div> + <p className="inline font-bold">Step {stepNum}. </p> + {text} + </div> + </Col> + ) +} + +function SetUpBot(props: { privateUser?: PrivateUser | null }) { + const { privateUser } = props + const twitchLinked = privateUser?.twitchInfo?.twitchName + return ( + <> + <Title + text={'Set up the bot for your own stream'} + className={'!mb-4 md:block'} + /> + <Col className="gap-4"> + <img + src="https://raw.githubusercontent.com/PhilBladen/ManifoldTwitchIntegration/master/docs/OBS.png" + className="!-my-2" + ></img> + To add the bot to your stream make sure you have logged in then follow + the steps below. + {!twitchLinked && ( + <Button + size="xl" + color="gradient" + className="my-4 self-center !px-16" + // onClick={getStarted} + > + Start playing + </Button> + )} + <div className="flex flex-col gap-6 sm:flex-row"> + <BotSetupStep + stepNum={1} + buttonName={twitchLinked && 'Add bot to channel'} + text="Use the button above to add the bot to your channel. Then mod it by typing in your Twitch chat: /mod ManifoldBot (or whatever you named the bot) If the bot is modded it will not work properly on the backend." + /> + <BotSetupStep + stepNum={2} + buttonName={twitchLinked && 'Overlay link'} + text="Create a new browser source in your streaming software such as OBS. Paste in the above link and resize it to your liking. We recommend setting the size to 400x400." + /> + <BotSetupStep + stepNum={3} + buttonName={twitchLinked && 'Control dock link'} + text="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." + /> + </div> + </Col> + </> + ) +} + +export default function TwitchLandingPage() { + useSaveReferral() + useTracking('view twitch landing page') + + const user = useUser() + const privateUser = usePrivateUser() + return ( <Page> <SEO @@ -62,7 +250,7 @@ export default function TwitchLandingPage() { <div className="px-4 pt-2 md:mt-0 lg:hidden"> <ManifoldLogo /> </div> - <Col className="items-center"> + {/* <Col className="items-center"> <Col className="max-w-3xl"> <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> <Row className="self-center"> @@ -114,6 +302,12 @@ export default function TwitchLandingPage() { )} </Col> </Col> + </Col> */} + + <Col className="max-w-3xl gap-8 rounded bg-white p-10 text-gray-600 shadow-md sm:mx-auto"> + <TwitchPlaysManifoldMarkets user={user} privateUser={privateUser} /> + <TwitchChatCommands /> + <SetUpBot privateUser={privateUser} /> </Col> </Page> ) diff --git a/web/public/twitch-glitch.svg b/web/public/twitch-glitch.svg new file mode 100644 index 00000000..3120fea7 --- /dev/null +++ b/web/public/twitch-glitch.svg @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill:#9146FF;} +</style> +<title>Asset 2</title> +<g> + <polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/> + <g> + <g id="Layer_1-2"> + <path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600 + V1300z"/> + <rect x="1700" y="550" class="st1" width="200" height="600"/> + <rect x="1150" y="550" class="st1" width="200" height="600"/> + </g> + </g> +</g> +</svg>