Custom invite code and note (#1649)
* Custom invite code and note * disable autocomplete and hide invite code under advanced * show invite description only to the owner * note->description and move unser advanced * Update lib/validate.js Co-authored-by: ekzyis <ek@stacker.news> * Update lib/webPush.js Co-authored-by: ekzyis <ek@stacker.news> * Update api/typeDefs/invite.js Co-authored-by: ekzyis <ek@stacker.news> * Update pages/invites/index.js Co-authored-by: ekzyis <ek@stacker.news> * Update pages/invites/index.js Co-authored-by: ekzyis <ek@stacker.news> * fix * apply review suggestions * change limits * Update lib/validate.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * don't show invite id in push notification * remove invoice metadata from push notifications * fix form reset, jsx/dom attrs, accidental uncontrolled prop warnings * support underscores as we claim * increase default gift to fit inflation --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
76e384b188
commit
7f11792111
|
@ -2,6 +2,7 @@ import { inviteSchema, validateSchema } from '@/lib/validate'
|
|||
import { msatsToSats } from '@/lib/format'
|
||||
import assertApiKeyNotPermitted from './apiKey'
|
||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
|
@ -9,7 +10,6 @@ export default {
|
|||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
|
||||
return await models.invite.findMany({
|
||||
where: {
|
||||
userId: me.id
|
||||
|
@ -29,17 +29,31 @@ export default {
|
|||
},
|
||||
|
||||
Mutation: {
|
||||
createInvite: async (parent, { gift, limit }, { me, models }) => {
|
||||
createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
assertApiKeyNotPermitted({ me })
|
||||
|
||||
await validateSchema(inviteSchema, { gift, limit })
|
||||
|
||||
await validateSchema(inviteSchema, { id, gift, limit, description })
|
||||
try {
|
||||
return await models.invite.create({
|
||||
data: { gift, limit, userId: me.id }
|
||||
data: {
|
||||
id,
|
||||
gift,
|
||||
limit,
|
||||
userId: me.id,
|
||||
description
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002' && error.meta.target.includes('id')) {
|
||||
throw new GqlInputError('an invite with this code already exists')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
revokeInvite: async (parent, { id }, { me, models }) => {
|
||||
if (!me) {
|
||||
|
@ -70,6 +84,9 @@ export default {
|
|||
poor: async (invite, args, { me, models }) => {
|
||||
const user = await models.user.findUnique({ where: { id: invite.userId } })
|
||||
return msatsToSats(user.msats) < invite.gift
|
||||
},
|
||||
description: (invite, args, { me }) => {
|
||||
return invite.userId === me?.id ? invite.description : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ export default gql`
|
|||
}
|
||||
|
||||
extend type Mutation {
|
||||
createInvite(gift: Int!, limit: Int): Invite
|
||||
createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
|
||||
revokeInvite(id: ID!): Invite
|
||||
}
|
||||
|
||||
|
@ -20,5 +20,6 @@ export default gql`
|
|||
user: User!
|
||||
revoked: Boolean!
|
||||
poor: Boolean!
|
||||
description: String
|
||||
}
|
||||
`
|
||||
|
|
|
@ -20,6 +20,7 @@ export default function Invite ({ invite, active }) {
|
|||
<div
|
||||
className={styles.invite}
|
||||
>
|
||||
{invite.description && <small className='text-muted'>{invite.description}</small>}
|
||||
<CopyInput
|
||||
groupClassName='mb-1'
|
||||
size='sm' type='text'
|
||||
|
|
|
@ -283,7 +283,7 @@ function Invitification ({ n }) {
|
|||
<>
|
||||
<NoteHeader color='secondary'>
|
||||
your invite has been redeemed by
|
||||
{numWithUnits(n.invite.invitees.length, {
|
||||
{' ' + numWithUnits(n.invite.invitees.length, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'stacker',
|
||||
unitPlural: 'stackers'
|
||||
|
|
|
@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
|
|||
}
|
||||
},
|
||||
onSuccessfulSubmit: (data, { resetForm }) => {
|
||||
resetForm({ text: '' })
|
||||
resetForm({ values: { text: '' } })
|
||||
setReply(replyOpen || false)
|
||||
},
|
||||
navigateOnSubmit: false
|
||||
|
|
|
@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
|
|||
...StreakFields
|
||||
}
|
||||
poor
|
||||
description
|
||||
}
|
||||
`
|
||||
|
|
|
@ -478,7 +478,9 @@ export const bioSchema = object({
|
|||
|
||||
export const inviteSchema = object({
|
||||
gift: intValidator.positive('must be greater than 0').required('required'),
|
||||
limit: intValidator.positive('must be positive')
|
||||
limit: intValidator.positive('must be positive'),
|
||||
description: string().trim().max(40, 'must be at most 40 characters'),
|
||||
id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(4, 'must be at least 4 characters').max(32, 'must be at most 32 characters')
|
||||
})
|
||||
|
||||
export const pushSubscriptionSchema = object({
|
||||
|
|
|
@ -9,6 +9,8 @@ import Invite from '@/components/invite'
|
|||
import { inviteSchema } from '@/lib/validate'
|
||||
import { SSR } from '@/lib/constants'
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import Info from '@/components/info'
|
||||
import Text from '@/components/text'
|
||||
|
||||
// force SSR to include CSP nonces
|
||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||
|
@ -17,8 +19,8 @@ function InviteForm () {
|
|||
const [createInvite] = useMutation(
|
||||
gql`
|
||||
${INVITE_FIELDS}
|
||||
mutation createInvite($gift: Int!, $limit: Int) {
|
||||
createInvite(gift: $gift, limit: $limit) {
|
||||
mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
|
||||
createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
|
||||
...InviteFields
|
||||
}
|
||||
}`, {
|
||||
|
@ -39,20 +41,28 @@ function InviteForm () {
|
|||
}
|
||||
)
|
||||
|
||||
const initialValues = {
|
||||
id: '',
|
||||
gift: 1000,
|
||||
limit: 1,
|
||||
description: ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
gift: 100,
|
||||
limit: 1
|
||||
}}
|
||||
initial={initialValues}
|
||||
schema={inviteSchema}
|
||||
onSubmit={async ({ limit, gift }) => {
|
||||
onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
|
||||
const { error } = await createInvite({
|
||||
variables: {
|
||||
gift: Number(gift), limit: limit ? Number(limit) : limit
|
||||
id: id || undefined,
|
||||
gift: Number(gift),
|
||||
limit: limit ? Number(limit) : limit,
|
||||
description: description || undefined
|
||||
}
|
||||
})
|
||||
if (error) throw error
|
||||
resetForm({ values: initialValues })
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
|
@ -65,8 +75,40 @@ function InviteForm () {
|
|||
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
|
||||
name='limit'
|
||||
/>
|
||||
|
||||
<SubmitButton variant='secondary'>create</SubmitButton>
|
||||
<AccordianItem
|
||||
headerColor='#6c757d' header='advanced' body={
|
||||
<>
|
||||
<Input
|
||||
prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
|
||||
label={<>invite code <small className='text-muted ms-2'>optional</small></>}
|
||||
name='id'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<>
|
||||
<div className='d-flex align-items-center'>
|
||||
description <small className='text-muted ms-2'>optional</small>
|
||||
<Info>
|
||||
<Text>
|
||||
A brief description to keep track of the invite purpose, such as "Shared in group chat".
|
||||
This description is private and visible only to you.
|
||||
</Text>
|
||||
</Info>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
name='description'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SubmitButton
|
||||
className='mt-4'
|
||||
variant='secondary'
|
||||
>create
|
||||
</SubmitButton>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Invite" ADD COLUMN "description" TEXT;
|
|
@ -477,6 +477,8 @@ model Invite {
|
|||
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
|
||||
invitees User[]
|
||||
|
||||
description String?
|
||||
|
||||
@@index([createdAt], map: "Invite.created_at_index")
|
||||
@@index([userId], map: "Invite.userId_index")
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue