Skip to content

Commit

Permalink
feat: coursebuilder form rig
Browse files Browse the repository at this point in the history
replaces convertkit with local database-backed form component and provider

- adds SubscribeToCoursebuilderForm component
- adds local email list provider
- updates docs with usage examples
  • Loading branch information
joelhooks committed Jan 17, 2025
1 parent 9acd8e1 commit e1a0c42
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 15 deletions.
56 changes: 56 additions & 0 deletions apps/go-local-first/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,59 @@ $ pnpm db:push
3. Navigate to `https://local.drizzle.studio`
2. Via Drizzle Studio (other flows are possible if you'd prefer, but this is the one we're documenting) find your new `user` record and change the `role` to `admin`. Make sure to save/apply the change.
3. When you visit `/tips`, you'll see the form for creating a new Tip.

## Forms

### Newsletter Subscription

A database-backed form component with validation and analytics:

```tsx
import { SubscribeToCoursebuilderForm } from '@/convertkit'

<SubscribeToCoursebuilderForm
onSuccess={(subscriber) => {
track('subscribed', { source: 'newsletter' })
router.push(redirectUrlBuilder(subscriber, '/confirm'))
}}
actionLabel="Subscribe"
className="[&_input]:h-16"
/>
```

Props:
- `onSuccess(subscriber)`: Subscription success callback
- `actionLabel`: Button text
- `className`: Styling overrides
- `formId`: Optional form ID
- `fields`: Additional form fields
- `validationSchema`: Custom Yup validation

Includes first name and email fields with loading states and error handling. Stores contacts directly in the coursebuilder database.

### Email List Provider

The form is backed by a local database provider that handles subscriber management:

```ts
// coursebuilder/email-list-provider.ts
export const emailListProvider = EmailListProvider({
apiKey: '',
apiSecret: '',
defaultListType: 'Newsletter',
})
```

Integrated in the coursebuilder config:

```ts
// coursebuilder/course-builder-config.ts
export const courseBuilderConfig: NextCourseBuilderConfig = {
providers: [
emailListProvider,
// ... other providers
],
}
```

The provider handles subscriber lookup, creation, and preference management using the local database schema. No external email service required.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import * as React from 'react'
import { useRouter } from 'next/navigation'
import { redirectUrlBuilder, SubscribeToConvertkitForm } from '@/convertkit'
import {
redirectUrlBuilder,
SubscribeToCoursebuilderForm,
} from '@/convertkit/coursebuilder-subscribe-form'
import { Subscriber } from '@/schemas/subscriber'
import common from '@/text/common'
import { track } from '@/utils/analytics'
Expand Down Expand Up @@ -86,7 +89,7 @@ export const PostNewsletterCta: React.FC<
</div>
</div>
<div id={id} className="w-full md:w-auto">
<SubscribeToConvertkitForm
<SubscribeToCoursebuilderForm
onSuccess={onSuccess ? onSuccess : handleOnSuccess}
actionLabel={actionLabel}
className="[&_input]:border-0"
Expand Down
11 changes: 5 additions & 6 deletions apps/go-local-first/src/components/primary-newsletter-cta.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
'use client'

import * as React from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { redirectUrlBuilder, SubscribeToConvertkitForm } from '@/convertkit'
import { redirectUrlBuilder, SubscribeToCoursebuilderForm } from '@/convertkit'
import { Subscriber } from '@/schemas/subscriber'
import common from '@/text/common'
import { track } from '@/utils/analytics'
import { cn } from '@/utils/cn'
import { ShieldCheckIcon } from 'lucide-react'

import common from '../text/common'

type PrimaryNewsletterCtaProps = {
onSuccess?: () => void
title?: string
Expand Down Expand Up @@ -56,13 +54,14 @@ export const PrimaryNewsletterCta: React.FC<
children
) : (
<div className="relative z-10 flex max-w-2xl flex-col items-center justify-center pb-10">
<h2 className="font-heading fluid-2xl text-center font-semibold ">
<h2 className="font-heading fluid-2xl text-center font-semibold">
{title}
</h2>
<h3 className="fluid-lg pt-4 text-center opacity-90">{byline}</h3>
</div>
)}
<SubscribeToConvertkitForm

<SubscribeToCoursebuilderForm
onSuccess={onSuccess ? onSuccess : handleOnSuccess}
actionLabel={actionLabel}
className="relative z-10 [&_input]:h-16"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import Spinner from '@/components/spinner'
import { useConvertkitForm } from '@/hooks/use-convertkit-form'
import { useCoursebuilderSubscribeForm } from '@/convertkit/useCoursebuilderSubscribeForm'
import { type Subscriber } from '@/schemas/subscriber'
import { CK_SUBSCRIBER_KEY } from '@skillrecordings/config'
import queryString from 'query-string'
Expand Down Expand Up @@ -69,7 +69,7 @@ export type SubscribeFormProps = {
* @param rest anything else!
* @constructor
*/
export const SubscribeToConvertkitForm: React.FC<
export const SubscribeToCoursebuilderForm: React.FC<
React.PropsWithChildren<SubscribeFormProps>
> = ({
formId,
Expand All @@ -88,7 +88,7 @@ export const SubscribeToConvertkitForm: React.FC<
...rest
}) => {
const { isSubmitting, status, handleChange, handleSubmit, errors, touched } =
useConvertkitForm({
useCoursebuilderSubscribeForm({
formId,
onSuccess,
onError,
Expand Down Expand Up @@ -186,7 +186,7 @@ export const SubscribeToConvertkitForm: React.FC<
)
}

export default SubscribeToConvertkitForm
export default SubscribeToCoursebuilderForm

export const redirectUrlBuilder = (
subscriber: Subscriber,
Expand Down
6 changes: 3 additions & 3 deletions apps/go-local-first/src/convertkit/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SubscribeToConvertkitForm, {
import SubscribeToCoursebuilderForm, {
redirectUrlBuilder,
} from './convertkit-subscribe-form'
} from './coursebuilder-subscribe-form'

export { SubscribeToConvertkitForm, redirectUrlBuilder }
export { SubscribeToCoursebuilderForm, redirectUrlBuilder }
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { type Subscriber } from '@/schemas/subscriber'
import axios from 'axios'
import { useFormik } from 'formik'
import * as Yup from 'yup'

export function useCoursebuilderSubscribeForm({
submitUrl = `/api/coursebuilder/subscribe-to-list/coursebuilder`,
formId = 0,
fields,
onSuccess,
onError,
validationSchema,
validateOnChange = false,
}: {
submitUrl?: string
formId?: number
onSuccess: (subscriber: Subscriber, email?: string) => void
onError: (error?: any) => void
fields?: any
validationSchema?: Yup.ObjectSchema<any>
validateOnChange?: boolean
}): {
isSubmitting: boolean
status: string
handleChange: any
handleSubmit: any
errors: any
touched: any
} {
const { isSubmitting, status, handleChange, handleSubmit, errors, touched } =
useFormik({
initialStatus: '',
initialValues: {
email: '',
first_name: '',
},
validationSchema:
validationSchema ||
Yup.object().shape({
email: Yup.string()
.email('Invalid email address')
.required('Required'),
first_name: Yup.string(),
}),
validateOnChange: validateOnChange,
enableReinitialize: true,
onSubmit: async ({ email, first_name }, { setStatus }) => {
return axios
.post(submitUrl, {
email,
name: first_name,
...(formId > 0 ? { form: formId } : {}),
fields,
})
.then((response: any) => {
const subscriber: Subscriber = response.data
onSuccess(subscriber, email)
setStatus('success')
if (!subscriber) {
setStatus('error')
}
})
.catch((error: Error) => {
onError(error)
setStatus('error')
console.log(error)
})
},
})

return { isSubmitting, status, handleChange, handleSubmit, errors, touched }
}
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import type { CallbacksOptions, CookiesOptions } from './types'

export { createActionURL, setEnvDefaults }

/**
* this handles the request and builds up the internal state
* for routing/handling the request with the appropriate action+provider
*
* @param request
* @param config
* @returns
*/
export async function CourseBuilder(
request: Request,
config: CourseBuilderConfig,
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { userLookup } from './actions/user-lookup'
import { init } from './init'
import { UnknownAction } from './utils/web'

/**
* this is essentially a "router" for the coursebuilder api that
* routes the request to the appropriate action based on the action
* and providerId
*
* @param request
* @param courseBuilderOptions
* @returns
*/
export async function CourseBuilderInternal(
request: RequestInternal,
courseBuilderOptions: CourseBuilderConfig,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/lib/utils/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ async function getBody(
}
}

/**
* takes a `Request` object and converts it into an internal request object
* that we use throughout the system
* @param req
* @param config
* @returns
*/
export async function toInternalRequest(
req: Request,
config: CourseBuilderConfig,
Expand All @@ -57,6 +64,12 @@ export async function toInternalRequest(
config.basePath,
)

console.debug({
url,
action,
providerId,
})

return {
url,
action,
Expand Down

0 comments on commit e1a0c42

Please sign in to comment.