Skip to content

Commit

Permalink
feat: add the product pricing widget (#167)
Browse files Browse the repository at this point in the history
* feat: add the product pricing widget

* feat: basic redemption pass for "golden ticket"

* feat: purchase lifecycle send emails

* chore minor fixes

* add changeset

* run format

* chore type fixes

* chore: update dependencies

* chore: update dependencies

* chore: appease a test

apparently it's picky about the syntax of the es target in ts config of projects it depends on 馃

* feat: include invoice link when a merchantChargeId is passed to email template

* email style tweaks

* adds email login for coursebuilder

* fix: build

* update lock
  • Loading branch information
joelhooks committed Apr 29, 2024
1 parent f3a4cfa commit 39b0ef5
Show file tree
Hide file tree
Showing 81 changed files with 4,460 additions and 4,106 deletions.
12 changes: 12 additions & 0 deletions .changeset/smart-experts-poke.md
@@ -0,0 +1,12 @@
---
"@coursebuilder/eslint-plugin": patch
"@coursebuilder/adapter-drizzle": patch
"@coursebuilder/email-templates": patch
"@coursebuilder/tsup-config": patch
"@coursebuilder/react-rsc": patch
"utils": patch
"@coursebuilder/core": patch
"create-course-app": patch
---

redemptions and purchase recording with an email at the end
3 changes: 2 additions & 1 deletion .gitignore
Expand Up @@ -11,6 +11,7 @@ dist-ssr
server/dist
public/dist
.idea
.react-email

packages/*/*.js
packages/*/*.d.ts
Expand Down Expand Up @@ -43,4 +44,4 @@ apps/*/public/sitemap-[0-99].xml
apps/*/public/robots.txt

# test results
apps/*/test-results
apps/*/test-results
1 change: 1 addition & 0 deletions apps/course-builder-web/.env.example
Expand Up @@ -11,6 +11,7 @@

NEXT_PUBLIC_APP_NAME=app-name
NEXT_PUBLIC_SUPPORT_EMAIL=team@coursebuilder.dev
NEXT_PUBLIC_SITE_TITLE="Course Builder"

COURSEBUILDER_URL="http://example.com"

Expand Down
20 changes: 10 additions & 10 deletions apps/course-builder-web/package.json
Expand Up @@ -30,7 +30,7 @@
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.0.3",
"@atlaskit/select": "^17.3.4",
"@atlaskit/tokens": "^1.42.1",
"@auth/core": "^0.28.1",
"@auth/core": "^0.30.0",
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/client-textract": "^3.525.0",
"@aws-sdk/credential-providers": "^3.525.0",
Expand All @@ -55,7 +55,7 @@
"@mdx-js/react": "^3.0.0",
"@msgpack/msgpack": "3.0.0-beta2",
"@mux/mux-player-react": "^2.2.0",
"@next/mdx": "14.3.0-canary.18",
"@next/mdx": "14.3.0-canary.29",
"@pinecone-database/pinecone": "^2.1.0",
"@planetscale/database": "1.14.0",
"@radix-ui/colors": "^3.0.0",
Expand All @@ -76,7 +76,7 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@react-email/components": "^0.0.11",
"@react-email/components": "^0.0.16",
"@react-email/markdown": "^0.0.7",
"@react-email/render": "^0.0.9",
"@sanity/icons": "^2.6.0",
Expand Down Expand Up @@ -121,8 +121,8 @@
"memoize-one": "^6.0.0",
"mjml": "^4.15.3",
"nanoid": "^5.0.2",
"next": "14.3.0-canary.18",
"next-auth": "5.0.0-beta.16",
"next": "14.3.0-canary.29",
"next-auth": "5.0.0-beta.17",
"next-axiom": "^1.1.1",
"next-mdx-remote": "^4.4.1",
"next-themes": "^0.3",
Expand All @@ -135,10 +135,10 @@
"pluralize": "^8.0.0",
"prism-react-renderer": "^2.2.0",
"query-string": "^9.0.0",
"react": "18.3.0-canary-feed8f3f9-20240118",
"react": "18.3.1",
"react-aria": "^3.32.1",
"react-countdown": "^2.3.5",
"react-dom": "18.3.0-canary-feed8f3f9-20240118",
"react-dom": "18.3.1",
"react-gravatar": "^2.6.3",
"react-hook-form": "^7.48.0",
"react-hot-toast": "^2.4.1",
Expand Down Expand Up @@ -181,8 +181,8 @@
"@types/mjml": "^4.7.4",
"@types/node": "^20.12.5",
"@types/pluralize": "^0.0.33",
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.22",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react-gravatar": "^2.6.13",
"@types/styled-components": "^5.1.29",
"autoprefixer": "^10.4.14",
Expand All @@ -201,7 +201,7 @@
"tailwindcss": "^3.3.3",
"ts-node": "^10.9.2",
"tsx": "^4.7.1",
"typescript": "5.4.3"
"typescript": "5.4.5"
},
"ct3aMetadata": {
"initVersion": "7.22.0"
Expand Down
@@ -0,0 +1,39 @@
'use client'

import * as React from 'react'
import { PricingData } from '@/lib/pricing-query'
import { CommerceProps } from '@/pricing/commerce-props'
import { PriceCheckProvider } from '@/pricing/pricing-check-context'
import { PricingWidget } from '@/pricing/pricing-widget'

export function ProductPricing({
product,
quantityAvailable,
pricingDataLoader,
commerceProps,
purchasedProductIds,
hasPurchasedCurrentProduct,
}: {
product: any
quantityAvailable: number
commerceProps: CommerceProps
pricingDataLoader: Promise<PricingData>
purchasedProductIds: string[]
hasPurchasedCurrentProduct?: boolean
}) {
console.log({ product, quantityAvailable, commerceProps, pricingDataLoader })
return (
<>
{product && (
<PriceCheckProvider purchasedProductIds={purchasedProductIds}>
<PricingWidget
commerceProps={{ ...commerceProps, products: [product] }}
product={product}
quantityAvailable={quantityAvailable}
pricingDataLoader={pricingDataLoader}
/>
</PriceCheckProvider>
)}
</>
)
}
Expand Up @@ -17,12 +17,10 @@ import {
archiveProduct,
updateProduct,
} from '@/lib/products-query'
import { addResourceToTutorial } from '@/lib/tutorials-query'
import { api } from '@/trpc/react'
import { cn } from '@/utils/cn'
import { User } from '@auth/core/types'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, ChevronsUpDown, ImagePlusIcon } from 'lucide-react'
import { ChevronsUpDown, ImagePlusIcon } from 'lucide-react'
import { useSession } from 'next-auth/react'
import { useTheme } from 'next-themes'
import { useForm, type UseFormReturn } from 'react-hook-form'
Expand Down Expand Up @@ -401,7 +399,7 @@ function EditProductFormDesktop({
}}
onArchive={() => {
form.setValue('fields.state', 'archived')
onSubmit(form.getValues())
onArchive(form.getValues())
}}
onUnPublish={() => {
form.setValue('fields.state', 'draft')
Expand Down
108 changes: 107 additions & 1 deletion apps/course-builder-web/src/app/(commerce)/products/[slug]/page.tsx
@@ -1,12 +1,20 @@
import { ParsedUrlQuery } from 'querystring'
import * as React from 'react'
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ProductPricing } from '@/app/(commerce)/products/[slug]/_components/product-pricing'
import { EventPageProps } from '@/app/(content)/events/[slug]/_components/event-page-props'
import { courseBuilderAdapter, db } from '@/db'
import { products, purchases } from '@/db/schema'
import { getPricingData } from '@/lib/pricing-query'
import { getProduct } from '@/lib/products-query'
import { propsForCommerce } from '@/lib/props-for-commerce'
import { getServerAuthSession } from '@/server/auth'
import { count, eq } from 'drizzle-orm'
import ReactMarkdown from 'react-markdown'

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

async function ProductActionBar({
Expand Down Expand Up @@ -76,8 +84,11 @@ async function ProductTitle({

export default async function ProductPage({
params,
searchParams,
}: {
params: { slug: string }
//arbitrary search params or query string
searchParams: ParsedUrlQuery
}) {
const productLoader = getProduct(params.slug)

Expand All @@ -93,7 +104,102 @@ export default async function ProductPage({
<article className="mx-auto flex w-full max-w-screen-lg flex-col px-5 py-10 md:py-16">
<ProductTitle productLoader={productLoader} />
<ProductDetails productLoader={productLoader} />
<ProductCommerce
productLoader={productLoader}
searchParams={searchParams}
/>
</article>
</div>
)
}

async function ProductCommerce({
productLoader,
searchParams,
}: {
productLoader: Promise<Product | null>
searchParams: ParsedUrlQuery
}) {
const { session, ability } = await getServerAuthSession()
const user = session?.user
const product = await productLoader
console.log({ product })
if (!product) return null
const pricingDataLoader = getPricingData(product?.id)
let productProps: any

let commerceProps = await propsForCommerce({
query: {
...searchParams,
allowPurchase: 'true',
},
userId: user?.id,
products: [product],
})

const { count: purchaseCount } = await db
.select({ count: count() })
.from(purchases)
.where(eq(purchases.productId, product.id))
.then((res) => res[0] ?? { count: 0 })

const productWithQuantityAvailable = await db
.select({ quantityAvailable: products.quantityAvailable })
.from(products)
.where(eq(products.id, product.id))
.then((res) => res[0])

let quantityAvailable = -1

if (productWithQuantityAvailable) {
quantityAvailable =
productWithQuantityAvailable.quantityAvailable - purchaseCount
}

if (quantityAvailable < 0) {
quantityAvailable = -1
}

const purchaseForProduct = commerceProps.purchases?.find(
(purchase: Purchase) => {
return purchase.productId === product.id
},
)

const baseProps = {
availableBonuses: [],
purchaseCount,
quantityAvailable,
totalQuantity: productWithQuantityAvailable?.quantityAvailable || 0,
product,
pricingDataLoader,
...commerceProps,
}

console.log({ baseProps })

productProps = baseProps

if (user && purchaseForProduct) {
const { purchase, existingPurchase } =
await courseBuilderAdapter.getPurchaseDetails(
purchaseForProduct.id,
user.id,
)

productProps = {
...baseProps,
hasPurchasedCurrentProduct: Boolean(purchase),
...(existingPurchase && {
purchasedProductIds: [existingPurchase.productId],
existingPurchase,
}),
}
}

return (
<Suspense>
<ProductPricing {...productProps} />
</Suspense>
)
}
31 changes: 28 additions & 3 deletions apps/course-builder-web/src/app/(commerce)/welcome/page.tsx
Expand Up @@ -7,9 +7,8 @@ import { githubAccountsForCurrentUser } from '@/lib/users'
import { convertToSerializeForNextResponse } from '@/path-to-purchase/serialize-for-next-response'
import { WelcomePage } from '@/path-to-purchase/welcome-page'
import { getPurchaseTransferForPurchaseId } from '@/purchase-transfer/purchase-transfer-actions'
import { getServerAuthSession } from '@/server/auth'
import { authOptions, getServerAuthSession } from '@/server/auth'
import { isString } from 'lodash'
import { getProviders } from 'next-auth/react'

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

Expand All @@ -22,7 +21,15 @@ const getServerSideProps = async (query: {
const token = await getServerAuthSession()
const user = token.session?.user

const providers = await getProviders()
const providers = authOptions.providers.map((p) => {
return {
// @ts-ignore
id: p.id,
name: p.name,
}
})

console.log({ providers })
const { getPurchaseDetails } = courseBuilderAdapter

let purchaseId = query.purchaseId
Expand All @@ -47,10 +54,14 @@ const getServerSideProps = async (query: {
}
}

console.log({ user, purchaseId })

if (user && isString(purchaseId) && isString(user?.id)) {
const { purchase, existingPurchase, availableUpgrades } =
await getPurchaseDetails(purchaseId, user?.id)

console.log({ purchase, existingPurchase, availableUpgrades })

if (purchase) {
const product = await courseBuilderAdapter.getProduct(purchase.productId)

Expand Down Expand Up @@ -110,6 +121,20 @@ const Welcome = async ({

const isGithubConnected = await githubAccountsForCurrentUser()

console.log({
isGithubConnected,
product,
purchase,
existingPurchase,
upgrade,
providers,
hasCharge,
redemptionsLeft,
isTransferAvailable,
purchaseUserTransfers,
userEmail: session?.user?.email,
})

return (
<div>
<WelcomePage
Expand Down
Expand Up @@ -5,8 +5,8 @@ import { usePathname } from 'next/navigation'
import { Layout } from '@/components/app/layout'
import { env } from '@/env.mjs'
import { EventDetails } from '@/pricing/event-details'
import { EventPricingWidget } from '@/pricing/event-pricing-widget'
import { PriceCheckProvider } from '@/pricing/pricing-check-context'
import { PricingWidget } from '@/pricing/pricing-widget'
import Balancer from 'react-wrap-balancer'

import { EventPageProps } from './event-page-props'
Expand Down Expand Up @@ -59,7 +59,7 @@ export async function EventTemplate(props: EventPageProps) {
<div className="shadow-soft-xl dark:bg-foreground/5 flex w-full flex-col items-center rounded-xl bg-white pb-5">
{product && product.status === 1 && isUpcoming && (
<PriceCheckProvider purchasedProductIds={purchasedProductIds}>
<EventPricingWidget
<PricingWidget
commerceProps={{ ...commerceProps, products }}
product={product}
quantityAvailable={quantityAvailable}
Expand Down

0 comments on commit 39b0ef5

Please sign in to comment.