enhance: referral notifications with source (#1862)
* wip: referral notification shows source of referral * simpler approach for source info gathering * fix territory representation; fix fragment field * cleanup; fix UI * better margin approach * hotfix: null check * add support for comments * use Union to represent ReferralSource; clarify with switch statements * cleanup: compact switch statement on Referral resolver * wip use refereeLanding * add comments; cleanup * hotfix: backwards compatibility for Earnings calculation * small copy and semantics changes --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
bdd87e7d39
commit
1afadbdf3b
@ -467,6 +467,24 @@ export default {
|
||||
return subAct.subName
|
||||
}
|
||||
},
|
||||
ReferralSource: {
|
||||
__resolveType: async (n, args, { models }) => n.type
|
||||
},
|
||||
Referral: {
|
||||
source: async (n, args, { models, me }) => {
|
||||
// retrieve the referee landing record
|
||||
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
|
||||
if (!referral) return null // if no landing record, it will return a generic referral
|
||||
|
||||
switch (referral.type) {
|
||||
case 'POST':
|
||||
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
|
||||
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
|
||||
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
},
|
||||
Streak: {
|
||||
days: async (n, args, { models }) => {
|
||||
const res = await models.$queryRaw`
|
||||
|
@ -124,9 +124,12 @@ export default gql`
|
||||
withdrawl: Withdrawl!
|
||||
}
|
||||
|
||||
union ReferralSource = Item | Sub | User
|
||||
|
||||
type Referral {
|
||||
id: ID!
|
||||
sortTime: Date!
|
||||
source: ReferralSource
|
||||
}
|
||||
|
||||
type SubStatus {
|
||||
|
@ -44,6 +44,7 @@ import classNames from 'classnames'
|
||||
import HolsterIcon from '@/svgs/holster.svg'
|
||||
import SaddleIcon from '@/svgs/saddle.svg'
|
||||
import CCInfo from './info/cc'
|
||||
import { useMe } from './me'
|
||||
|
||||
function Notification ({ n, fresh }) {
|
||||
const type = n.__typename
|
||||
@ -528,11 +529,27 @@ function WithdrawlPaid ({ n }) {
|
||||
}
|
||||
|
||||
function Referral ({ n }) {
|
||||
const { me } = useMe()
|
||||
let referralSource = 'of you'
|
||||
switch (n.source?.__typename) {
|
||||
case 'Item':
|
||||
referralSource = (Number(me?.id) === Number(n.source.user?.id) ? 'of your' : 'you shared this') + ' ' + (n.source.title ? 'post' : 'comment')
|
||||
break
|
||||
case 'Sub':
|
||||
referralSource = (Number(me?.id) === Number(n.source.userId) ? 'of your' : 'you shared the') + ' ~' + n.source.name + ' territory'
|
||||
break
|
||||
case 'User':
|
||||
referralSource = (me?.name === n.source.name ? 'of your profile' : `you shared ${n.source.name}'s profile`)
|
||||
break
|
||||
}
|
||||
return (
|
||||
<small className='fw-bold text-success'>
|
||||
<UserAdd className='fill-success me-2' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because of you
|
||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
<>
|
||||
<small className='fw-bold text-success'>
|
||||
<UserAdd className='fill-success me-1' height={21} width={21} style={{ transform: 'rotateY(180deg)' }} />someone joined SN because {referralSource}
|
||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||
</small>
|
||||
{n.source?.__typename === 'Item' && <NoteItem itemClassName='pt-2' item={n.source} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -112,6 +112,18 @@ export const NOTIFICATIONS = gql`
|
||||
... on Referral {
|
||||
id
|
||||
sortTime
|
||||
source {
|
||||
__typename
|
||||
... on Item {
|
||||
...ItemFullFields
|
||||
}
|
||||
... on Sub {
|
||||
...SubFields
|
||||
}
|
||||
... on User {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
... on Reply {
|
||||
id
|
||||
|
@ -9,6 +9,26 @@ const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })
|
||||
const SN_REFERRER = 'sn_referrer'
|
||||
// we use this to hold /r/... referrers through the redirect
|
||||
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
|
||||
// key for referred pages
|
||||
const SN_REFEREE_LANDING = 'sn_referee_landing'
|
||||
|
||||
function getContentReferrer (request, url) {
|
||||
if (itemPattern.test(url)) {
|
||||
let id = request.nextUrl.searchParams.get('commentId')
|
||||
if (!id) {
|
||||
({ id } = itemPattern.exec(url).pathname.groups)
|
||||
}
|
||||
return `item-${id}`
|
||||
}
|
||||
if (profilePattern.test(url)) {
|
||||
const { name } = profilePattern.exec(url).pathname.groups
|
||||
return `profile-${name}`
|
||||
}
|
||||
if (territoryPattern.test(url)) {
|
||||
const { name } = territoryPattern.exec(url).pathname.groups
|
||||
return `territory-${name}`
|
||||
}
|
||||
}
|
||||
|
||||
// we store the referrers in cookies for a future signup event
|
||||
// we pass the referrers in the request headers so we can use them in referral rewards for logged in stackers
|
||||
@ -25,6 +45,14 @@ function referrerMiddleware (request) {
|
||||
// referrers. Content referrers do not override explicit referrers because
|
||||
// explicit referees might click around before signing up.
|
||||
response.cookies.set(SN_REFERRER, referrer, { maxAge: 60 * 60 * 24 })
|
||||
|
||||
// we record the first page the user lands on and keep it for 24 hours
|
||||
// in addition to the explicit referrer, this allows us to tell the referrer
|
||||
// which share link the user clicked on
|
||||
const contentReferrer = getContentReferrer(request, url)
|
||||
if (contentReferrer) {
|
||||
response.cookies.set(SN_REFEREE_LANDING, contentReferrer, { maxAge: 60 * 60 * 24 })
|
||||
}
|
||||
// store the explicit referrer for one page load
|
||||
// this allows us to attribute both explicit and implicit referrers after the redirect
|
||||
// e.g. items/<num>/r/<referrer> links should attribute both the item op and the referrer
|
||||
@ -33,22 +61,9 @@ function referrerMiddleware (request) {
|
||||
return response
|
||||
}
|
||||
|
||||
let contentReferrer
|
||||
if (itemPattern.test(request.url)) {
|
||||
let id = request.nextUrl.searchParams.get('commentId')
|
||||
if (!id) {
|
||||
({ id } = itemPattern.exec(request.url).pathname.groups)
|
||||
}
|
||||
contentReferrer = `item-${id}`
|
||||
} else if (profilePattern.test(request.url)) {
|
||||
const { name } = profilePattern.exec(request.url).pathname.groups
|
||||
contentReferrer = `profile-${name}`
|
||||
} else if (territoryPattern.test(request.url)) {
|
||||
const { name } = territoryPattern.exec(request.url).pathname.groups
|
||||
contentReferrer = `territory-${name}`
|
||||
}
|
||||
const contentReferrer = getContentReferrer(request, request.url)
|
||||
|
||||
// pass the referrers to SSR in the request headers
|
||||
// pass the referrers to SSR in the request headers for one day referrer attribution
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
|
||||
if (referrers.length) {
|
||||
|
@ -40,20 +40,46 @@ function getEventCallbacks () {
|
||||
}
|
||||
}
|
||||
|
||||
async function getReferrerId (referrer) {
|
||||
async function getReferrerFromCookie (referrer) {
|
||||
let referrerId
|
||||
let type
|
||||
let typeId
|
||||
try {
|
||||
if (referrer.startsWith('item-')) {
|
||||
return (await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } }))?.userId
|
||||
const item = await prisma.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
|
||||
type = item?.parentId ? 'COMMENT' : 'POST'
|
||||
referrerId = item?.userId
|
||||
typeId = item?.id
|
||||
} else if (referrer.startsWith('profile-')) {
|
||||
return (await prisma.user.findUnique({ where: { name: referrer.slice(8) } }))?.id
|
||||
const user = await prisma.user.findUnique({ where: { name: referrer.slice(8) } })
|
||||
type = 'PROFILE'
|
||||
referrerId = user?.id
|
||||
typeId = user?.id
|
||||
} else if (referrer.startsWith('territory-')) {
|
||||
return (await prisma.sub.findUnique({ where: { name: referrer.slice(10) } }))?.userId
|
||||
type = 'TERRITORY'
|
||||
typeId = referrer.slice(10)
|
||||
const sub = await prisma.sub.findUnique({ where: { name: typeId } })
|
||||
referrerId = sub?.userId
|
||||
} else {
|
||||
return (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
||||
return {
|
||||
referrerId: (await prisma.user.findUnique({ where: { name: referrer } }))?.id
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error getting referrer id', error)
|
||||
return
|
||||
}
|
||||
return { referrerId, type, typeId: String(typeId) }
|
||||
}
|
||||
|
||||
async function getReferrerData (referrer, landing) {
|
||||
const referrerData = await getReferrerFromCookie(referrer)
|
||||
if (landing) {
|
||||
const landingData = await getReferrerFromCookie(landing)
|
||||
// explicit referrer takes precedence over landing referrer
|
||||
return { ...landingData, ...referrerData }
|
||||
}
|
||||
return referrerData
|
||||
}
|
||||
|
||||
/** @returns {Partial<import('next-auth').CallbacksOptions>} */
|
||||
@ -77,10 +103,14 @@ function getCallbacks (req, res) {
|
||||
// isNewUser doesn't work for nostr/lightning auth because we create the user before nextauth can
|
||||
// this means users can update their referrer if they don't have one, which is fine
|
||||
if (req.cookies.sn_referrer && user?.id) {
|
||||
const referrerId = await getReferrerId(req.cookies.sn_referrer)
|
||||
if (referrerId && referrerId !== parseInt(user?.id)) {
|
||||
const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId } })
|
||||
if (count > 0) notifyReferral(referrerId)
|
||||
const referrerData = await getReferrerData(req.cookies.sn_referrer, req.cookies.sn_referee_landing)
|
||||
if (referrerData?.referrerId && referrerData.referrerId !== parseInt(user?.id)) {
|
||||
// if we have recorded a referee landing, record it in the db
|
||||
if (referrerData.type && referrerData.typeId) {
|
||||
await prisma.oneDayReferral.create({ data: { ...referrerData, refereeId: user.id, landing: true } })
|
||||
}
|
||||
const { count } = await prisma.user.updateMany({ where: { id: user.id, referrerId: null }, data: { referrerId: referrerData.referrerId } })
|
||||
if (count > 0) notifyReferral(referrerData.referrerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OneDayReferral" ADD COLUMN "landing" BOOLEAN NOT NULL DEFAULT false;
|
@ -176,6 +176,7 @@ model OneDayReferral {
|
||||
referee User @relation("OneDayReferral_referrees", fields: [refereeId], references: [id], onDelete: Cascade)
|
||||
type OneDayReferralType
|
||||
typeId String
|
||||
landing Boolean @default(false)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([referrerId])
|
||||
|
@ -74,6 +74,7 @@ export async function earn ({ name }) {
|
||||
FROM earners
|
||||
LEFT JOIN "OneDayReferral" ON "OneDayReferral"."refereeId" = earners."userId"
|
||||
WHERE "OneDayReferral".created_at >= date_trunc('day', now() AT TIME ZONE 'America/Chicago' - interval '1 day')
|
||||
AND "OneDayReferral".landing IS NOT TRUE
|
||||
GROUP BY earners."userId", earners."foreverReferrerId", earners.proportion, earners.rank
|
||||
ORDER BY rank ASC`
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user