show sources and history of rewards
This commit is contained in:
		
							parent
							
								
									679f766f07
								
							
						
					
					
						commit
						e4831e65d5
					
				@ -5,33 +5,64 @@ import { ANON_USER_ID } from '../../lib/constants'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  Query: {
 | 
			
		||||
    expectedRewards: async (parent, args, { models }) => {
 | 
			
		||||
    rewards: async (parent, { when }, { models }) => {
 | 
			
		||||
      if (when && isNaN(new Date(when))) {
 | 
			
		||||
        throw new GraphQLError('invalid date', { extensions: { code: 'BAD_USER_INPUT' } })
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [result] = await models.$queryRaw`
 | 
			
		||||
        SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
 | 
			
		||||
        WITH day_cte (day) AS (
 | 
			
		||||
          SELECT COALESCE(${when}::text::timestamp - interval '1 day', date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
 | 
			
		||||
        )
 | 
			
		||||
        SELECT coalesce(FLOOR(sum(sats)), 0) as total,
 | 
			
		||||
          COALESCE(${when}::text::timestamp, date_trunc('day', (now() + interval '1 day') AT TIME ZONE 'America/Chicago')) as time,
 | 
			
		||||
          json_build_array(
 | 
			
		||||
            json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)),
 | 
			
		||||
            json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION', 'ANON'))), 0)),
 | 
			
		||||
            json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
 | 
			
		||||
            json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0)),
 | 
			
		||||
          json_build_object('name', 'anon earnings', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0))
 | 
			
		||||
            json_build_object('name', 'anon''s stack', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'ANON')), 0))
 | 
			
		||||
        ) AS sources
 | 
			
		||||
        FROM (
 | 
			
		||||
        FROM day_cte
 | 
			
		||||
        CROSS JOIN LATERAL (
 | 
			
		||||
          (SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type
 | 
			
		||||
            FROM "ItemAct"
 | 
			
		||||
            LEFT JOIN "ReferralAct" ON "ReferralAct"."itemActId" = "ItemAct".id
 | 
			
		||||
            WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', now() AT TIME ZONE 'America/Chicago') AND "ItemAct".act <> 'TIP')
 | 
			
		||||
            WHERE date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day AND "ItemAct".act <> 'TIP')
 | 
			
		||||
            UNION ALL
 | 
			
		||||
          (SELECT sats::FLOAT, 'DONATION' as type
 | 
			
		||||
            FROM "Donation"
 | 
			
		||||
            WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
 | 
			
		||||
            WHERE date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
 | 
			
		||||
            UNION ALL
 | 
			
		||||
          (SELECT "ItemAct".msats / 1000.0 as sats, 'ANON' as type
 | 
			
		||||
            FROM "Item"
 | 
			
		||||
            JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
 | 
			
		||||
            WHERE "Item"."userId" = ${ANON_USER_ID} AND "ItemAct".act = 'TIP' AND "Item"."fwdUserId" IS NULL
 | 
			
		||||
            AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = date_trunc('day', now() AT TIME ZONE 'America/Chicago'))
 | 
			
		||||
            AND date_trunc('day', "ItemAct".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day)
 | 
			
		||||
        ) subquery`
 | 
			
		||||
 | 
			
		||||
      return result || { total: 0, sources: [] }
 | 
			
		||||
      return result || { total: 0, time: 0, sources: [] }
 | 
			
		||||
    },
 | 
			
		||||
    meRewards: async (parent, { when }, { me, models }) => {
 | 
			
		||||
      if (!me) {
 | 
			
		||||
        return null
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const [result] = await models.$queryRaw`
 | 
			
		||||
        WITH day_cte (day) AS (
 | 
			
		||||
          SELECT date_trunc('day', ${when}::text::timestamp AT TIME ZONE 'America/Chicago')
 | 
			
		||||
        )
 | 
			
		||||
        SELECT coalesce(sum(sats), 0) as total, json_agg("Earn".*) as rewards
 | 
			
		||||
        FROM day_cte
 | 
			
		||||
        CROSS JOIN LATERAL (
 | 
			
		||||
          (SELECT FLOOR("Earn".msats / 1000.0) as sats, type, rank
 | 
			
		||||
            FROM "Earn"
 | 
			
		||||
            WHERE "Earn"."userId" = ${me.id}
 | 
			
		||||
            AND date_trunc('day', "Earn".created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago') = day_cte.day
 | 
			
		||||
            ORDER BY "Earn".msats DESC)
 | 
			
		||||
        ) "Earn"`
 | 
			
		||||
 | 
			
		||||
      return result
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  Mutation: {
 | 
			
		||||
 | 
			
		||||
@ -2,15 +2,28 @@ import { gql } from 'graphql-tag'
 | 
			
		||||
 | 
			
		||||
export default gql`
 | 
			
		||||
  extend type Query {
 | 
			
		||||
    expectedRewards: ExpectedRewards!
 | 
			
		||||
    rewards(when: String): Rewards!
 | 
			
		||||
    meRewards(when: String!): MeRewards
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  extend type Mutation {
 | 
			
		||||
    donateToRewards(sats: Int!): Int!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type ExpectedRewards {
 | 
			
		||||
  type Rewards {
 | 
			
		||||
    total: Int!
 | 
			
		||||
    time: Date!
 | 
			
		||||
    sources: [NameValue!]!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type Reward {
 | 
			
		||||
    type: String
 | 
			
		||||
    rank: Int
 | 
			
		||||
    sats: Int!
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type MeRewards {
 | 
			
		||||
    total: Int!
 | 
			
		||||
    rewards: [Reward!]
 | 
			
		||||
  }
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
@ -179,15 +179,19 @@ export function WhenComposedChart ({
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function GrowthPieChart ({ data }) {
 | 
			
		||||
  const nonZeroData = data.filter(d => d.value > 0)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ResponsiveContainer width='100%' height={250} minWidth={200}>
 | 
			
		||||
      <PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
 | 
			
		||||
        <Pie
 | 
			
		||||
          dataKey='value'
 | 
			
		||||
          isAnimationActive={false}
 | 
			
		||||
          data={data}
 | 
			
		||||
          data={nonZeroData}
 | 
			
		||||
          cx='50%'
 | 
			
		||||
          cy='50%'
 | 
			
		||||
          minAngle={5}
 | 
			
		||||
          paddingAngle={0}
 | 
			
		||||
          outerRadius={80}
 | 
			
		||||
          fill='var(--bs-secondary)'
 | 
			
		||||
          label
 | 
			
		||||
 | 
			
		||||
@ -5,14 +5,14 @@ import { SSR } from '../lib/constants'
 | 
			
		||||
 | 
			
		||||
const REWARDS = gql`
 | 
			
		||||
{
 | 
			
		||||
  expectedRewards {
 | 
			
		||||
  rewards {
 | 
			
		||||
    total
 | 
			
		||||
  }
 | 
			
		||||
}`
 | 
			
		||||
 | 
			
		||||
export default function Rewards () {
 | 
			
		||||
  const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
 | 
			
		||||
  const total = data?.expectedRewards?.total
 | 
			
		||||
  const total = data?.rewards?.total
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
 | 
			
		||||
 | 
			
		||||
@ -73,7 +73,7 @@ function NotificationLayout ({ children, nid, href, as, fresh }) {
 | 
			
		||||
 | 
			
		||||
const defaultOnClick = n => {
 | 
			
		||||
  const type = n.__typename
 | 
			
		||||
  if (type === 'Earn') return {}
 | 
			
		||||
  if (type === 'Earn') return { href: `/rewards/${new Date(n.sortTime).toISOString().slice(0, 10)}` }
 | 
			
		||||
  if (type === 'Invitification') return { href: '/invites' }
 | 
			
		||||
  if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
 | 
			
		||||
  if (type === 'Referral') return { href: '/referrals/month' }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										33
									
								
								fragments/rewards.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								fragments/rewards.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
import gql from 'graphql-tag'
 | 
			
		||||
 | 
			
		||||
export const REWARDS = gql`
 | 
			
		||||
  query rewards($when: String) {
 | 
			
		||||
    rewards(when: $when) {
 | 
			
		||||
      total
 | 
			
		||||
      time
 | 
			
		||||
      sources {
 | 
			
		||||
        name
 | 
			
		||||
        value
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }`
 | 
			
		||||
 | 
			
		||||
export const ME_REWARDS = gql`
 | 
			
		||||
  query meRewards($when: String) {
 | 
			
		||||
    rewards(when: $when) {
 | 
			
		||||
      total
 | 
			
		||||
      time
 | 
			
		||||
      sources {
 | 
			
		||||
        name
 | 
			
		||||
        value
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    meRewards(when: $when) {
 | 
			
		||||
      total
 | 
			
		||||
      rewards {
 | 
			
		||||
        type
 | 
			
		||||
        rank
 | 
			
		||||
        sats
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }`
 | 
			
		||||
							
								
								
									
										78
									
								
								pages/rewards/[when].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								pages/rewards/[when].js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,78 @@
 | 
			
		||||
import { useQuery } from '@apollo/client'
 | 
			
		||||
import PageLoading from '../../components/page-loading'
 | 
			
		||||
import { ME_REWARDS } from '../../fragments/rewards'
 | 
			
		||||
import { CenterLayout } from '../../components/layout'
 | 
			
		||||
import dynamic from 'next/dynamic'
 | 
			
		||||
import { useRouter } from 'next/router'
 | 
			
		||||
import { getGetServerSideProps } from '../../api/ssrApollo'
 | 
			
		||||
import { fixedDecimal } from '../../lib/format'
 | 
			
		||||
import Trophy from '../../svgs/trophy-fill.svg'
 | 
			
		||||
 | 
			
		||||
const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), {
 | 
			
		||||
  loading: () => <div>Loading...</div>
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const getServerSideProps = getGetServerSideProps(ME_REWARDS, null,
 | 
			
		||||
  (data, params) => data.rewards.total === 0 || new Date(data.rewards.time) > new Date())
 | 
			
		||||
 | 
			
		||||
const timeString = when => new Date(when).toISOString().slice(0, 10)
 | 
			
		||||
 | 
			
		||||
export default function Rewards ({ ssrData }) {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const { data } = useQuery(ME_REWARDS, { variables: { ...router.query } })
 | 
			
		||||
  if (!data && !ssrData) return <PageLoading />
 | 
			
		||||
 | 
			
		||||
  const { rewards: { total, sources, time }, meRewards } = data || ssrData
 | 
			
		||||
  const when = router.query.when
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <CenterLayout footerLinks>
 | 
			
		||||
      <div className='py-3'>
 | 
			
		||||
        <h4 className='fw-bold text-muted ps-0'>
 | 
			
		||||
          {when && <div className='text-muted fst-italic fs-6 fw-normal pb-1'>On {timeString(time)} at 12a CT</div>}
 | 
			
		||||
          {total} sats were rewarded
 | 
			
		||||
        </h4>
 | 
			
		||||
        <div className='my-3 w-100'>
 | 
			
		||||
          <GrowthPieChart data={sources} />
 | 
			
		||||
        </div>
 | 
			
		||||
        {meRewards &&
 | 
			
		||||
          <>
 | 
			
		||||
            <h4 className='fw-bold text-muted text-center'>
 | 
			
		||||
              you earned {meRewards.total} sats ({fixedDecimal(meRewards.total * 100 / total, 2)}%)
 | 
			
		||||
            </h4>
 | 
			
		||||
            <div>
 | 
			
		||||
              {meRewards.rewards?.map((r, i) => <Reward key={[r.rank, r.type].join('-')} {...r} />)}
 | 
			
		||||
            </div>
 | 
			
		||||
          </>}
 | 
			
		||||
      </div>
 | 
			
		||||
    </CenterLayout>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Reward ({ rank, type, sats }) {
 | 
			
		||||
  if (!rank) return null
 | 
			
		||||
 | 
			
		||||
  const color = rank <= 3 ? 'text-primary' : 'text-muted'
 | 
			
		||||
 | 
			
		||||
  let category = type
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case 'TIP_POST':
 | 
			
		||||
      category = 'in post zapping'
 | 
			
		||||
      break
 | 
			
		||||
    case 'TIP_COMMENT':
 | 
			
		||||
      category = 'in comment zapping'
 | 
			
		||||
      break
 | 
			
		||||
    case 'POST':
 | 
			
		||||
      category = 'among posts'
 | 
			
		||||
      break
 | 
			
		||||
    case 'COMMENT':
 | 
			
		||||
      category = 'among comments'
 | 
			
		||||
      break
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={color}>
 | 
			
		||||
      <Trophy height={20} width={20} /> <b>#{rank}</b> {category} for <i><b>{sats} sats</b></i>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@ -2,26 +2,26 @@ import { gql } from 'graphql-tag'
 | 
			
		||||
import { useMemo } from 'react'
 | 
			
		||||
import Button from 'react-bootstrap/Button'
 | 
			
		||||
import InputGroup from 'react-bootstrap/InputGroup'
 | 
			
		||||
import { getGetServerSideProps } from '../api/ssrApollo'
 | 
			
		||||
import { Form, Input, SubmitButton } from '../components/form'
 | 
			
		||||
import { CenterLayout } from '../components/layout'
 | 
			
		||||
import { getGetServerSideProps } from '../../api/ssrApollo'
 | 
			
		||||
import { Form, Input, SubmitButton } from '../../components/form'
 | 
			
		||||
import { CenterLayout } from '../../components/layout'
 | 
			
		||||
import { useMutation, useQuery } from '@apollo/client'
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
import { amountSchema } from '../lib/validate'
 | 
			
		||||
import { amountSchema } from '../../lib/validate'
 | 
			
		||||
import Countdown from 'react-countdown'
 | 
			
		||||
import { numWithUnits } from '../lib/format'
 | 
			
		||||
import PageLoading from '../components/page-loading'
 | 
			
		||||
import { useShowModal } from '../components/modal'
 | 
			
		||||
import { numWithUnits } from '../../lib/format'
 | 
			
		||||
import PageLoading from '../../components/page-loading'
 | 
			
		||||
import { useShowModal } from '../../components/modal'
 | 
			
		||||
import dynamic from 'next/dynamic'
 | 
			
		||||
import { SSR } from '../lib/constants'
 | 
			
		||||
import { SSR } from '../../lib/constants'
 | 
			
		||||
 | 
			
		||||
const GrowthPieChart = dynamic(() => import('../components/charts').then(mod => mod.GrowthPieChart), {
 | 
			
		||||
const GrowthPieChart = dynamic(() => import('../../components/charts').then(mod => mod.GrowthPieChart), {
 | 
			
		||||
  loading: () => <div>Loading...</div>
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const REWARDS = gql`
 | 
			
		||||
{
 | 
			
		||||
  expectedRewards {
 | 
			
		||||
  rewards {
 | 
			
		||||
    total
 | 
			
		||||
    sources {
 | 
			
		||||
      name
 | 
			
		||||
@ -66,7 +66,7 @@ export default function Rewards ({ ssrData }) {
 | 
			
		||||
  const { data } = useQuery(REWARDS, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
 | 
			
		||||
  if (!data && !ssrData) return <PageLoading />
 | 
			
		||||
 | 
			
		||||
  const { expectedRewards: { total, sources } } = data || ssrData
 | 
			
		||||
  const { rewards: { total, sources } } = data || ssrData
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <CenterLayout footerLinks>
 | 
			
		||||
@ -74,7 +74,7 @@ export default function Rewards ({ ssrData }) {
 | 
			
		||||
        <div>
 | 
			
		||||
          <RewardLine total={total} />
 | 
			
		||||
        </div>
 | 
			
		||||
        <Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-reset'>
 | 
			
		||||
        <Link href='/faq#how-do-i-earn-sats-on-stacker-news' className='text-info fw-normal'>
 | 
			
		||||
          <small><small><small>learn about rewards</small></small></small>
 | 
			
		||||
        </Link>
 | 
			
		||||
      </h4>
 | 
			
		||||
@ -87,9 +87,9 @@ function Satus ({ status }) {
 | 
			
		||||
function Detail ({ fact }) {
 | 
			
		||||
  if (fact.type === 'earn') {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='px-3' style={{ lineHeight: '140%' }}>
 | 
			
		||||
        SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards'>here</Link>.
 | 
			
		||||
      </div>
 | 
			
		||||
      <Link href={`/rewards/${new Date(fact.createdAt).toISOString().slice(0, 10)}`} className='px-3 text-reset' style={{ lineHeight: '140%' }}>
 | 
			
		||||
        SN distributes the sats it earns back to its best stackers daily. These sats come from <Link href='/~jobs'>jobs</Link>, boosts, posting fees, and donations.
 | 
			
		||||
      </Link>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
  if (fact.type === 'donation') {
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,7 @@ $theme-colors: (
 | 
			
		||||
  "grey" : #e9ecef,
 | 
			
		||||
  "grey-medium" : #d2d2d2,
 | 
			
		||||
  "grey-darkmode": #8c8c8c,
 | 
			
		||||
  "nostr": #8d45dd,
 | 
			
		||||
  "nostr": #8d45dd
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
$body-bg: #fcfcff;
 | 
			
		||||
@ -167,6 +167,10 @@ $grid-gutter-width: 2rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-primary svg {
 | 
			
		||||
  fill: var(--bs-primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.line-height-1 {
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								svgs/trophy-fill.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								svgs/trophy-fill.svg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.0049 16.941V19.0029H18.0049V21.0029H6.00488V19.0029H11.0049V16.941C7.05857 16.4489 4.00488 13.0825 4.00488 9.00293V3.00293H20.0049V9.00293C20.0049 13.0825 16.9512 16.4489 13.0049 16.941ZM1.00488 5.00293H3.00488V9.00293H1.00488V5.00293ZM21.0049 5.00293H23.0049V9.00293H21.0049V5.00293Z"></path></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 372 B  | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user