Skip to content

Commit

Permalink
feat: purchase transfers & ppp (#170)
Browse files Browse the repository at this point in the history
* feat: dynamic mysql loader with docker for tests

* feat: display the PPP opt-in

* feat: post purchase teams

* feat: purchase transfers
  • Loading branch information
joelhooks committed May 4, 2024
1 parent 84fd7df commit 22c290a
Show file tree
Hide file tree
Showing 33 changed files with 1,026 additions and 270 deletions.
8 changes: 8 additions & 0 deletions .changeset/stupid-insects-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@coursebuilder/adapter-drizzle": patch
"@coursebuilder/email-templates": patch
"@coursebuilder/core": patch
"create-course-app": patch
---

enables purchase transfers end to end
1 change: 1 addition & 0 deletions apps/course-builder-web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ NEXT_PUBLIC_SUPPORT_EMAIL=team@coursebuilder.dev
NEXT_PUBLIC_SITE_TITLE="Course Builder"

COURSEBUILDER_URL="http://example.com"
CREATE_USER_ON_LOGIN=true

# Drizzle
# Get the Database URL from the "prisma" dropdown selector in PlanetScale
Expand Down
4 changes: 3 additions & 1 deletion apps/course-builder-web/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ const config = {
project: true,
},
extends: ['next/core-web-vitals'],
rules: {},
rules: {
'react/no-unescaped-entities': 'off',
},
}

module.exports = config
3 changes: 2 additions & 1 deletion apps/course-builder-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@codemirror/view": "^6.22.3",
"@coursebuilder/adapter-drizzle": "0.1.4",
"@coursebuilder/core": "0.1.5",
"@coursebuilder/email-templates": "^1.0.1",
"@coursebuilder/next": "workspace:^",
"@coursebuilder/react-rsc": "workspace:^",
"@coursebuilder/ui": "^1.0.10",
Expand Down Expand Up @@ -106,7 +107,7 @@
"codemirror": "^6.0.1",
"crypto": "^1.0.1",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"date-fns-tz": "^2.0.1",
"drizzle-orm": "0.30.2",
"formik": "2.2.9",
"framer-motion": "^11",
Expand Down
87 changes: 57 additions & 30 deletions apps/course-builder-web/src/app/(commerce)/thanks/purchase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,39 +26,56 @@ const getServerSideProps = async (session_id: string) => {
notFound()
}

const purchaseInfo = await paymentProvider.getPurchaseInfo(
session_id,
courseBuilderAdapter,
)

const {
email,
chargeIdentifier,
quantity: seatsPurchased,
product: merchantProduct,
purchaseType,
} = purchaseInfo

const stripeProductName = merchantProduct.name

const purchase =
await courseBuilderAdapter.getPurchaseForStripeCharge(chargeIdentifier)
const maxRetries = 5
const initialDelay = 150
const maxDelay = 15000

let retries = 0
let delay = initialDelay

while (retries < maxRetries) {
try {
const purchaseInfo = await paymentProvider.getPurchaseInfo(
session_id,
courseBuilderAdapter,
)

if (!purchase || !email) {
return notFound()
const {
email,
chargeIdentifier,
quantity: seatsPurchased,
product: merchantProduct,
purchaseType,
} = purchaseInfo

const stripeProductName = merchantProduct.name

const purchase =
await courseBuilderAdapter.getPurchaseForStripeCharge(chargeIdentifier)

if (!purchase || !email) {
throw new Error('Purchase or email not found')
}

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

return {
purchase: convertToSerializeForNextResponse(purchase),
email,
seatsPurchased,
purchaseType,
bulkCouponId: purchase.bulkCoupon?.id || null,
product: convertToSerializeForNextResponse(product) || null,
stripeProductName,
}
} catch (error) {
retries++
await new Promise((resolve) => setTimeout(resolve, delay))
delay = Math.min(delay * 2, maxDelay)
}
}

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

return {
purchase: convertToSerializeForNextResponse(purchase),
email,
seatsPurchased,
purchaseType,
bulkCouponId: purchase.bulkCoupon?.id || null,
product: convertToSerializeForNextResponse(product) || null,
stripeProductName,
}
notFound()
}

export default async function ThanksPurchasePage({
Expand All @@ -78,6 +95,16 @@ export default async function ThanksPurchasePage({
stripeProductName,
} = await getServerSideProps(session_id)

console.log({
purchase,
email,
seatsPurchased,
purchaseType,
bulkCouponId,
product,
stripeProductName,
})

return (
<ThanksVerify
email={email}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { revalidatePath } from 'next/cache'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { Layout } from '@/components/app/layout'
import { courseBuilderAdapter } from '@/db'
import { acceptPurchaseTransfer } from '@/purchase-transfer/purchase-transfer-actions'
import { getServerAuthSession } from '@/server/auth'

const PurchaseTransferPage = async ({
params,
}: {
params: { purchaseTransferId: string }
}) => {
const { session } = await getServerAuthSession()
const user = session?.user
const purchaseTransfer =
await courseBuilderAdapter.getPurchaseUserTransferById({
id: params.purchaseTransferId,
})

const signedInAsTargetUser = user?.id === purchaseTransfer?.targetUserId

return (
<Layout>
<main className="mx-auto flex w-full flex-grow flex-col items-center justify-center px-5 py-24 sm:py-32">
{purchaseTransfer?.transferState === 'INITIATED' && (
<div className="flex w-full max-w-xl flex-col gap-3">
<h1 className="text-center text-3xl font-bold">
馃憢 Welcome to {process.env.NEXT_PUBLIC_SITE_TITLE}
</h1>
<h2 className="text-center text-xl font-semibold">
You've been invited by{' '}
{purchaseTransfer?.sourceUser?.name ||
purchaseTransfer?.sourceUser?.email ||
''}{' '}
to join {process.env.NEXT_PUBLIC_SITE_TITLE}
</h2>
{signedInAsTargetUser ? (
<>
<form
action={async () => {
'use server'
user &&
purchaseTransfer &&
(await acceptPurchaseTransfer({
purchaseUserTransferId: params.purchaseTransferId,
email: user.email,
}))
revalidatePath(`/transfer/${params.purchaseTransferId}`)
redirect(`/transfer/${params.purchaseTransferId}`)
}}
>
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700"
disabled={!purchaseTransfer}
>
accept this transfer
</button>
</form>
<p className="text-center text-xs">
By accepting this transfer you are agreeing to the{' '}
<Link
className="font-semibold hover:underline"
href="/privacy"
>
terms and conditions of {process.env.NEXT_PUBLIC_SITE_TITLE}
</Link>
.
</p>
</>
) : (
<p className="text-center">
In order to accept this invitation, you must be signed in as{' '}
<span className="font-semibold underline">
{purchaseTransfer?.targetUser?.email}
</span>
. Please sign in to the account tied to that email and revisit
this URL to accept the transfer.
</p>
)}
</div>
)}
{purchaseTransfer?.transferState === 'COMPLETED' && (
<div className="flex w-full max-w-xl flex-col gap-3">
<h1 className="text-center text-3xl font-bold">
Purchase Transfer Completed
</h1>
<h2 className="text-center text-xl font-semibold">
The license transfer from{' '}
{purchaseTransfer?.sourceUser?.name ||
purchaseTransfer?.sourceUser?.email ||
''}{' '}
has been completed.
</h2>
</div>
)}
{purchaseTransfer?.transferState === 'CANCELED' && (
<div className="flex w-full max-w-xl flex-col gap-3">
<h1 className="text-center text-3xl font-bold">
Purchase Transfer Canceled
</h1>
<p className="text-center">
The license transfer from{' '}
<a
className="font-semibold hover:underline"
href={`mailto:${purchaseTransfer?.sourceUser?.email}`}
>
{purchaseTransfer?.sourceUser?.email}
</a>{' '}
has been canceled. Please contact them with any questions.
</p>
</div>
)}
</main>
</Layout>
)
}

export default PurchaseTransferPage
2 changes: 2 additions & 0 deletions apps/course-builder-web/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const {
couponRelations,
merchantAccount,
merchantCharge,
merchantChargeRelations,
merchantCoupon,
merchantCustomer,
merchantPrice,
Expand All @@ -35,6 +36,7 @@ export const {
purchases,
purchaseRelations,
purchaseUserTransfer,
purchaseUserTransferRelations,
communicationChannel,
communicationPreferenceTypes,
communicationPreferences,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const InlineTeamInvite = ({
<h2 className="pb-2 text-sm font-semibold uppercase tracking-wide">
Invite your team
</h2>
<div className="flex flex-col rounded-lg border border-gray-700/30 bg-gray-800 p-5 shadow-xl shadow-black/10">
<div className="flex flex-col rounded-lg border border-gray-700/30 p-5 shadow-xl shadow-black/10">
<p className="pb-2 font-semibold">
You have purchased {seatsPurchased} seats.
</p>
Expand Down
6 changes: 3 additions & 3 deletions apps/course-builder-web/src/path-to-purchase/invoice-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export const InvoiceCard: React.FC<{ purchase: Purchase | any }> = ({
purchase,
}) => {
return (
<div className="bg-card flex flex-col items-start justify-between rounded-lg border p-5 shadow-xl shadow-black/10 sm:flex-row sm:items-center">
<div className="bg-card flex flex-col items-start justify-between rounded-lg border border-gray-700/30 p-5 shadow-xl shadow-black/10 sm:flex-row sm:items-center">
<div className="flex w-full gap-2">
<div>
<DocumentTextIcon aria-hidden className="w-6 text-cyan-500" />
<DocumentTextIcon aria-hidden className="w-6 text-gray-500" />
</div>
<div>
<h2 className="text-lg font-semibold leading-tight">
Expand All @@ -32,7 +32,7 @@ export const InvoiceCard: React.FC<{ purchase: Purchase | any }> = ({
</div>
<Link
href={`/invoices/${purchase.merchantChargeId}`}
className="ml-8 mt-5 flex flex-shrink-0 items-center justify-end rounded-md bg-cyan-300/20 px-4 py-2.5 text-sm font-semibold text-cyan-300 transition hover:bg-cyan-300/30 sm:ml-0 sm:mt-0 sm:justify-center"
className="ml-8 mt-5 flex flex-shrink-0 items-center justify-end rounded-md bg-gray-300/20 px-4 py-2.5 text-sm font-semibold text-gray-800 transition hover:bg-gray-400/30 sm:ml-0 sm:mt-0 sm:justify-center"
>
<span className="pr-0.5">View Invoice</span>
<ChevronRightIcon aria-hidden="true" className="w-4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Balancer from 'react-wrap-balancer'

export const LoginLink: React.FC<{ email: string }> = ({ email }) => {
return (
<div className="bg-card relative mx-auto flex w-full items-center justify-between gap-5 overflow-hidden rounded-xl border p-7 shadow-2xl sm:p-12">
<div className="bg-card relative mx-auto flex w-full items-center justify-between gap-5 overflow-hidden rounded-xl border border-gray-700/30 p-7 shadow-2xl sm:p-12">
<div className="relative z-10">
<p className="bg-primary/20 text-primary inline-flex rounded-full px-3 py-1 text-xs font-semibold uppercase sm:text-sm">
Final step
Expand All @@ -14,13 +14,13 @@ export const LoginLink: React.FC<{ email: string }> = ({ email }) => {
Please check your inbox for a <strong>login link</strong> that just
got sent.
</h2>
<div className="bg-primary mb-3 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-gray-900 shadow-lg shadow-cyan-600/20">
<div className=" mb-3 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-gray-900 shadow-lg shadow-gray-600/20">
<MailIcon className="h-5 w-5 flex-shrink-0 text-slate-900/40" />{' '}
<strong className="inline-block break-all font-semibold">
Email sent to: {email}
</strong>
</div>
<p className="mx-auto pt-5 font-medium leading-relaxed text-white/90 sm:text-base sm:leading-relaxed">
<p className="mx-auto pt-5 font-medium leading-relaxed sm:text-base sm:leading-relaxed">
<Balancer>
As a final step to access the course you need to check your inbox (
<strong>{email}</strong>) where you will find an email from{' '}
Expand Down
2 changes: 1 addition & 1 deletion apps/course-builder-web/src/path-to-purchase/thank-you.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const ThankYou: React.FC<ThankYouProps> = ({
</span>
<span className="w-full text-balance">{title}</span>
</h1>
<p className="pt-5 text-lg font-normal text-gray-100">
<p className="pt-5 text-lg font-normal">
<Balancer>{byline}</Balancer>
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type PricingContextType = {
addPrice: (price: FormattedPrice, productId: string) => void
isDowngrade: (price?: FormattedPrice | null) => boolean
isDiscount: (price?: FormattedPrice | null) => boolean
merchantCoupon?: MinimalMerchantCoupon | undefined
merchantCoupon?: MinimalMerchantCoupon | undefined | null
setMerchantCoupon: Dispatch<SetStateAction<MinimalMerchantCoupon | undefined>>
quantity: number
setQuantity: Dispatch<SetStateAction<number>>
Expand Down
Loading

0 comments on commit 22c290a

Please sign in to comment.