Files

195 lines
8.1 KiB
TypeScript

'use client'
import { useEffect, useState, useSyncExternalStore } from 'react'
import { ArrowRight } from 'lucide-react'
import { Text, XStack, YStack } from '@hanzo/ui'
import { Action, Title } from '@hanzo/ui/marketing'
import { useI18n } from '@hanzo/i18n/react'
import { Box } from '@/components/ui'
import { Card, Quiet } from '@/components/beta/card'
import { Board } from '@/components/rewards/board'
import { LinkGithub } from '@/components/rewards/github'
import { Excluded, Levels, Posting, Redeemable, Rules } from '@/components/rewards/rules'
import { Redeem } from '@/components/rewards/redeem'
import { Posts, ShareRank } from '@/components/rewards/share'
import { Collection, Overview, Summary, Track } from '@/components/rewards/standing'
import { useGithub, useMe, useRules, useSigned } from '@/components/rewards/use'
import { LEADERBOARD } from '@/components/rewards/words'
import { mine } from '@/lib/hanzo/referrals'
/** The program's sections, in the order the navigation draws them; Bounties is its own page. */
const VIEWS = ['overview', 'earn', 'collection', 'leaderboard', 'redeem'] as const
type View = (typeof VIEWS)[number]
/** The section the address names (#earn); a press writes it there, so a link opens it and Back returns. */
const hash = () => window.location.hash.slice(1)
const listen = (change: () => void) => {
window.addEventListener('hashchange', change)
return () => window.removeEventListener('hashchange', change)
}
function useView(signed: boolean) {
const at = useSyncExternalStore(listen, hash, () => '')
const view: View = (VIEWS as readonly string[]).includes(at) ? (at as View) : signed ? 'overview' : 'leaderboard'
return [view, (v: View) => void (window.location.hash = v)] as const
}
/**
* /leaderboard — the home of Hanzo Rewards: the reader's standing, what earns,
* the badge collection, the board, the bounties and redeeming, each read from
* the cloud's rewards (GET /v1/rewards, /me, /rules, /posts, /redemptions).
* /referral, /beta and /bounty are views of the same points and link here.
*
* Signed out it opens on the public board (names only for people who listed
* them), with what earns and every badge locked. Signed in it opens on the
* reader's Overview: level, lifetime points, the next level and achievements,
* what to try next, their posts and their rank to share. Nothing on it is a
* figure the cloud did not answer.
*/
export default function Leaderboard() {
const { t } = useI18n()
const signed = useSigned()
const rules = useRules()
const { me, listed, track, show, again } = useMe(signed)
const gh = useGithub(signed, again)
const [view, go] = useView(signed)
const [code, setCode] = useState('')
useEffect(() => {
if (!signed) return
let live = true
mine()
.then((r) => live && setCode(r.code))
.catch(() => {})
return () => {
live = false
}
}, [signed])
const names: Record<View, string> = { overview: t('Overview'), earn: t('Earn'), collection: t('Collection'), leaderboard: t('Leaderboard'), redeem: t('Redeem') }
const join = (
<XStack gap={10} flexWrap="wrap" data-slot="leaderboard-join">
<Action href={`/signup?next=${encodeURIComponent('/leaderboard')}`} fill>
{t('Create account')}
<ArrowRight size={16} aria-hidden data-i18n-mirror />
</Action>
<Action href={`/login?next=${encodeURIComponent('/leaderboard')}`}>{t('Sign in')}</Action>
</XStack>
)
return (
<Box minH="100vh" pt={72} pb={96} px="var(--page-gutter)">
<YStack data-motion="rise" gap={16} width="100%" maxW={960} mx="auto" data-slot="leaderboard">
<Card gap={20} data-slot="rewards-standing">
<YStack gap={8}>
<Title render="h1">{t(LEADERBOARD.title)}</Title>
<Text fontSize="$4" color="var(--muted-foreground)">
{t(LEADERBOARD.line)}
</Text>
</YStack>
{signed && me ? <Summary me={me} /> : signed ? null : join}
<XStack gap={8} flexWrap="wrap" role="group" aria-label={t('Rewards')} data-slot="rewards-nav">
{VIEWS.slice(0, 4).map((v) => (
<Action key={v} render="button" type="button" fill={view === v} aria-pressed={view === v} onClick={() => go(v)}>
{names[v]}
</Action>
))}
<Action href="/bounty">{t('Bounties')}</Action>
<Action render="button" type="button" fill={view === 'redeem'} aria-pressed={view === 'redeem'} onClick={() => go('redeem')}>
{names.redeem}
</Action>
</XStack>
</Card>
{view === 'overview' ? (
signed ? (
me ? (
<>
<Overview me={me} rules={rules} />
{track ? <Track track={track} /> : null}
<Card gap={12}>
<LinkGithub signed={signed} {...gh} />
</Card>
<Posts rules={rules} signed={signed} />
<ShareRank me={me} code={code} rules={rules} />
</>
) : (
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Reading your standing…')}
</Text>
)
) : (
<Card gap={16}>
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Sign in to see your level, your points, the badges you have unlocked and what to try next.')}
</Text>
{join}
</Card>
)
) : null}
{view === 'earn' ? (
<Card gap={20} data-slot="leaderboard-how">
<Title quiet render="h2">
{t('How points are earned')}
</Title>
<Text fontSize="$3" color="var(--muted-foreground)">
{t('Every point is awarded by the cloud from something it can check: a task its usage ledger records, a builder your link brought who stays active, a merge or a label a maintainer made on GitHub. The table below is the one the cloud awards by.')}
</Text>
<Rules rules={rules} />
<YStack gap={8}>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
{t('Levels')}
</Text>
<Levels rules={rules} points={me?.points} />
</YStack>
<YStack gap={8}>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
{t('Posts')}
</Text>
<Posting rules={rules} />
</YStack>
<YStack gap={8}>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
{t('What points are')}
</Text>
<Redeemable rules={rules} />
</YStack>
<Excluded rules={rules} />
<YStack gap={8}>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
{t('Link your GitHub account')}
</Text>
<Text fontSize="$2" color="var(--muted-foreground)">
{t('Merged pull requests and accepted issues count for the GitHub account linked to yours. Work done before you link is held for that GitHub account and joins your points when you link it.')}
</Text>
<LinkGithub signed={signed} {...gh} />
</YStack>
</Card>
) : null}
{view === 'collection' ? <Collection me={signed ? me : null} rules={rules} /> : null}
{view === 'leaderboard' ? <Board rules={rules} signed={signed} listed={listed} show={show} /> : null}
{view === 'redeem' ? (
signed && me ? (
<Redeem me={me} rules={rules} done={again} />
) : (
<Card gap={16} data-slot="rewards-redeem">
<Title quiet render="h2">
{names.redeem}
</Title>
<Redeemable rules={rules} />
{signed ? null : join}
</Card>
)
) : null}
<XStack gap={20} flexWrap="wrap">
<Quiet href="/bounty">{t('Bounties')}</Quiet>
<Quiet href="/referral">{t('Referrals')}</Quiet>
<Quiet href="/beta">{t('The beta')}</Quiet>
</XStack>
</YStack>
</Box>
)
}