diff --git a/api/resolvers/growth.js b/api/resolvers/growth.js index 18a78b1f..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 @@ -74,10 +74,18 @@ export default { json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')), json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')), json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')), - json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')) + json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')), + json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION')) ) AS data FROM times - LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at) + LEFT JOIN + ((SELECT "ItemAct".created_at, "userId", act::text as act + FROM "ItemAct" + WHERE ${intervalClause(when, 'ItemAct', false)}) + UNION ALL + (SELECT created_at, "userId", 'DONATION' as act + FROM "Donation" + WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) GROUP BY time ORDER BY time ASC`) }, @@ -98,14 +106,21 @@ export default { return await models.$queryRaw( `${withClause(when)} SELECT time, json_build_array( - json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN "ItemAct".msats ELSE 0 END)/1000),0)), - json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN "ItemAct".msats ELSE 0 END)/1000),0)), - json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM') THEN "ItemAct".msats ELSE 0 END)/1000),0)), - json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN "ItemAct".msats ELSE 0 END)/1000),0)) + json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)), + json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)), + json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)), + json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)), + json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0)) ) AS data FROM times - LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at) - JOIN "Item" ON "ItemAct"."itemId" = "Item".id + LEFT JOIN + ((SELECT "ItemAct".created_at, msats, act::text as act + FROM "ItemAct" + WHERE ${intervalClause(when, 'ItemAct', false)}) + UNION ALL + (SELECT created_at, sats * 1000 as msats, 'DONATION' as act + FROM "Donation" + WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at) GROUP BY time ORDER BY time ASC`) }, @@ -116,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 @@ -127,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`) }, @@ -137,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 d2bb87d9..be4d925c 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -9,7 +9,9 @@ import sub from './sub' 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, { JSONObject: GraphQLJSONObject }] + upload, growth, search, rewards, referrals, { JSONObject: GraphQLJSONObject }] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 3f0888eb..d963875f 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -14,13 +14,13 @@ async function comments (me, models, id, sort) { let orderBy switch (sort) { case 'top': - orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC` + orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC` break case 'recent': - orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC' + orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, "Item".id DESC' break default: - orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".id DESC` + orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, "Item".id DESC` break } 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 new file mode 100644 index 00000000..8832416b --- /dev/null +++ b/api/resolvers/rewards.js @@ -0,0 +1,49 @@ +import { AuthenticationError } from 'apollo-server-micro' +import serialize from './serial' + +export default { + Query: { + expectedRewards: async (parent, args, { models }) => { + // get the last reward time, then get all contributions to rewards since then + const lastReward = await models.earn.findFirst({ + orderBy: { + createdAt: 'desc' + } + }) + + const [result] = await models.$queryRaw` + 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 ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type + FROM "ItemAct" + LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId" + WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP') + UNION ALL + (SELECT sats::FLOAT, 'DONATION' as type + FROM "Donation" + WHERE created_at > ${lastReward.createdAt}) + ) subquery` + + return result + } + }, + Mutation: { + donateToRewards: async (parent, { sats }, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + await serialize(models, + models.$queryRaw( + 'SELECT donate($1, $2)', + sats, Number(me.id))) + + return sats + } + } +} diff --git a/api/resolvers/user.js b/api/resolvers/user.js index a120ec3a..7794b84c 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -93,12 +93,20 @@ export default { let users if (sort === 'spent') { users = await models.$queryRaw(` - SELECT users.*, floor(sum("ItemAct".msats)/1000) as spent - FROM "ItemAct" - JOIN users on "ItemAct"."userId" = users.id - WHERE "ItemAct".created_at <= $1 + SELECT users.*, sum(sats_spent) as spent + FROM + ((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent + FROM "ItemAct" + WHERE "ItemAct".created_at <= $1 + ${within('ItemAct', when)} + GROUP BY "userId") + UNION ALL + (SELECT "userId", sats as sats_spent + FROM "Donation" + WHERE created_at <= $1 + ${within('Donation', when)})) spending + JOIN users on spending."userId" = users.id AND NOT users."hideFromTopUsers" - ${within('ItemAct', when)} GROUP BY users.id, users.name ORDER BY spent DESC NULLS LAST, users.created_at DESC OFFSET $2 @@ -127,6 +135,18 @@ export default { ORDER BY ncomments DESC NULLS LAST, users.created_at DESC OFFSET $2 LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) + } else if (sort === 'referrals') { + users = await models.$queryRaw(` + SELECT users.*, count(*) as referrals + FROM users + JOIN "users" referree on users.id = referree."referrerId" + WHERE referree.created_at <= $1 + AND NOT users."hideFromTopUsers" + ${within('referree', when)} + GROUP BY users.id + ORDER BY referrals DESC NULLS LAST, users.created_at DESC + OFFSET $2 + LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset) } else { users = await models.$queryRaw(` SELECT u.id, u.name, u."photoId", floor(sum(amount)/1000) as stacked @@ -143,7 +163,13 @@ export default { FROM "Earn" JOIN users on users.id = "Earn"."userId" WHERE "Earn".msats > 0 ${within('Earn', when)} - AND NOT users."hideFromTopUsers")) u + AND NOT users."hideFromTopUsers") + UNION ALL + (SELECT users.*, "ReferralAct".msats as amount + FROM "ReferralAct" + JOIN users on users.id = "ReferralAct"."referrerId" + WHERE "ReferralAct".msats > 0 ${within('ReferralAct', when)} + AND NOT users."hideFromTopUsers")) u GROUP BY u.id, u.name, u.created_at, u."photoId" ORDER BY stacked DESC NULLS LAST, created_at DESC OFFSET $2 @@ -263,6 +289,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 @@ -433,13 +471,18 @@ export default { const [{ stacked }] = await models.$queryRaw(` SELECT sum(amount) as stacked FROM - ((SELECT sum("ItemAct".msats) as amount + ((SELECT coalesce(sum("ItemAct".msats),0) as amount FROM "ItemAct" JOIN "Item" on "ItemAct"."itemId" = "Item".id WHERE act <> 'BOOST' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2 AND "ItemAct".created_at >= $1) UNION ALL - (SELECT sum("Earn".msats) as amount + (SELECT coalesce(sum("ReferralAct".msats),0) as amount + FROM "ReferralAct" + WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $2 + AND "ReferralAct".created_at >= $1) + UNION ALL + (SELECT coalesce(sum("Earn".msats), 0) as amount FROM "Earn" WHERE "Earn".msats > 0 AND "Earn"."userId" = $2 AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id)) @@ -465,6 +508,16 @@ export default { return (msats && msatsToSats(msats)) || 0 }, + referrals: async (user, { when }, { models }) => { + return await models.user.count({ + where: { + referrerId: user.id, + createdAt: { + gte: withinDate(when) + } + } + }) + }, sats: async (user, args, { models, me }) => { if (me?.id !== user.id) { return 0 diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b8dafcec..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')) { @@ -122,6 +128,13 @@ export default { WHERE "ItemAct"."userId" = $1 AND "ItemAct".created_at <= $2 GROUP BY "Item".id)`) + queries.push( + `(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11, + created_at as "createdAt", sats * 1000 as msats, + 0 as "msatsFee", NULL as status, 'donation' as type + FROM "Donation" + WHERE "userId" = $1 + AND created_at <= $2)`) } if (queries.length === 0) { @@ -157,6 +170,9 @@ export default { case 'spent': f.msats *= -1 break + case 'donation': + f.msats *= -1 + break default: break } @@ -277,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 5768cc88..8eb96b29 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -10,6 +10,8 @@ import invite from './invite' 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 { @@ -26,4 +28,4 @@ const link = gql` ` export default [link, user, item, message, wallet, lnurl, notifications, invite, - sub, upload, growth] + 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/rewards.js b/api/typeDefs/rewards.js new file mode 100644 index 00000000..e9776c20 --- /dev/null +++ b/api/typeDefs/rewards.js @@ -0,0 +1,16 @@ +import { gql } from 'apollo-server-micro' + +export default gql` + extend type Query { + expectedRewards: ExpectedRewards! + } + + extend type Mutation { + donateToRewards(sats: Int!): Int! + } + + type ExpectedRewards { + total: Int! + sources: [NameValue!]! + } +` diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1fe2bdf0..c6810bd5 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -19,8 +19,8 @@ export default gql` extend type Mutation { setName(name: String!): Boolean - setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!, - noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, + setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!, + noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!, wildWestMode: Boolean!, greeterMode: Boolean!): User setPhoto(photoId: ID!): Int! @@ -45,10 +45,12 @@ export default gql` ncomments(when: String): Int! stacked(when: String): Int! spent(when: String): Int! + referrals(when: String): Int! freePosts: Int! freeComments: Int! hasInvites: Boolean! tipDefault: Int! + turboTipping: Boolean! fiatCurrency: String! bio: Item bioId: Int 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/footer.js b/components/footer.js index 7b013e68..f3cb2377 100644 --- a/components/footer.js +++ b/components/footer.js @@ -151,6 +151,13 @@ export default function Footer ({ noLinks }) { darkMode.toggle()} className='fill-grey theme' /> } +
+ + + rewards + + +
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 40a7d60f..79ccb16f 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -1,8 +1,9 @@ -import { InputGroup, Modal } from 'react-bootstrap' +import { Button, InputGroup, Modal } from 'react-bootstrap' import React, { useState, useCallback, useContext, useRef, useEffect } from 'react' import * as Yup from 'yup' import { Form, Input, SubmitButton } from './form' import { useMe } from './me' +import UpBolt from '../svgs/bolt.svg' export const ItemActContext = React.createContext({ item: null, @@ -38,6 +39,7 @@ export function ItemActModal () { const { item, setItem } = useItemAct() const inputRef = useRef(null) const me = useMe() + const [oValue, setOValue] = useState() useEffect(() => { inputRef.current?.focus() @@ -73,10 +75,26 @@ export function ItemActModal () { label='amount' name='amount' innerRef={inputRef} + overrideValue={oValue} required autoFocus append={sats} /> +
+ {[1, 10, 100, 1000, 10000].map(num => + )} +
tip
diff --git a/components/item-job.js b/components/item-job.js index 8c18773e..837f9743 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -6,6 +6,7 @@ import styles from './item.module.css' import Link from 'next/link' import { timeSince } from '../lib/time' import EmailIcon from '../svgs/mail-open-line.svg' +import Share from './share' export default function ItemJob ({ item, toc, rank, children }) { const isEmail = Yup.string().email().isValidSync(item.url) @@ -73,7 +74,11 @@ export default function ItemJob ({ item, toc, rank, children }) { {item.maxBid > 0 && item.status === 'ACTIVE' && PROMOTED}
- {toc && } + {toc && + <> + + + }
{children && (
diff --git a/components/item.js b/components/item.js index 722c40a8..b5a649f2 100644 --- a/components/item.js +++ b/components/item.js @@ -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 }) {
{showFwdUser && item.fwdUser && } - {toc && } + {toc && + <> + + + } {children && (
diff --git a/components/layout-center.js b/components/layout-center.js index c326de15..41b3d769 100644 --- a/components/layout-center.js +++ b/components/layout-center.js @@ -1,10 +1,10 @@ import Layout from './layout' import styles from './layout-center.module.css' -export default function LayoutCenter ({ children, ...props }) { +export default function LayoutCenter ({ children, footerLinks, ...props }) { return (
- +
{children}
diff --git a/components/notifications.js b/components/notifications.js index 85879bd9..ebd5961e 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..846870f6 --- /dev/null +++ b/components/share.js @@ -0,0 +1,46 @@ +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/top-header.js b/components/top-header.js index 72175c78..e2cffb4f 100644 --- a/components/top-header.js +++ b/components/top-header.js @@ -41,7 +41,7 @@ export default function TopHeader ({ cat }) { onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })} name='sort' size='sm' - items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts'] : ['votes', 'comments', 'sats']} + items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts', 'referrals'] : ['votes', 'comments', 'sats']} /> for router.push(`/referrals/${e.target.value}`)} + /> + + + + +
+
referral link:
+ +
+
    +
  • {`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/rewards.js b/pages/rewards.js new file mode 100644 index 00000000..cc6083cc --- /dev/null +++ b/pages/rewards.js @@ -0,0 +1,142 @@ +import { gql } from 'apollo-server-micro' +import { useEffect, useRef, useState } from 'react' +import { Button, InputGroup, Modal } from 'react-bootstrap' +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts' +import { getGetServerSideProps } from '../api/ssrApollo' +import { Form, Input, SubmitButton } from '../components/form' +import LayoutCenter from '../components/layout-center' +import * as Yup from 'yup' +import { useMutation, useQuery } from '@apollo/client' +import Link from 'next/link' + +const REWARDS = gql` +{ + expectedRewards { + total + sources { + name + value + } + } +} +` + +export const getServerSideProps = getGetServerSideProps(REWARDS) + +export default function Rewards ({ data: { expectedRewards: { total, sources } } }) { + const { data } = useQuery(REWARDS, { pollInterval: 1000 }) + + if (data) { + ({ expectedRewards: { total, sources } } = data) + } + + return ( + +

+
{total} sats to be rewarded today
+ + learn about rewards + +

+
+ +
+ +
+ ) +} + +const COLORS = [ + 'var(--secondary)', + 'var(--info)', + 'var(--success)', + 'var(--boost)', + 'var(--grey)' +] + +function GrowthPieChart ({ data }) { + return ( + + + + { + data.map((entry, index) => ( + + )) + } + + + + + ) +} + +export const DonateSchema = Yup.object({ + amount: Yup.number().typeError('must be a number').required('required') + .positive('must be positive').integer('must be whole') +}) + +export function DonateButton () { + const [show, setShow] = useState(false) + const inputRef = useRef(null) + const [donateToRewards] = useMutation( + gql` + mutation donateToRewards($sats: Int!) { + donateToRewards(sats: $sats) + }`) + + useEffect(() => { + inputRef.current?.focus() + }, [show]) + + return ( + <> + + { + setShow(false) + }} + > +
setShow(false)}>X
+ +
{ + await donateToRewards({ + variables: { + sats: Number(amount) + } + }) + setShow(false) + }} + > + sats} + /> +
+ donate +
+
+
+
+ + ) +} diff --git a/pages/satistics.js b/pages/satistics.js index 58085d8c..1eb331ce 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) @@ -98,6 +97,25 @@ function Detail ({ fact }) { ) } + if (fact.type === 'donation') { + return ( + <> +
+ You made a donation to daily rewards! +
+ + ) + } + if (fact.type === 'referral') { + return ( + <> +
+ You stacked sats from a referral! +
+ + ) + } + if (!fact.item) { return ( <> @@ -145,6 +163,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor case 'invoice': return `/${fact.type}s/${fact.factId}` case 'earn': + case 'donation': + case 'referral': return default: return `/items/${fact.factId}` @@ -201,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 @@ -220,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 41c75073..e955dcf1 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -14,6 +14,7 @@ import { useRouter } from 'next/router' import Info from '../components/info' import { CURRENCY_SYMBOLS } from '../components/price' import Link from 'next/link' +import AccordianItem from '../components/accordian-item' export const getServerSideProps = getGetServerSideProps(SETTINGS) @@ -59,6 +60,7 @@ export default function Settings ({ data: { settings } }) {
sats} + hint={note: you can also press and hold the lightning bolt to tip custom amounts} /> +
+ advanced
} + body={turbo tipping + +
    +
  • Makes every additional bolt click raise your total tip to another 10x multiple of your default tip
  • +
  • e.g. if your tip default is 10 sats +
      +
    • 1st click: 10 sats total tipped
    • +
    • 2nd click: 100 sats total tipped
    • +
    • 3rd click: 1000 sats total tipped
    • +
    • 4th click: 10000 sats total tipped
    • +
    • and so on ...
    • +
    +
  • +
  • You can still custom tip via long press +
      +
    • the next bolt click rounds up to the next greatest 10x multiple of your default
    • +
    +
  • +
+
+ + } + />} + /> +