Skip to content

Commit

Permalink
feat: redeem 100% off coupons (#171)
Browse files Browse the repository at this point in the history
* feat: redeem coupons

* feat: redeem coupons

* changeset added
  • Loading branch information
joelhooks committed May 4, 2024
1 parent 22c290a commit 78d8536
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 34 deletions.
6 changes: 6 additions & 0 deletions .changeset/wild-glasses-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@coursebuilder/adapter-drizzle": patch
"@coursebuilder/core": patch
---

enables coupon redemption for 100% of "golden tickets"
3 changes: 2 additions & 1 deletion apps/course-builder-web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Party } from '@/app/_components/party'
import { Providers } from '@/app/_components/providers'
import Navigation from '@/components/navigation'
import { ThemeProvider } from '@/components/theme-provider'
import { CouponProvider } from '@/pricing/coupon-context'
import { TRPCReactProvider } from '@/trpc/react'
import { ourFileRouter } from '@/uploadthing/core'
import { NextSSRPlugin } from '@uploadthing/react/next-ssr-plugin'
Expand Down Expand Up @@ -54,7 +55,7 @@ export default function RootLayout({
*/
routerConfig={extractRouterConfig(ourFileRouter)}
/>
{children}
<CouponProvider>{children}</CouponProvider>
</main>
</div>
</ThemeProvider>
Expand Down
13 changes: 12 additions & 1 deletion apps/course-builder-web/src/lib/props-for-commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,29 @@ export const validateCoupon = async (
}

export async function getCouponForCode(
code: string,
code: string | null,
productIds: string[] = [],
) {
if (!code) return undefined

let couponFromCode = code && (await courseBuilderAdapter.getCoupon(code))

if (couponFromCode) {
if (
productIds.length === 0 &&
couponFromCode.restrictedToProductId &&
couponFromCode.percentageDiscount === 1
) {
productIds = [couponFromCode?.restrictedToProductId]
}
const validatedCoupon = await validateCoupon(couponFromCode, productIds)
return {
...couponFromCode,
...validatedCoupon,
}
}

return undefined
}

type PropsForCommerce = {
Expand Down
57 changes: 57 additions & 0 deletions apps/course-builder-web/src/pricing/coupon-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client'

import * as React from 'react'
import { use } from 'react'
import { getProduct } from '@/lib/products-query'
import { getCouponForCode } from '@/lib/props-for-commerce'
import RedeemDialog from '@/pricing/redeem-dialog'

import { Product } from '@coursebuilder/core/schemas'

export const CouponContext = React.createContext<any>({})

export const CouponProvider = ({ children }: { children: React.ReactNode }) => {
const [couponLoader, setCouponLoader] =
React.useState<ReturnType<typeof getCouponForCode>>()
const [productLoader, setProductLoader] = React.useState<
Promise<Product | null> | undefined
>()

React.useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)

const codeParam = searchParams.get('code')
const couponParam = searchParams.get('coupon')

setCouponLoader(getCouponForCode(codeParam || couponParam))
}, [])

const coupon = couponLoader ? use(couponLoader) : undefined
const validCoupon = Boolean(coupon && coupon.isValid)

React.useEffect(() => {
if (coupon?.isValid) {
setProductLoader(getProduct(coupon.restrictedToProductId as string))
}
}, [coupon?.restrictedToProductId, coupon?.isValid])

const product = productLoader ? use(productLoader) : undefined
const isRedeemable = validCoupon && product && coupon?.isRedeemable

console.log({ coupon, product })

return (
<CouponContext.Provider value={{ coupon }}>
<>
{isRedeemable && (
<RedeemDialog
open={validCoupon}
couponId={coupon?.id}
product={product}
/>
)}
{children}
</>
</CouponContext.Provider>
)
}
33 changes: 23 additions & 10 deletions apps/course-builder-web/src/pricing/redeem-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Balancer from 'react-wrap-balancer'
import * as Yup from 'yup'

import { Product } from '@coursebuilder/core/schemas'
import { Button, Input, Label } from '@coursebuilder/ui'

import { redeemFullPriceCoupon } from './redeem-full-price-coupon'

Expand All @@ -22,10 +23,11 @@ interface RedeemDialogProps {
}

const RedeemDialog = ({
open = false,
open: initialOpen = false,
couponId,
product,
}: RedeemDialogProps) => {
const [open, setOpen] = React.useState(initialOpen)
const { data: session } = useSession()
const router = useRouter()

Expand Down Expand Up @@ -54,6 +56,7 @@ const RedeemDialog = ({
} else {
router.push(`/thanks/redeem?purchaseId=${purchase?.id}`)
}
setOpen(false)
}
},
})
Expand All @@ -70,10 +73,17 @@ const RedeemDialog = ({
{image && title && (
<div className="flex w-full flex-col items-center justify-center border-b border-gray-200 px-5 pb-5 pt-8 text-center dark:border-gray-700">
{image && (
<Image src={image.url} alt="" aria-hidden layout="fill" />
<Image
src={image.url}
alt=""
aria-hidden
width={100}
height={100}
priority
/>
)}
{title ? (
<div className="pt-5 text-lg font-medium">
<div className="p-5 px-5 text-lg font-medium">
<Balancer>
Coupon for {title} by{' '}
{process.env.NEXT_PUBLIC_PARTNER_FIRST_NAME}{' '}
Expand All @@ -93,8 +103,8 @@ const RedeemDialog = ({
</AlertDialogPrimitive.Description>
<form onSubmit={formik.handleSubmit}>
<div data-email="">
<label htmlFor="email">Email address</label>
<input
<Label htmlFor="email">Email address</Label>
<Input
required
id="email"
type="email"
Expand All @@ -105,21 +115,24 @@ const RedeemDialog = ({
</div>
<div data-actions="">
<AlertDialogPrimitive.Cancel asChild>
<button
<Button
onClick={(e) => {
const code = query.get('code')
const pathname = pathName.replace(`?code=${code}`, '')
let pathname = pathName.replace(`?code=${code}`, '')
const coupon = query.get('coupon')
pathname = pathname.replace(`?coupon=${coupon}`, '')
router.push(pathname)
setOpen(false)
}}
data-cancel=""
>
Cancel
</button>
</Button>
</AlertDialogPrimitive.Cancel>
<AlertDialogPrimitive.Action asChild>
<button data-submit="" type="submit">
<Button data-submit="" type="submit">
Yes, Claim License
</button>
</Button>
</AlertDialogPrimitive.Action>
</div>
</form>
Expand Down
1 change: 1 addition & 0 deletions apps/course-builder-web/src/pricing/use-coupon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type CouponValidator = {
isRedeemable: boolean
}

// CouponValidator is from CommerceProps as `couponFromCode`
export function useCoupon(coupon?: CouponValidator, product?: Product) {
const [validCoupon, setValidCoupon] = React.useState(false)
React.useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/course-builder-web/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

@import './commerce.css';
@import './login.css';
@import './redeem-dialog.css';

@layer base {
:root {
Expand Down
40 changes: 40 additions & 0 deletions apps/course-builder-web/src/styles/redeem-dialog.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* Redeem dialog */
[data-redeem-dialog] {
@apply h-[400px] overflow-y-scroll;
}
[data-redeem-dialog-content] {
@apply bg-background dark:bg-secondary fixed left-1/2 top-1/2 z-[60] max-h-[90vh] w-full max-w-[95%] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-md shadow-xl sm:max-w-md;
[data-title] {
@apply px-8 pt-8 text-xl font-bold;
}
[data-description] {
@apply px-8 pb-4 pt-4 opacity-80;
}
form {
@apply px-8 py-4;
[data-email] {
@apply mt-2 flex flex-col;
label {
@apply pb-1;
}
input {
@apply dark:bg-background rounded border border-gray-300 bg-gray-200 px-4 py-2 dark:border-gray-900;
}
}
[data-actions] {
@apply flex w-full justify-end gap-3 py-8;
[data-cancel] {
@apply dark:bg-background flex rounded bg-gray-200 px-4 py-2 text-sm font-medium;
}
[data-submit] {
@apply bg-primary flex rounded border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm transition;
}
[data-submit]:hover {
@apply brightness-105;
}
}
}
}
[data-redeem-dialog-overlay] {
@apply fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-sm;
}
45 changes: 42 additions & 3 deletions packages/adapter-drizzle/src/lib/mysql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,23 +259,33 @@ export function mySqlDrizzleAdapter(
} = options
const email = String(baseEmail).replace(' ', '+')

console.log('email', email)

const coupon = await adapter.getCouponWithBulkPurchases(couponId)

console.log('馃拃 coupon', coupon)

const productId =
(coupon && (coupon.restrictedToProductId as string)) ||
redeemingProductId

console.log('馃 productId', productId)

if (!productId) throw new Error(`unable-to-find-any-product-id`)

const couponValidation = validateCoupon(coupon, productIds)

console.log('馃 couponValidation', couponValidation)

if (coupon && couponValidation.isRedeemable) {
// if the Coupon is the Bulk Coupon of a Bulk Purchase,
// then a bulk coupon is being redeemed
const bulkCouponRedemption = Boolean(
coupon.bulkCouponPurchases[0]?.bulkCouponId,
)

console.log('馃 bulkCouponRedemption', bulkCouponRedemption)

const { user } = await adapter.findOrCreateUser(email)

if (!user) throw new Error(`unable-to-create-user-${email}`)
Expand All @@ -284,8 +294,12 @@ export function mySqlDrizzleAdapter(
? await adapter.getUserById(currentUserId)
: null

console.log('馃懚馃徎 currentUser', currentUser)

const redeemingForCurrentUser = currentUser?.id === user.id

console.log('馃懚馃徎 redeemingForCurrentUser', redeemingForCurrentUser)

// To prevent double-purchasing, check if this user already has a
// Purchase record for this product that is valid and wasn't a bulk
// coupon purchase.
Expand All @@ -295,6 +309,8 @@ export function mySqlDrizzleAdapter(
productId,
})

console.log('馃尳 existingPurchases', existingPurchases)

if (existingPurchases.length > 0)
throw new Error(`already-purchased-${email}`)

Expand All @@ -314,6 +330,8 @@ export function mySqlDrizzleAdapter(

const newPurchase = await adapter.getPurchase(purchaseId)

console.log('馃尳 newPurchase', newPurchase)

await adapter.incrementCouponUsedCount(coupon.id)

await adapter.createPurchaseTransfer({
Expand Down Expand Up @@ -844,13 +862,34 @@ export function mySqlDrizzleAdapter(
.then((res) => res[0] ?? null),
)
},
getCouponWithBulkPurchases(couponId: string): Promise<
async getCouponWithBulkPurchases(couponId: string): Promise<
| (Coupon & {
bulkCouponPurchases: { bulkCouponId: string }[]
bulkCouponPurchases: { bulkCouponId?: string | null }[]
})
| null
> {
throw new Error('getCouponWithBulkPurchases Method not implemented.')
const couponData =
(await client.query.coupon.findFirst({
where: eq(coupon.id, couponId),
with: {
bulkCouponPurchases: true,
},
})) || null

const parsedCoupon = couponSchema
.merge(
z.object({
bulkCouponPurchases: z.array(purchaseSchema),
}),
)
.nullable()
.safeParse(couponData)

if (!parsedCoupon.success) {
console.error('Error parsing coupon', couponData)
return null
}
return parsedCoupon.data
},
async getDefaultCoupon(productIds?: string[]): Promise<{
defaultMerchantCoupon: MerchantCoupon
Expand Down
Loading

0 comments on commit 78d8536

Please sign in to comment.