diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js
index f048d109..c4f5e7ac 100644
--- a/api/resolvers/growth.js
+++ b/api/resolvers/growth.js
@@ -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
diff --git a/api/resolvers/index.js b/api/resolvers/index.js
index 7798a789..be4d925c 100644
--- a/api/resolvers/index.js
+++ b/api/resolvers/index.js
@@ -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 }]
diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
index afe97dbe..d9405b99 100644
--- a/api/resolvers/notifications.js
+++ b/api/resolvers/notifications.js
@@ -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) {
diff --git a/api/resolvers/referrals.js b/api/resolvers/referrals.js
new file mode 100644
index 00000000..55ec2ad1
--- /dev/null
+++ b/api/resolvers/referrals.js
@@ -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
+ }
+ }
+ }
+}
diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js
index caddb485..8832416b 100644
--- a/api/resolvers/rewards.js
+++ b/api/resolvers/rewards.js
@@ -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`
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 3485189d..8bb44ac7 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -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
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index 3255b230..82421db7 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -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)
}
}
diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js
index 183069da..8eb96b29 100644
--- a/api/typeDefs/index.js
+++ b/api/typeDefs/index.js
@@ -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]
diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js
index d880ba98..9d4d5c12 100644
--- a/api/typeDefs/notifications.js
+++ b/api/typeDefs/notifications.js
@@ -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
diff --git a/api/typeDefs/referrals.js b/api/typeDefs/referrals.js
new file mode 100644
index 00000000..5309f37d
--- /dev/null
+++ b/api/typeDefs/referrals.js
@@ -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!]!
+ }
+`
diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js
index 5b4d3303..ff06a926 100644
--- a/api/typeDefs/wallet.js
+++ b/api/typeDefs/wallet.js
@@ -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
diff --git a/components/comment.js b/components/comment.js
index f1be8849..bf121a34 100644
--- a/components/comment.js
+++ b/components/comment.js
@@ -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 && }
{edit
? (
diff --git a/components/header.js b/components/header.js
index 6e34a2b2..34c81fc1 100644
--- a/components/header.js
+++ b/components/header.js
@@ -92,13 +92,8 @@ export default function Header ({ sub }) {
satistics
-
- invites
- {me && !me.hasInvites &&
-
- {' '}
-
}
-
+
+ referrals
diff --git a/components/item-act.js b/components/item-act.js
index 073fe7ab..79ccb16f 100644
--- a/components/item-act.js
+++ b/components/item-act.js
@@ -84,7 +84,7 @@ export function ItemActModal () {
{[1, 10, 100, 1000, 10000].map(num =>
{showFwdUser && item.fwdUser && }
- {toc && }
+ {toc &&
+ <>
+
+
+ >}
{children && (
diff --git a/components/notifications.js b/components/notifications.js
index 4b4fd57c..75c650b9 100644
--- a/components/notifications.js
+++ b/components/notifications.js
@@ -20,7 +20,7 @@ function Notification ({ n }) {
{
- if (n.__typename === 'Earn') {
+ if (n.__typename === 'Earn' || n.__typename === 'Referral') {
return
}
@@ -88,41 +88,50 @@ function Notification ({ n }) {
)
- : n.__typename === 'InvoicePaid'
+ : n.__typename === 'Referral'
? (
-
- {n.earnedSats} sats were deposited in your account
- {timeSince(new Date(n.sortTime))}
-
)
- : (
<>
- {n.__typename === 'Votification' &&
-
- your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
- }
- {n.__typename === 'Mention' &&
-
- you were mentioned in
- }
- {n.__typename === 'JobChanged' &&
-
- {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')}
- }
-
- {n.item.isJob
- ?
- : n.item.title
- ?
- : (
-
-
-
)}
-
- >)}
+
+ someone joined via one of your referral links
+ {timeSince(new Date(n.sortTime))}
+
+ >
+ )
+ : n.__typename === 'InvoicePaid'
+ ? (
+
+ {n.earnedSats} sats were deposited in your account
+ {timeSince(new Date(n.sortTime))}
+
)
+ : (
+ <>
+ {n.__typename === 'Votification' &&
+
+ your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
+ }
+ {n.__typename === 'Mention' &&
+
+ you were mentioned in
+ }
+ {n.__typename === 'JobChanged' &&
+
+ {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')}
+ }
+
+ {n.item.isJob
+ ?
+ : n.item.title
+ ?
+ : (
+
+
+
)}
+
+ >)}
)
}
diff --git a/components/share.js b/components/share.js
new file mode 100644
index 00000000..74c82172
--- /dev/null
+++ b/components/share.js
@@ -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
+ ? (
+
+ {
+ 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')
+ }
+ }}
+ />
+
)
+ : (
+
+
+
+
+
+
+ {
+ copy(url)
+ }}
+ >
+ copy link
+
+
+ )
+}
diff --git a/components/when-charts.js b/components/when-charts.js
new file mode 100644
index 00000000..ba042264
--- /dev/null
+++ b/components/when-charts.js
@@ -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 (
+
+
+
+
+
+
+ {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
+ )}
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+ {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
+ )}
+
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+
+ {barNames?.map((v, i) =>
+ )}
+ {areaNames?.map((v, i) =>
+ )}
+ {lineNames?.map((v, i) =>
+ )}
+
+
+ )
+}
diff --git a/fragments/notifications.js b/fragments/notifications.js
index c7f43172..58482a55 100644
--- a/fragments/notifications.js
+++ b/fragments/notifications.js
@@ -37,6 +37,9 @@ export const NOTIFICATIONS = gql`
tips
}
}
+ ... on Referral {
+ sortTime
+ }
... on Reply {
sortTime
item {
diff --git a/lib/constants.js b/lib/constants.js
index ec7a9582..185cb5a4 100644
--- a/lib/constants.js
+++ b/lib/constants.js
@@ -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
diff --git a/lib/format.js b/lib/format.js
index d312eb9e..405ea09a 100644
--- a/lib/format.js
+++ b/lib/format.js
@@ -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)
+}
diff --git a/middleware.js b/middleware.js
new file mode 100644
index 00000000..b76a96a1
--- /dev/null
+++ b/middleware.js
@@ -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_]+)([?#]?.*)']
+}
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index e104714a..7beffc55 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -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,
diff --git a/pages/invites/index.js b/pages/invites/index.js
index b401b2e9..e78d74e3 100644
--- a/pages/invites/index.js
+++ b/pages/invites/index.js
@@ -120,7 +120,7 @@ export default function Invites () {
invite links
- send these to people you trust somewhat, e.g. group chats or DMs
+ send these to people you trust, e.g. group chats or DMs
{active.length > 0 && }
diff --git a/pages/referrals/[when].js b/pages/referrals/[when].js
new file mode 100644
index 00000000..942d6a6a
--- /dev/null
+++ b/pages/referrals/[when].js
@@ -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 (
+
+
+
+
+
+
+ - {`appending /r/${me.name} to any SN link makes it a ref link`}
+
+ - e.g. https://stacker.news/items/1/r/{me.name}
+
+
+ - earn 21% of boost and job fees spent by referred stackers
+ - earn 2.1% of all tips received by referred stackers
+ - invite links are also implicitly referral links
+
+
+ )
+}
diff --git a/pages/satistics.js b/pages/satistics.js
index 8cdf4f14..771c1d4c 100644
--- a/pages/satistics.js
+++ b/pages/satistics.js
@@ -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 (
<>
- You made a donation to daily rewards!
+ You made a donation to
daily rewards!
+
+ >
+ )
+ }
+ if (fact.type === 'referral') {
+ return (
+ <>
+
>
)
@@ -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
type |
detail |
- sats
-
- Sats are rounded down from millisats to the nearest sat, so the actual amount might be slightly larger.
-
-
+ sats
|
@@ -231,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
|
- {Math.floor(f.sats)} |
+ {f.sats} |
)
diff --git a/pages/settings.js b/pages/settings.js
index 37793fc9..e955dcf1 100644
--- a/pages/settings.js
+++ b/pages/settings.js
@@ -146,7 +146,7 @@ export default function Settings ({ data: { settings } }) {
groupClassName='mb-0'
/>
diff --git a/pages/users/[when].js b/pages/users/[when].js
index 9a19c700..6a3db864 100644
--- a/pages/users/[when].js
+++ b/pages/users/[when].js
@@ -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 ({
stackers
-
+
stacking
-
+
spenders
-
+
spending
-
+
registrations
-
+
items
-
+
)
}
-
-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 (
-
-
-
-
-
-
- {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
- )}
-
-
- )
-}
-
-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 (
-
-
-
-
-
-
- {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
- )}
-
-
- )
-}
diff --git a/prisma/migrations/20221213220945_referral/migration.sql b/prisma/migrations/20221213220945_referral/migration.sql
new file mode 100644
index 00000000..70d474d7
--- /dev/null
+++ b/prisma/migrations/20221213220945_referral/migration.sql
@@ -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;
diff --git a/prisma/migrations/20221214175911_referral_act/migration.sql b/prisma/migrations/20221214175911_referral_act/migration.sql
new file mode 100644
index 00000000..46b01055
--- /dev/null
+++ b/prisma/migrations/20221214175911_referral_act/migration.sql
@@ -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;
\ No newline at end of file
diff --git a/prisma/migrations/20221214201527_referral_funcs/migration.sql b/prisma/migrations/20221214201527_referral_funcs/migration.sql
new file mode 100644
index 00000000..2afdf709
--- /dev/null
+++ b/prisma/migrations/20221214201527_referral_funcs/migration.sql
@@ -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;
+$$;
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 185caf3c..814d1138 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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])
diff --git a/svgs/share-fill.svg b/svgs/share-fill.svg
new file mode 100644
index 00000000..ef3faa93
--- /dev/null
+++ b/svgs/share-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/share-forward-fill.svg b/svgs/share-forward-fill.svg
new file mode 100644
index 00000000..1c7c3f4d
--- /dev/null
+++ b/svgs/share-forward-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/worker/earn.js b/worker/earn.js
index 4b07a2cb..5adde9b4 100644
--- a/worker/earn.js
+++ b/worker/earn.js
@@ -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'`