referrals
This commit is contained in:
parent
4282f6724f
commit
41226245c5
|
@ -1,6 +1,6 @@
|
|||
const PLACEHOLDERS_NUM = 616
|
||||
|
||||
function interval (when) {
|
||||
export function interval (when) {
|
||||
switch (when) {
|
||||
case 'week':
|
||||
return '1 week'
|
||||
|
@ -15,7 +15,7 @@ function interval (when) {
|
|||
}
|
||||
}
|
||||
|
||||
function timeUnit (when) {
|
||||
export function timeUnit (when) {
|
||||
switch (when) {
|
||||
case 'week':
|
||||
case 'month':
|
||||
|
@ -28,7 +28,7 @@ function timeUnit (when) {
|
|||
}
|
||||
}
|
||||
|
||||
function withClause (when) {
|
||||
export function withClause (when) {
|
||||
const ival = interval(when)
|
||||
const unit = timeUnit(when)
|
||||
|
||||
|
@ -44,7 +44,7 @@ function withClause (when) {
|
|||
}
|
||||
|
||||
// HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables
|
||||
function intervalClause (when, table, and) {
|
||||
export function intervalClause (when, table, and) {
|
||||
if (when === 'forever') {
|
||||
return and ? '' : 'TRUE'
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ export default {
|
|||
return await models.$queryRaw(
|
||||
`${withClause(when)}
|
||||
SELECT time, json_build_array(
|
||||
json_build_object('name', 'invited', 'value', count("inviteId")),
|
||||
json_build_object('name', 'referrals', 'value', count("referrerId")),
|
||||
json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId"))
|
||||
) AS data
|
||||
FROM times
|
||||
|
@ -131,7 +131,8 @@ export default {
|
|||
json_build_object('name', 'any', 'value', count(distinct user_id)),
|
||||
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
|
||||
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
|
||||
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN'))
|
||||
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
|
||||
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL'))
|
||||
) AS data
|
||||
FROM times
|
||||
LEFT JOIN
|
||||
|
@ -142,7 +143,11 @@ export default {
|
|||
UNION ALL
|
||||
(SELECT created_at, "userId" as user_id, 'EARN' as type
|
||||
FROM "Earn"
|
||||
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||
WHERE ${intervalClause(when, 'Earn', false)})
|
||||
UNION ALL
|
||||
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
|
||||
FROM "ReferralAct"
|
||||
WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||
GROUP BY time
|
||||
ORDER BY time ASC`)
|
||||
},
|
||||
|
@ -152,18 +157,24 @@ export default {
|
|||
SELECT time, json_build_array(
|
||||
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
|
||||
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
|
||||
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0))
|
||||
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
|
||||
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0))
|
||||
) AS data
|
||||
FROM times
|
||||
LEFT JOIN
|
||||
((SELECT "ItemAct".created_at, 0 as airdrop,
|
||||
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
|
||||
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post
|
||||
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
|
||||
0 as referral
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
|
||||
UNION ALL
|
||||
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment
|
||||
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
|
||||
FROM "ReferralAct"
|
||||
WHERE ${intervalClause(when, 'ReferralAct', false)})
|
||||
UNION ALL
|
||||
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
|
||||
FROM "Earn"
|
||||
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||
GROUP BY time
|
||||
|
|
|
@ -10,7 +10,8 @@ import upload from './upload'
|
|||
import growth from './growth'
|
||||
import search from './search'
|
||||
import rewards from './rewards'
|
||||
import referrals from './referrals'
|
||||
import { GraphQLJSONObject } from 'graphql-type-json'
|
||||
|
||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||
upload, growth, search, rewards, { JSONObject: GraphQLJSONObject }]
|
||||
upload, growth, search, rewards, referrals, { JSONObject: GraphQLJSONObject }]
|
||||
|
|
|
@ -160,6 +160,15 @@ export default {
|
|||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
queries.push(
|
||||
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
|
||||
'Referral' AS type
|
||||
FROM users
|
||||
WHERE "users"."referrerId" = $1
|
||||
AND "inviteId" IS NULL
|
||||
AND users.created_at <= $2
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
}
|
||||
|
||||
if (meFull.noteEarning) {
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import { AuthenticationError } from 'apollo-server-micro'
|
||||
import { withClause, intervalClause, timeUnit } from './growth'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
referrals: async (parent, { when }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new AuthenticationError('you must be logged in')
|
||||
}
|
||||
|
||||
const [{ totalSats }] = await models.$queryRaw(`
|
||||
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
|
||||
FROM "ReferralAct"
|
||||
WHERE ${intervalClause(when, 'ReferralAct', true)}
|
||||
"ReferralAct"."referrerId" = $1
|
||||
`, Number(me.id))
|
||||
|
||||
const [{ totalReferrals }] = await models.$queryRaw(`
|
||||
SELECT count(*) as "totalReferrals"
|
||||
FROM users
|
||||
WHERE ${intervalClause(when, 'users', true)}
|
||||
"referrerId" = $1
|
||||
`, Number(me.id))
|
||||
|
||||
const stats = await models.$queryRaw(
|
||||
`${withClause(when)}
|
||||
SELECT time, json_build_array(
|
||||
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
|
||||
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
|
||||
) AS data
|
||||
FROM times
|
||||
LEFT JOIN
|
||||
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
|
||||
FROM "ReferralAct"
|
||||
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
||||
WHERE ${intervalClause(when, 'ReferralAct', true)}
|
||||
"ReferralAct"."referrerId" = $1)
|
||||
UNION ALL
|
||||
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
|
||||
FROM users
|
||||
WHERE ${intervalClause(when, 'users', true)}
|
||||
"referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
|
||||
GROUP BY time
|
||||
ORDER BY time ASC`, Number(me.id))
|
||||
|
||||
console.log(totalSats)
|
||||
|
||||
return {
|
||||
totalSats,
|
||||
totalReferrals,
|
||||
stats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,18 +12,19 @@ export default {
|
|||
})
|
||||
|
||||
const [result] = await models.$queryRaw`
|
||||
SELECT coalesce(sum(sats), 0) as total, json_build_array(
|
||||
json_build_object('name', 'donations', 'value', coalesce(sum(sats) FILTER(WHERE type = 'DONATION'), 0)),
|
||||
json_build_object('name', 'fees', 'value', coalesce(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION')), 0)),
|
||||
json_build_object('name', 'boost', 'value', coalesce(sum(sats) FILTER(WHERE type = 'BOOST'), 0)),
|
||||
json_build_object('name', 'jobs', 'value', coalesce(sum(sats) FILTER(WHERE type = 'STREAM'), 0))
|
||||
SELECT coalesce(FLOOR(sum(sats)), 0) as total, 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'))), 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))
|
||||
) AS sources
|
||||
FROM (
|
||||
(SELECT msats / 1000 as sats, act::text as type
|
||||
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type
|
||||
FROM "ItemAct"
|
||||
WHERE created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
|
||||
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
||||
WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
|
||||
UNION ALL
|
||||
(SELECT sats, 'DONATION' as type
|
||||
(SELECT sats::FLOAT, 'DONATION' as type
|
||||
FROM "Donation"
|
||||
WHERE created_at > ${lastReward.createdAt})
|
||||
) subquery`
|
||||
|
|
|
@ -271,6 +271,18 @@ export default {
|
|||
if (newInvitees.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const referral = await models.user.findFirst({
|
||||
where: {
|
||||
referrerId: me.id,
|
||||
createdAt: {
|
||||
gt: lastChecked
|
||||
}
|
||||
}
|
||||
})
|
||||
if (referral) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
|
|
@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
|
|||
import lnpr from 'bolt11'
|
||||
import { SELECT } from './item'
|
||||
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
|
||||
import { msatsToSats } from '../../lib/format'
|
||||
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
|
||||
|
||||
export async function getInvoice (parent, { id }, { me, models }) {
|
||||
if (!me) {
|
||||
|
@ -110,6 +110,12 @@ export default {
|
|||
FROM "Earn"
|
||||
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
|
||||
GROUP BY "userId", created_at)`)
|
||||
queries.push(
|
||||
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
|
||||
created_at as "createdAt", msats,
|
||||
0 as "msatsFee", NULL as status, 'referral' as type
|
||||
FROM "ReferralAct"
|
||||
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
|
||||
}
|
||||
|
||||
if (include.has('spent')) {
|
||||
|
@ -287,8 +293,8 @@ export default {
|
|||
|
||||
return item
|
||||
},
|
||||
sats: fact => msatsToSats(fact.msats),
|
||||
satsFee: fact => msatsToSats(fact.msatsFee)
|
||||
sats: fact => msatsToSatsDecimal(fact.msats),
|
||||
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import sub from './sub'
|
|||
import upload from './upload'
|
||||
import growth from './growth'
|
||||
import rewards from './rewards'
|
||||
import referrals from './referrals'
|
||||
|
||||
const link = gql`
|
||||
type Query {
|
||||
|
@ -27,4 +28,4 @@ const link = gql`
|
|||
`
|
||||
|
||||
export default [link, user, item, message, wallet, lnurl, notifications, invite,
|
||||
sub, upload, growth, rewards]
|
||||
sub, upload, growth, rewards, referrals]
|
||||
|
|
|
@ -50,8 +50,12 @@ export default gql`
|
|||
sortTime: String!
|
||||
}
|
||||
|
||||
type Referral {
|
||||
sortTime: String!
|
||||
}
|
||||
|
||||
union Notification = Reply | Votification | Mention
|
||||
| Invitification | Earn | JobChanged | InvoicePaid
|
||||
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
||||
|
||||
type Notifications {
|
||||
lastChecked: String
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
|
||||
export default gql`
|
||||
extend type Query {
|
||||
referrals(when: String): Referrals!
|
||||
}
|
||||
|
||||
type Referrals {
|
||||
totalSats: Int!
|
||||
totalReferrals: Int!
|
||||
stats: [TimeData!]!
|
||||
}
|
||||
`
|
|
@ -41,8 +41,8 @@ export default gql`
|
|||
factId: ID!
|
||||
bolt11: String
|
||||
createdAt: String!
|
||||
sats: Int!
|
||||
satsFee: Int
|
||||
sats: Float!
|
||||
satsFee: Float
|
||||
status: String
|
||||
type: String!
|
||||
description: String
|
||||
|
|
|
@ -18,6 +18,7 @@ import DontLikeThis from './dont-link-this'
|
|||
import Flag from '../svgs/flag-fill.svg'
|
||||
import { Badge } from 'react-bootstrap'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import Share from './share'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const ParentFrag = () => (
|
||||
|
@ -169,6 +170,7 @@ export default function Comment ({
|
|||
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
|
||||
}}
|
||||
/>)}
|
||||
{topLevel && <Share item={item} />}
|
||||
</div>
|
||||
{edit
|
||||
? (
|
||||
|
|
|
@ -92,13 +92,8 @@ export default function Header ({ sub }) {
|
|||
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<Link href='/invites' passHref>
|
||||
<NavDropdown.Item eventKey='invites'>invites
|
||||
{me && !me.hasInvites &&
|
||||
<div className='p-1 d-inline-block bg-success ml-1'>
|
||||
<span className='invisible'>{' '}</span>
|
||||
</div>}
|
||||
</NavDropdown.Item>
|
||||
<Link href='/referrals/month' passHref>
|
||||
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
|
||||
</Link>
|
||||
<NavDropdown.Divider />
|
||||
<div className='d-flex align-items-center'>
|
||||
|
|
|
@ -84,7 +84,7 @@ export function ItemActModal () {
|
|||
{[1, 10, 100, 1000, 10000].map(num =>
|
||||
<Button
|
||||
size='sm'
|
||||
className={num > 1 ? 'ml-2' : ''}
|
||||
className={`${num > 1 ? 'ml-2' : ''} mb-2`}
|
||||
key={num}
|
||||
onClick={() => { setOValue(num) }}
|
||||
>
|
||||
|
|
|
@ -14,6 +14,7 @@ import { newComments } from '../lib/new-comments'
|
|||
import { useMe } from './me'
|
||||
import DontLikeThis from './dont-link-this'
|
||||
import Flag from '../svgs/flag-fill.svg'
|
||||
import Share from './share'
|
||||
import { abbrNum } from '../lib/format'
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
|
@ -141,7 +142,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
|
|||
</div>
|
||||
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
|
||||
</div>
|
||||
{toc && <Toc text={item.text} />}
|
||||
{toc &&
|
||||
<>
|
||||
<Share item={item} />
|
||||
<Toc text={item.text} />
|
||||
</>}
|
||||
</div>
|
||||
{children && (
|
||||
<div className={styles.children}>
|
||||
|
|
|
@ -20,7 +20,7 @@ function Notification ({ n }) {
|
|||
<div
|
||||
className='clickToContext'
|
||||
onClick={e => {
|
||||
if (n.__typename === 'Earn') {
|
||||
if (n.__typename === 'Earn' || n.__typename === 'Referral') {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -88,41 +88,50 @@ function Notification ({ n }) {
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
: n.__typename === 'InvoicePaid'
|
||||
: n.__typename === 'Referral'
|
||||
? (
|
||||
<div className='font-weight-bold text-info ml-2 py-1'>
|
||||
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
|
||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</div>)
|
||||
: (
|
||||
<>
|
||||
{n.__typename === 'Votification' &&
|
||||
<small className='font-weight-bold text-success ml-2'>
|
||||
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
||||
</small>}
|
||||
{n.__typename === 'Mention' &&
|
||||
<small className='font-weight-bold text-info ml-2'>
|
||||
you were mentioned in
|
||||
</small>}
|
||||
{n.__typename === 'JobChanged' &&
|
||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||
{n.item.status === 'ACTIVE'
|
||||
? 'your job is active again'
|
||||
: (n.item.status === 'NOSATS'
|
||||
? 'your job promotion ran out of sats'
|
||||
: 'your job has been stopped')}
|
||||
</small>}
|
||||
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
|
||||
{n.item.isJob
|
||||
? <ItemJob item={n.item} />
|
||||
: n.item.title
|
||||
? <Item item={n.item} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
|
||||
</div>)}
|
||||
</div>
|
||||
</>)}
|
||||
<small className='font-weight-bold text-secondary ml-2'>
|
||||
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
|
||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
</>
|
||||
)
|
||||
: n.__typename === 'InvoicePaid'
|
||||
? (
|
||||
<div className='font-weight-bold text-info ml-2 py-1'>
|
||||
<Check className='fill-info mr-1' />{n.earnedSats} sats were deposited in your account
|
||||
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
|
||||
</div>)
|
||||
: (
|
||||
<>
|
||||
{n.__typename === 'Votification' &&
|
||||
<small className='font-weight-bold text-success ml-2'>
|
||||
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
|
||||
</small>}
|
||||
{n.__typename === 'Mention' &&
|
||||
<small className='font-weight-bold text-info ml-2'>
|
||||
you were mentioned in
|
||||
</small>}
|
||||
{n.__typename === 'JobChanged' &&
|
||||
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
|
||||
{n.item.status === 'ACTIVE'
|
||||
? 'your job is active again'
|
||||
: (n.item.status === 'NOSATS'
|
||||
? 'your job promotion ran out of sats'
|
||||
: 'your job has been stopped')}
|
||||
</small>}
|
||||
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' || n.__typename === 'JobChanged' ? '' : 'py-2'}>
|
||||
{n.item.isJob
|
||||
? <ItemJob item={n.item} />
|
||||
: n.item.title
|
||||
? <Item item={n.item} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
|
||||
</div>)}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { Dropdown } from 'react-bootstrap'
|
||||
import ShareIcon from '../svgs/share-fill.svg'
|
||||
import copy from 'clipboard-copy'
|
||||
import { useMe } from './me'
|
||||
|
||||
export default function Share ({ item }) {
|
||||
const me = useMe()
|
||||
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
|
||||
|
||||
return typeof window !== 'undefined' && navigator?.share
|
||||
? (
|
||||
<div className='d-flex align-items-center'>
|
||||
<ShareIcon
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: item.title || '',
|
||||
text: '',
|
||||
url
|
||||
}).then(() => console.log('Successful share'))
|
||||
.catch((error) => console.log('Error sharing', error))
|
||||
} else {
|
||||
console.log('no navigator.share')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>)
|
||||
: (
|
||||
<Dropdown alignRight className='pointer d-flex align-items-center' as='span'>
|
||||
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
|
||||
<ShareIcon className='mx-2 fill-grey theme' />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
className='text-center'
|
||||
onClick={async () => {
|
||||
copy(url)
|
||||
}}
|
||||
>
|
||||
copy link
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>)
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area, ComposedChart, Bar } from 'recharts'
|
||||
import { abbrNum } from '../lib/format'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const dateFormatter = when => {
|
||||
return timeStr => {
|
||||
const date = new Date(timeStr)
|
||||
switch (when) {
|
||||
case 'week':
|
||||
case 'month':
|
||||
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
|
||||
case 'year':
|
||||
case 'forever':
|
||||
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
|
||||
default:
|
||||
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function xAxisName (when) {
|
||||
switch (when) {
|
||||
case 'week':
|
||||
case 'month':
|
||||
return 'days'
|
||||
case 'year':
|
||||
case 'forever':
|
||||
return 'months'
|
||||
default:
|
||||
return 'hours'
|
||||
}
|
||||
}
|
||||
|
||||
const transformData = data => {
|
||||
return data.map(entry => {
|
||||
const obj = { time: entry.time }
|
||||
entry.data.forEach(entry1 => {
|
||||
obj[entry1.name] = entry1.value
|
||||
})
|
||||
return obj
|
||||
})
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'var(--secondary)',
|
||||
'var(--info)',
|
||||
'var(--success)',
|
||||
'var(--boost)',
|
||||
'var(--theme-grey)',
|
||||
'var(--danger)'
|
||||
]
|
||||
|
||||
export function WhenAreaChart ({ data }) {
|
||||
const router = useRouter()
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
// transform data into expected shape
|
||||
data = transformData(data)
|
||||
// need to grab when
|
||||
const when = router.query.when
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||
tick={{ fill: 'var(--theme-grey)' }}
|
||||
/>
|
||||
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||
<Legend />
|
||||
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function WhenLineChart ({ data }) {
|
||||
const router = useRouter()
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
// transform data into expected shape
|
||||
data = transformData(data)
|
||||
// need to grab when
|
||||
const when = router.query.when
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||
tick={{ fill: 'var(--theme-grey)' }}
|
||||
/>
|
||||
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||
<Legend />
|
||||
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) {
|
||||
const router = useRouter()
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
// transform data into expected shape
|
||||
data = transformData(data)
|
||||
// need to grab when
|
||||
const when = router.query.when
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||
tick={{ fill: 'var(--theme-grey)' }}
|
||||
/>
|
||||
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||
<Legend />
|
||||
{barNames?.map((v, i) =>
|
||||
<Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--info)' fill='var(--info)' />)}
|
||||
{areaNames?.map((v, i) =>
|
||||
<Area yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
{lineNames?.map((v, i) =>
|
||||
<Line yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
|
@ -37,6 +37,9 @@ export const NOTIFICATIONS = gql`
|
|||
tips
|
||||
}
|
||||
}
|
||||
... on Referral {
|
||||
sortTime
|
||||
}
|
||||
... on Reply {
|
||||
sortTime
|
||||
item {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const NOFOLLOW_LIMIT = 1000
|
||||
export const NOFOLLOW_LIMIT = 100
|
||||
export const BOOST_MIN = 5000
|
||||
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
|
||||
export const IMAGE_PIXELS_MAX = 35000000
|
||||
|
|
|
@ -16,3 +16,10 @@ export const msatsToSats = msats => {
|
|||
}
|
||||
return Number(BigInt(msats) / 1000n)
|
||||
}
|
||||
|
||||
export const msatsToSatsDecimal = msats => {
|
||||
if (msats === null || msats === undefined) {
|
||||
return null
|
||||
}
|
||||
return fixedDecimal(msats / 1000.0, 3)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function middleware (request) {
|
||||
const regex = /(\/.*)?\/r\/([\w_]+)/
|
||||
const m = regex.exec(request.nextUrl.pathname)
|
||||
|
||||
const url = new URL(m[1] || '/', request.url)
|
||||
url.search = request.nextUrl.search
|
||||
url.hash = request.nextUrl.hash
|
||||
|
||||
const resp = NextResponse.redirect(url)
|
||||
resp.cookies.set('sn_referrer', m[2])
|
||||
return resp
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
|
||||
}
|
|
@ -5,9 +5,7 @@ import prisma from '../../../api/models'
|
|||
import nodemailer from 'nodemailer'
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default (req, res) => NextAuth(req, res, options)
|
||||
|
||||
const options = {
|
||||
export default (req, res) => NextAuth(req, res, {
|
||||
callbacks: {
|
||||
/**
|
||||
* @param {object} token Decrypted JSON Web Token
|
||||
|
@ -26,22 +24,32 @@ const options = {
|
|||
token.user = { id: Number(user.id) }
|
||||
}
|
||||
|
||||
// sign them up for the newsletter
|
||||
if (isNewUser && profile.email) {
|
||||
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: profile.email,
|
||||
name: 'blank',
|
||||
lists: [2],
|
||||
status: 'enabled',
|
||||
preconfirm_subscriptions: true
|
||||
})
|
||||
}).then(async r => console.log(await r.json())).catch(console.log)
|
||||
if (isNewUser) {
|
||||
// if referrer exists, set on user
|
||||
if (req.cookies.sn_referrer && user?.id) {
|
||||
const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
|
||||
if (referrer) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
|
||||
}
|
||||
}
|
||||
|
||||
// sign them up for the newsletter
|
||||
if (profile.email) {
|
||||
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: profile.email,
|
||||
name: 'blank',
|
||||
lists: [2],
|
||||
status: 'enabled',
|
||||
preconfirm_subscriptions: true
|
||||
})
|
||||
}).then(async r => console.log(await r.json())).catch(console.log)
|
||||
}
|
||||
}
|
||||
|
||||
return token
|
||||
|
@ -130,7 +138,7 @@ const options = {
|
|||
pages: {
|
||||
signIn: '/login'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function sendVerificationRequest ({
|
||||
identifier: email,
|
||||
|
|
|
@ -120,7 +120,7 @@ export default function Invites () {
|
|||
<h2 className='mt-3 mb-0'>
|
||||
invite links
|
||||
</h2>
|
||||
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust somewhat, e.g. group chats or DMs</small>
|
||||
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust, e.g. group chats or DMs</small>
|
||||
</div>
|
||||
<InviteForm />
|
||||
{active.length > 0 && <InviteList name='active' invites={active} />}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import { gql } from 'apollo-server-micro'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||
import { CopyInput, Form, Select } from '../../components/form'
|
||||
import LayoutCenter from '../../components/layout-center'
|
||||
import { useMe } from '../../components/me'
|
||||
import { WhenComposedChart } from '../../components/when-charts'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(
|
||||
gql`
|
||||
query Referrals($when: String!)
|
||||
{
|
||||
referrals(when: $when) {
|
||||
totalSats
|
||||
totalReferrals
|
||||
stats {
|
||||
time
|
||||
data {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
export default function Referrals ({ data: { referrals: { totalSats, totalReferrals, stats } } }) {
|
||||
const router = useRouter()
|
||||
const me = useMe()
|
||||
return (
|
||||
<LayoutCenter footerLinks>
|
||||
<Form
|
||||
initial={{
|
||||
when: router.query.when
|
||||
}}
|
||||
>
|
||||
<h4 className='font-weight-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'>
|
||||
{totalReferrals} referrals & {totalSats} sats in the last
|
||||
<Select
|
||||
groupClassName='mb-0 ml-2'
|
||||
className='w-auto'
|
||||
name='when'
|
||||
size='sm'
|
||||
items={['day', 'week', 'month', 'year', 'forever']}
|
||||
onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)}
|
||||
/>
|
||||
</h4>
|
||||
</Form>
|
||||
<WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} />
|
||||
|
||||
<div
|
||||
className='text-small pt-5 px-3 d-flex w-100 align-items-center'
|
||||
>
|
||||
<div className='nav-item text-muted pr-2' style={{ 'white-space': 'nowrap' }}>referral link:</div>
|
||||
<CopyInput
|
||||
size='sm'
|
||||
groupClassName='mb-0 w-100'
|
||||
readOnly
|
||||
noForm
|
||||
placeholder={`https://stacker.news/r/${me.name}`}
|
||||
/>
|
||||
</div>
|
||||
<ul className='py-3 text-muted'>
|
||||
<li>{`appending /r/${me.name} to any SN link makes it a ref link`}
|
||||
<ul>
|
||||
<li>e.g. https://stacker.news/items/1/r/{me.name}</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>earn 21% of boost and job fees spent by referred stackers</li>
|
||||
<li>earn 2.1% of all tips received by referred stackers</li>
|
||||
<li><Link href='/invites' passHref><a>invite links</a></Link> are also implicitly referral links</li>
|
||||
</ul>
|
||||
</LayoutCenter>
|
||||
)
|
||||
}
|
|
@ -15,7 +15,6 @@ import { useRouter } from 'next/router'
|
|||
import Item from '../components/item'
|
||||
import Comment from '../components/comment'
|
||||
import React from 'react'
|
||||
import Info from '../components/info'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
|
||||
|
||||
|
@ -102,7 +101,16 @@ function Detail ({ fact }) {
|
|||
return (
|
||||
<>
|
||||
<div className={satusClass(fact.status)}>
|
||||
You made a donation to daily rewards!
|
||||
You made a donation to <Link href='/rewards' passHref><a>daily rewards</a></Link>!
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (fact.type === 'referral') {
|
||||
return (
|
||||
<>
|
||||
<div className={satusClass(fact.status)}>
|
||||
You stacked sats from <Link href='/referrals/month' passHref><a>a referral</a></Link>!
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
@ -156,6 +164,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||
return `/${fact.type}s/${fact.factId}`
|
||||
case 'earn':
|
||||
case 'donation':
|
||||
case 'referral':
|
||||
return
|
||||
default:
|
||||
return `/items/${fact.factId}`
|
||||
|
@ -212,11 +221,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||
<th className={styles.type}>type</th>
|
||||
<th>detail</th>
|
||||
<th className={styles.sats}>
|
||||
<div>sats
|
||||
<Info>
|
||||
<div className='font-weight-bold'>Sats are rounded down from millisats to the nearest sat, so the actual amount might be slightly larger.</div>
|
||||
</Info>
|
||||
</div>
|
||||
sats
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -231,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
|||
<td className={styles.description}>
|
||||
<Detail fact={f} />
|
||||
</td>
|
||||
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.sats)}</td>
|
||||
<td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td>
|
||||
</tr>
|
||||
</Wrapper>
|
||||
)
|
||||
|
|
|
@ -146,7 +146,7 @@ export default function Settings ({ data: { settings } }) {
|
|||
groupClassName='mb-0'
|
||||
/>
|
||||
<Checkbox
|
||||
label='my invite links are redeemed'
|
||||
label='someone joins using my invite or referral links'
|
||||
name='noteInvites'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { getGetServerSideProps } from '../../api/ssrApollo'
|
||||
import Layout from '../../components/layout'
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||
import { Col, Row } from 'react-bootstrap'
|
||||
import { abbrNum } from '../../lib/format'
|
||||
import { UsageHeader } from '../../components/usage-header'
|
||||
import { useRouter } from 'next/router'
|
||||
import { WhenLineChart, WhenAreaChart } from '../../components/when-charts'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps(
|
||||
gql`
|
||||
|
@ -55,46 +53,6 @@ export const getServerSideProps = getGetServerSideProps(
|
|||
}
|
||||
}`)
|
||||
|
||||
// todo: this needs to accomodate hours, days, months now
|
||||
const dateFormatter = when => {
|
||||
return timeStr => {
|
||||
const date = new Date(timeStr)
|
||||
switch (when) {
|
||||
case 'week':
|
||||
case 'month':
|
||||
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
|
||||
case 'year':
|
||||
case 'forever':
|
||||
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
|
||||
default:
|
||||
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function xAxisName (when) {
|
||||
switch (when) {
|
||||
case 'week':
|
||||
case 'month':
|
||||
return 'days'
|
||||
case 'year':
|
||||
case 'forever':
|
||||
return 'months'
|
||||
default:
|
||||
return 'hours'
|
||||
}
|
||||
}
|
||||
|
||||
const transformData = data => {
|
||||
return data.map(entry => {
|
||||
const obj = { time: entry.time }
|
||||
entry.data.forEach(entry1 => {
|
||||
obj[entry1.name] = entry1.value
|
||||
})
|
||||
return obj
|
||||
})
|
||||
}
|
||||
|
||||
export default function Growth ({
|
||||
data: { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth }
|
||||
}) {
|
||||
|
@ -104,112 +62,33 @@ export default function Growth ({
|
|||
<Row>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>stackers</div>
|
||||
<GrowthLineChart data={stackerGrowth} />
|
||||
<WhenLineChart data={stackerGrowth} />
|
||||
</Col>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>stacking</div>
|
||||
<GrowthAreaChart data={stackingGrowth} />
|
||||
<WhenAreaChart data={stackingGrowth} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>spenders</div>
|
||||
<GrowthLineChart data={spenderGrowth} />
|
||||
<WhenLineChart data={spenderGrowth} />
|
||||
</Col>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>spending</div>
|
||||
<GrowthAreaChart data={spendingGrowth} />
|
||||
<WhenAreaChart data={spendingGrowth} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>registrations</div>
|
||||
<GrowthAreaChart data={registrationGrowth} />
|
||||
<WhenAreaChart data={registrationGrowth} />
|
||||
</Col>
|
||||
<Col className='mt-3'>
|
||||
<div className='text-center text-muted font-weight-bold'>items</div>
|
||||
<GrowthAreaChart data={itemGrowth} />
|
||||
<WhenAreaChart data={itemGrowth} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'var(--secondary)',
|
||||
'var(--info)',
|
||||
'var(--success)',
|
||||
'var(--boost)',
|
||||
'var(--theme-grey)',
|
||||
'var(--danger)'
|
||||
]
|
||||
|
||||
function GrowthAreaChart ({ data }) {
|
||||
const router = useRouter()
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
// transform data into expected shape
|
||||
data = transformData(data)
|
||||
// need to grab when
|
||||
const when = router.query.when
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||
tick={{ fill: 'var(--theme-grey)' }}
|
||||
/>
|
||||
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||
<Legend />
|
||||
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function GrowthLineChart ({ data }) {
|
||||
const router = useRouter()
|
||||
if (!data || data.length === 0) {
|
||||
return null
|
||||
}
|
||||
// transform data into expected shape
|
||||
data = transformData(data)
|
||||
// need to grab when
|
||||
const when = router.query.when
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width='100%' height={300} minWidth={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
|
||||
tick={{ fill: 'var(--theme-grey)' }}
|
||||
/>
|
||||
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
|
||||
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
|
||||
<Legend />
|
||||
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
|
||||
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "referrerId" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -0,0 +1,17 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ReferralAct" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"referrerId" INTEGER NOT NULL,
|
||||
"itemActId" INTEGER NOT NULL,
|
||||
"msats" BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("itemActId") REFERENCES "ItemAct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,202 @@
|
|||
CREATE OR REPLACE FUNCTION referral_act(referrer_id INTEGER, item_act_id INTEGER)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
act_msats BIGINT;
|
||||
referral_act "ItemActType";
|
||||
referral_msats BIGINT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT msats, act INTO act_msats, referral_act FROM "ItemAct" WHERE id = item_act_id;
|
||||
|
||||
IF referral_act IN ('FEE', 'BOOST', 'STREAM') THEN
|
||||
referral_msats := CEIL(act_msats * .21);
|
||||
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
|
||||
VALUES(referrer_id, item_act_id, referral_msats, now_utc(), now_utc());
|
||||
UPDATE users
|
||||
SET msats = msats + referral_msats, "stackedMsats" = "stackedMsats" + referral_msats
|
||||
WHERE id = referrer_id;
|
||||
END IF;
|
||||
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- add referral act on item_act
|
||||
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats BIGINT;
|
||||
act_msats BIGINT;
|
||||
fee_msats BIGINT;
|
||||
item_act_id INTEGER;
|
||||
referrer_id INTEGER;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
act_msats := act_sats * 1000;
|
||||
SELECT msats, "referrerId" INTO user_msats, referrer_id FROM users WHERE id = user_id;
|
||||
IF act_msats > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
END IF;
|
||||
|
||||
-- deduct msats from actor
|
||||
UPDATE users SET msats = msats - act_msats WHERE id = user_id;
|
||||
|
||||
IF act = 'VOTE' THEN
|
||||
RAISE EXCEPTION 'SN_UNSUPPORTED';
|
||||
END IF;
|
||||
|
||||
IF act = 'TIP' THEN
|
||||
-- call to influence weightedVotes ... we need to do this before we record the acts because
|
||||
-- the priors acts are taken into account
|
||||
PERFORM weighted_votes_after_tip(item_id, user_id, act_sats);
|
||||
|
||||
-- take 10% and insert as FEE
|
||||
fee_msats := CEIL(act_msats * 0.1);
|
||||
act_msats := act_msats - fee_msats;
|
||||
|
||||
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc())
|
||||
RETURNING id INTO item_act_id;
|
||||
|
||||
-- add sats to actee's balance and stacked count
|
||||
UPDATE users
|
||||
SET msats = msats + act_msats, "stackedMsats" = "stackedMsats" + act_msats
|
||||
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id)
|
||||
RETURNING "referrerId" INTO referrer_id;
|
||||
|
||||
-- leave the rest as a tip
|
||||
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc());
|
||||
|
||||
-- call to denormalize sats and commentSats
|
||||
PERFORM sats_after_tip(item_id, user_id, act_msats + fee_msats);
|
||||
ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM
|
||||
-- call to influence if DONT_LIKE_THIS weightedDownVotes
|
||||
IF act = 'DONT_LIKE_THIS' THEN
|
||||
-- make sure they haven't done this before
|
||||
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'DONT_LIKE_THIS') THEN
|
||||
RAISE EXCEPTION 'SN_DUPLICATE';
|
||||
END IF;
|
||||
|
||||
PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats);
|
||||
END IF;
|
||||
|
||||
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
|
||||
VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc())
|
||||
RETURNING id INTO item_act_id;
|
||||
END IF;
|
||||
|
||||
-- they have a referrer and the referrer isn't the one tipping them
|
||||
IF referrer_id IS NOT NULL AND user_id <> referrer_id THEN
|
||||
PERFORM referral_act(referrer_id, item_act_id);
|
||||
END IF;
|
||||
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
|
||||
DECLARE
|
||||
bid_sats INTEGER;
|
||||
user_msats BIGINT;
|
||||
user_id INTEGER;
|
||||
item_status "Status";
|
||||
status_updated_at timestamp(3);
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
-- extract data we need
|
||||
SELECT "maxBid", "userId", status, "statusUpdatedAt" INTO bid_sats, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id;
|
||||
SELECT msats INTO user_msats FROM users WHERE id = user_id;
|
||||
|
||||
-- 0 bid items expire after 30 days unless updated
|
||||
IF bid_sats = 0 THEN
|
||||
IF item_status <> 'STOPPED' THEN
|
||||
IF status_updated_at < now_utc() - INTERVAL '30 days' THEN
|
||||
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
ELSEIF item_status = 'NOSATS' THEN
|
||||
UPDATE "Item" SET status = 'ACTIVE' WHERE id = item_id;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- check if user wallet has enough sats
|
||||
IF bid_sats * 1000 > user_msats THEN
|
||||
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
|
||||
IF item_status <> 'NOSATS' THEN
|
||||
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
END IF;
|
||||
ELSE
|
||||
PERFORM item_act(item_id, user_id, 'STREAM', bid_sats);
|
||||
|
||||
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
|
||||
IF item_status = 'NOSATS' THEN
|
||||
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- retro actively, turn all invites into referrals
|
||||
UPDATE users
|
||||
SET "referrerId" = subquery.inviter
|
||||
FROM (
|
||||
SELECT invitees.id AS invitee, inviters.id AS inviter
|
||||
FROM users invitees
|
||||
JOIN "Invite" ON invitees."inviteId" = "Invite".id
|
||||
JOIN users inviters ON inviters.id = "Invite"."userId") subquery
|
||||
WHERE id = subquery.invitee;
|
||||
|
||||
-- make inviters referrers too
|
||||
CREATE OR REPLACE FUNCTION invite_drain(user_id INTEGER, invite_id TEXT)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
inviter_id INTEGER;
|
||||
inviter_sats INTEGER;
|
||||
gift INTEGER;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
-- check user was created in last hour
|
||||
-- check user did not already redeem an invite
|
||||
PERFORM FROM users
|
||||
WHERE id = user_id AND users.created_at >= NOW() AT TIME ZONE 'UTC' - INTERVAL '1 HOUR'
|
||||
AND users."inviteId" IS NULL;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'SN_INELIGIBLE';
|
||||
END IF;
|
||||
|
||||
-- check that invite has not reached limit
|
||||
-- check that invite is not revoked
|
||||
SELECT "Invite"."userId", "Invite".gift INTO inviter_id, gift FROM "Invite"
|
||||
LEFT JOIN users ON users."inviteId" = invite_id
|
||||
WHERE "Invite".id = invite_id AND NOT "Invite".revoked
|
||||
GROUP BY "Invite".id
|
||||
HAVING COUNT(DISTINCT users.id) < "Invite".limit OR "Invite".limit IS NULL;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
|
||||
END IF;
|
||||
|
||||
-- check that inviter has sufficient balance
|
||||
SELECT (msats / 1000) INTO inviter_sats
|
||||
FROM users WHERE id = inviter_id;
|
||||
IF inviter_sats < gift THEN
|
||||
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
|
||||
END IF;
|
||||
|
||||
-- subtract amount from inviter
|
||||
UPDATE users SET msats = msats - (1000 * gift) WHERE id = inviter_id;
|
||||
-- add amount to invitee
|
||||
UPDATE users SET msats = msats + (1000 * gift), "inviteId" = invite_id, "referrerId" = inviter_id WHERE id = user_id;
|
||||
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;
|
|
@ -47,6 +47,11 @@ model User {
|
|||
upvotePopover Boolean @default(false)
|
||||
tipPopover Boolean @default(false)
|
||||
|
||||
// referrals
|
||||
referrer User? @relation("referrals", fields: [referrerId], references: [id])
|
||||
referrerId Int?
|
||||
referrees User[] @relation("referrals")
|
||||
|
||||
// tip settings
|
||||
tipDefault Int @default(10)
|
||||
turboTipping Boolean @default(false)
|
||||
|
@ -68,10 +73,11 @@ model User {
|
|||
wildWestMode Boolean @default(false)
|
||||
greeterMode Boolean @default(false)
|
||||
|
||||
Earn Earn[]
|
||||
Upload Upload[] @relation(name: "Uploads")
|
||||
PollVote PollVote[]
|
||||
Donation Donation[]
|
||||
Earn Earn[]
|
||||
Upload Upload[] @relation(name: "Uploads")
|
||||
PollVote PollVote[]
|
||||
Donation Donation[]
|
||||
ReferralAct ReferralAct[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([inviteId])
|
||||
|
@ -318,6 +324,17 @@ model Pin {
|
|||
Item Item[]
|
||||
}
|
||||
|
||||
model ReferralAct {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
referrerId Int
|
||||
referrer User @relation(fields: [referrerId], references: [id])
|
||||
itemActId Int
|
||||
itemAct ItemAct @relation(fields: [itemActId], references: [id])
|
||||
msats BigInt
|
||||
}
|
||||
|
||||
enum ItemActType {
|
||||
VOTE
|
||||
BOOST
|
||||
|
@ -329,15 +346,16 @@ enum ItemActType {
|
|||
}
|
||||
|
||||
model ItemAct {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
msats BigInt
|
||||
act ItemActType
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
itemId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
msats BigInt
|
||||
act ItemActType
|
||||
item Item @relation(fields: [itemId], references: [id])
|
||||
itemId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
ReferralAct ReferralAct[]
|
||||
|
||||
@@index([itemId])
|
||||
@@index([userId])
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13.576 17.271l-5.11-2.787a3.5 3.5 0 1 1 0-4.968l5.11-2.787a3.5 3.5 0 1 1 .958 1.755l-5.11 2.787a3.514 3.514 0 0 1 0 1.458l5.11 2.787a3.5 3.5 0 1 1-.958 1.755z"/></svg>
|
After Width: | Height: | Size: 297 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13 14h-2a8.999 8.999 0 0 0-7.968 4.81A10.136 10.136 0 0 1 3 18C3 12.477 7.477 8 13 8V3l10 8-10 8v-5z"/></svg>
|
After Width: | Height: | Size: 239 B |
|
@ -11,9 +11,10 @@ function earn ({ models }) {
|
|||
|
||||
// compute how much sn earned today
|
||||
let [{ sum }] = await models.$queryRaw`
|
||||
SELECT coalesce(sum("ItemAct".msats), 0) as sum
|
||||
SELECT coalesce(sum("ItemAct".msats - coalesce("ReferralAct".msats, 0)), 0) as sum
|
||||
FROM "ItemAct"
|
||||
JOIN "Item" on "ItemAct"."itemId" = "Item".id
|
||||
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
|
||||
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
|
||||
WHERE "ItemAct".act <> 'TIP'
|
||||
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
|
||||
|
||||
|
|
Loading…
Reference in New Issue