diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index a5c295f..376a3d7 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -19,5 +19,15 @@ module.exports = {
},
],
'simple-import-sort/imports': 'error',
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ args: 'all',
+ argsIgnorePattern: '^_',
+ ignoreRestSiblings: true,
+ varsIgnorePattern: '^_',
+ caughtErrors: 'all',
+ },
+ ],
},
}
diff --git a/lib/PortingEmbed/PortingEmbed.tsx b/lib/PortingEmbed/PortingEmbed.tsx
new file mode 100644
index 0000000..6b46dca
--- /dev/null
+++ b/lib/PortingEmbed/PortingEmbed.tsx
@@ -0,0 +1,19 @@
+import { Porting } from '../types'
+
+export type CustomizableEmbedProps = {
+ // TODO: add styling options
+ styleConfig?: {
+ foo?: string
+ }
+}
+
+type CoreEmbedProps = {
+ token: string
+ initialPorting: Porting
+}
+
+type PortingEmbedProps = CoreEmbedProps & CustomizableEmbedProps
+
+export function PortingEmbed({ token: _, initialPorting }: PortingEmbedProps) {
+ return
Hello {initialPorting.id}!
+}
diff --git a/lib/PortingEmbed/README.md b/lib/PortingEmbed/README.md
new file mode 100644
index 0000000..b2827be
--- /dev/null
+++ b/lib/PortingEmbed/README.md
@@ -0,0 +1,22 @@
+# Porting Embed
+
+## Usage
+
+```ts
+import { PortingEmbed } from '@gigs/gigs-embeds-js'
+
+// Obtain a ConnectSession from your own backend
+const connectSession = await fetchConnectSession()
+
+const embed = await PortingEmbed(connectSession, { project: 'your-project' })
+embed.mount(document.getElementById('gigsEmbedMount'))
+
+// can also use a selector:
+// embed.mount('#gigsEmbedMount')
+
+// Here be more dragons
+
+// ---
+// index.html
+
+```
diff --git a/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx
new file mode 100644
index 0000000..b4ce8ca
--- /dev/null
+++ b/lib/PortingEmbed/__stories__/PortingEmbed.stories.tsx
@@ -0,0 +1,20 @@
+import { portingFactory } from '@/testing/factories/porting'
+
+import { PortingEmbed } from '../PortingEmbed'
+
+export default {
+ title: 'Porting Embed/Base',
+ component: PortingEmbed,
+ tags: ['autodocs'],
+ argTypes: {
+ token: { control: 'text' },
+ initialPorting: { control: 'object' },
+ },
+}
+
+export const Primary = {
+ args: {
+ token: 'abc:123',
+ initialPorting: portingFactory.build(),
+ },
+}
diff --git a/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx
new file mode 100644
index 0000000..e4e113e
--- /dev/null
+++ b/lib/PortingEmbed/__tests__/PortingEmbed.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@testing-library/preact'
+
+import { portingFactory } from '@/testing/factories/porting'
+
+import { PortingEmbed } from '../PortingEmbed'
+
+it('gets the porting', () => {
+ const porting = portingFactory.params({ id: 'prt_123' }).build()
+ render()
+ const greeting = screen.getByText(/Hello prt_123/i)
+ expect(greeting).toBeInTheDocument()
+})
diff --git a/lib/PortingEmbed/__tests__/index.test.tsx b/lib/PortingEmbed/__tests__/index.test.tsx
new file mode 100644
index 0000000..36c54f5
--- /dev/null
+++ b/lib/PortingEmbed/__tests__/index.test.tsx
@@ -0,0 +1,181 @@
+import { render } from '@testing-library/preact'
+
+import { connectSessionFactory } from '@/testing/factories/connectSession'
+import { portingFactory } from '@/testing/factories/porting'
+import { subscriptionFactory } from '@/testing/factories/subscription'
+
+import { PortingStatus } from '../../types'
+import { PortingEmbed } from '../'
+
+const project = 'test_project'
+
+async function createFixtures() {
+ const subscription = await subscriptionFactory.create()
+ const connectSession = connectSessionFactory
+ .completePorting(subscription.id)
+ .build()
+ return connectSession
+}
+
+beforeEach(() => {
+ render()
+})
+
+describe('mounting', () => {
+ it('mounts into a DOM selector', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ embed.mount('#mount')
+ expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument()
+ })
+
+ it('mounts into a DOM element', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ embed.mount(document.getElementById('mount')!)
+ expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument()
+ })
+})
+
+describe('updating', () => {
+ it('updates the embed', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ embed.mount('#mount')
+ embed.update({})
+ expect(document.querySelector('.__gigsPortingEmbed')).toBeInTheDocument()
+ })
+
+ it('fails to update an unmounted embed', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ expect(() => embed.update({})).toThrow(/an unmounted embed/i)
+ })
+})
+
+describe('unmounting', () => {
+ it('unmounts the embed', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ embed.mount('#mount')
+ expect(document.getElementById('mount')).not.toBeEmptyDOMElement()
+ embed.unmount()
+ expect(document.getElementById('mount')).toBeEmptyDOMElement()
+ })
+
+ it('fails to unmount an unmounted embed', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+ expect(() => embed.unmount()).toThrow(/an unmounted embed/i)
+ })
+})
+
+describe('initialization', () => {
+ it('initializes with valid data', async () => {
+ const csn = await createFixtures()
+ const embed = await PortingEmbed(csn, { project })
+
+ expect(embed.mount).toBeDefined()
+ expect(embed.update).toBeDefined()
+ expect(embed.unmount).toBeDefined()
+ expect(embed.on).toBeDefined()
+ expect(embed.off).toBeDefined()
+ })
+
+ it('throws without a project', async () => {
+ const csn = await createFixtures()
+ // @ts-expect-error Assume the project is missing in a non-typechecked usage
+ const init = PortingEmbed(csn, {})
+ expect(init).rejects.toThrow(/NO_PROJECT/)
+ })
+
+ it('throws with the wrong ConnectSession', async () => {
+ expect(PortingEmbed(null, { project })).rejects.toThrow(/INVALID_SESSION/)
+ expect(PortingEmbed({}, { project })).rejects.toThrow(/INVALID_SESSION/)
+ expect(PortingEmbed({ secret: 'foo' }, { project })).rejects.toThrow(
+ /INVALID_SESSION/,
+ )
+ })
+
+ it('throws with a wrong intent', async () => {
+ const csn = connectSessionFactory
+ // @ts-expect-error Unsupported intent type
+ .params({ intent: { type: 'foo' } })
+ .build()
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/INVALID_SESSION/)
+ })
+
+ it('throws with a non-existing subscription', async () => {
+ const csn = connectSessionFactory.completePorting('sub_404').build()
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/NOT_FOUND/)
+ })
+
+ it('throws without a porting', async () => {
+ const sub = await subscriptionFactory.withoutPorting().create()
+ const csn = connectSessionFactory.completePorting(sub.id).build()
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/NOT_FOUND/)
+ })
+
+ describe('with porting status', () => {
+ async function createWithStatus(status: PortingStatus) {
+ const porting = portingFactory.params({ status }).build()
+ const subscription = await subscriptionFactory
+ .associations({ porting })
+ .create()
+ const connectSession = connectSessionFactory
+ .completePorting(subscription.id)
+ .build()
+ return connectSession
+ }
+
+ it('initializes with informationRequired', async () => {
+ const csn = await createWithStatus('informationRequired')
+ const init = PortingEmbed(csn, { project })
+ expect(init).resolves.toBeDefined()
+ })
+
+ it('initializes with declined', async () => {
+ const csn = await createWithStatus('declined')
+ const init = PortingEmbed(csn, { project })
+ expect(init).resolves.toBeDefined()
+ })
+
+ it('throws with draft', async () => {
+ const csn = await createWithStatus('draft')
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/UNSUPPORTED/)
+ })
+
+ it('throws with requested', async () => {
+ const csn = await createWithStatus('requested')
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/UNSUPPORTED/)
+ })
+
+ it('throws with completed', async () => {
+ const csn = await createWithStatus('completed')
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/UNSUPPORTED/)
+ })
+
+ it('throws with canceled', async () => {
+ const csn = await createWithStatus('canceled')
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/UNSUPPORTED/)
+ })
+
+ it('throws with expired', async () => {
+ const csn = await createWithStatus('expired')
+ const init = PortingEmbed(csn, { project })
+ expect(init).rejects.toThrow(/UNSUPPORTED/)
+ })
+ })
+})
diff --git a/lib/PortingEmbed/index.tsx b/lib/PortingEmbed/index.tsx
new file mode 100644
index 0000000..a472a9c
--- /dev/null
+++ b/lib/PortingEmbed/index.tsx
@@ -0,0 +1,151 @@
+import mitt from 'mitt'
+import { render } from 'preact'
+
+import { assert } from '../core/assert'
+import { fetchSubscription } from '../core/subscription'
+import { exchangeSessionWithToken } from '../core/token'
+import { PortingStatus } from '../types'
+import {
+ CustomizableEmbedProps,
+ PortingEmbed as PortingEmbedComponent,
+} from './PortingEmbed'
+
+type PortingEmbedInit = {
+ /** The ID of your Gigs project. */
+ project: string
+}
+export type PortingEmbedOptions = CustomizableEmbedProps
+
+type Events = never
+
+/**
+ * Initializes an embed to complete a porting (port-in a number). Requires an
+ * authenticated ConnectSession. After initialization, the embed can be mounted
+ * into your document.
+ *
+ * @example
+ * const embed = await PortingEmbed(connectSession, { project: 'my-project' })
+ * // hide your loading states
+ * embed.mount('#embed')
+ *
+ * @param connectSession An authenticated ConnectSession with an intent type of
+ * "completePorting".
+ * @param options Initialization options.
+ */
+export async function PortingEmbed(
+ initConnectSession: unknown,
+ {
+ options: initialOptions,
+ project,
+ }: {
+ /** Additional options to configure the behavior of the embed. */
+ options?: PortingEmbedOptions
+ } & PortingEmbedInit,
+) {
+ // Ensure embed was initialized with proper options.
+ assert(
+ project,
+ 'NO_PROJECT: Cannot initialize PortingEmbed without a project.',
+ )
+
+ // Ensure that the ConnectSession is valid and obtain a token.
+ const { connectSession, token } = await exchangeSessionWithToken(
+ initConnectSession,
+ 'completePorting',
+ )
+
+ let element: Element | null = null
+ let options = initialOptions
+ const emitter = mitt()
+
+ // Fetch the necessary data before the embed can be mounted. While the embed
+ // is loading, the embedder can show their own loading state.
+ const subscription = await fetchSubscription(
+ connectSession.intent.completePorting.subscription,
+ { project, token },
+ )
+ const { porting } = subscription
+ assert(porting, 'NOT_FOUND: The given subscription has no porting.')
+
+ const supportedPortingStatus: PortingStatus[] = [
+ 'informationRequired',
+ 'declined',
+ ]
+ assert(
+ supportedPortingStatus.includes(porting.status),
+ `UNSUPPORTED: Porting status "${porting.status}" is not supported by the embed.`,
+ )
+
+ const renderWithCurrentOptions = () => {
+ assert(element, 'No element present to render embed into.')
+
+ render(
+ ,
+ element,
+ )
+ }
+
+ return {
+ /**
+ * Mount the embed into a container.
+ *
+ * @example
+ * embed.mount('#embed')
+ *
+ * @example
+ * embed.mount(document.getElementById('embed'))
+ *
+ * @param container The HTML Element or selector in which the embed should be
+ * mounted to.
+ */
+ mount(container: Element | string) {
+ assert(container, 'Cannot call mount() without specifying a container.')
+
+ element =
+ typeof container === 'string'
+ ? document.querySelector(container)
+ : container
+ assert(element, 'Element to mount to could not be found.')
+
+ renderWithCurrentOptions()
+ },
+
+ /**
+ * Update the mounted embed with new options.
+ *
+ * @example
+ * embed.update({ styleOptions: { ... }})
+ *
+ * @param newOptions New options for the embed
+ */
+ update(newOptions: PortingEmbedOptions) {
+ assert(element, 'Cannot call update() on an unmounted embed.')
+
+ options = newOptions
+ renderWithCurrentOptions()
+ },
+
+ /**
+ * Unmount the mounted embed.
+ *
+ * @example
+ * embed.unmount()
+ */
+ unmount() {
+ assert(element, 'Cannot call unmount() on an unmounted embed.')
+
+ render(null, element)
+ element = null
+ },
+
+ /** Add an event listener. */
+ on: emitter.on.bind(emitter),
+
+ /** Remove event listener. */
+ off: emitter.off.bind(emitter),
+ }
+}
diff --git a/lib/core/__tests__/assert.test.ts b/lib/core/__tests__/assert.test.ts
new file mode 100644
index 0000000..5b12a32
--- /dev/null
+++ b/lib/core/__tests__/assert.test.ts
@@ -0,0 +1,16 @@
+import { assert } from '../assert'
+
+it('throws when it evaluates to false', () => {
+ expect(() => assert(false, 'is false')).toThrowError('is false')
+ expect(() => assert(null, 'is null')).toThrowError('is null')
+ expect(() => assert(0, 'is zero')).toThrowError('is zero')
+ expect(() => assert('', 'is empty')).toThrowError('is empty')
+})
+
+it('does not throw when it evaluates to true', () => {
+ expect(() => assert(true, 'is true')).not.toThrowError('is true')
+ expect(() => assert({}, 'is object')).not.toThrowError('is object')
+ expect(() => assert('string', 'is string')).not.toThrowError('is string')
+ expect(() => assert(1, 'is 1')).not.toThrowError('is 1')
+ expect(() => assert([], 'is array')).not.toThrowError('is array')
+})
diff --git a/lib/core/__tests__/subscription.test.ts b/lib/core/__tests__/subscription.test.ts
new file mode 100644
index 0000000..ef0b084
--- /dev/null
+++ b/lib/core/__tests__/subscription.test.ts
@@ -0,0 +1,18 @@
+import { subscriptionFactory } from '@/testing/factories/subscription'
+
+import { fetchSubscription } from '../subscription'
+
+const project = 'test_project'
+const token = 'test_token'
+
+it('returns an existing subscription', async () => {
+ const sub = await subscriptionFactory.create()
+ const result = await fetchSubscription(sub.id, { project, token })
+ expect(result).toEqual(sub)
+})
+
+it('throws if the subscription does not exist', async () => {
+ expect(
+ fetchSubscription('sub_not_found', { project, token }),
+ ).rejects.toThrow(/SUB_NOT_FOUND/)
+})
diff --git a/lib/core/__tests__/token.test.ts b/lib/core/__tests__/token.test.ts
new file mode 100644
index 0000000..5e50049
--- /dev/null
+++ b/lib/core/__tests__/token.test.ts
@@ -0,0 +1,77 @@
+import { connectSessionFactory } from '@/testing/factories/connectSession'
+
+import { exchangeSessionWithToken } from '../token'
+
+it('exchanges an authenticated session with a user token', async () => {
+ const csn = connectSessionFactory
+ .params({ intent: { type: 'completePorting' } })
+ .transient({ token: 'secret_sauce' })
+ .build()
+ const { token } = await exchangeSessionWithToken(csn, 'completePorting')
+ expect(token).toBe('exchanged:secret_sauce')
+})
+
+it('returns the validated connectSession object', async () => {
+ const csn = connectSessionFactory
+ .params({ intent: { type: 'completePorting' } })
+ .build()
+ const { connectSession } = await exchangeSessionWithToken(
+ csn,
+ 'completePorting',
+ )
+ expect(connectSession).toEqual(csn)
+})
+
+it('throws with an invalid connectSession object', () => {
+ expect(exchangeSessionWithToken({}, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: The object you passed in/,
+ )
+ expect(exchangeSessionWithToken(null, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: The object you passed in/,
+ )
+ expect(
+ exchangeSessionWithToken(undefined, 'completePorting'),
+ ).rejects.toThrow(/INVALID_SESSION: The object you passed in/)
+ expect(exchangeSessionWithToken('secret', 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: The object you passed in/,
+ )
+})
+
+it('throws with the wrong intent type', () => {
+ const csn = connectSessionFactory
+ // @ts-expect-error Assume the intent type is not supported
+ .params({ intent: { type: 'somethingElse' } })
+ .build()
+ expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: PortingEmbed must be initialized with the "completePorting" intent/,
+ )
+})
+
+it('throws with an unauthenticated session', async () => {
+ const csn = connectSessionFactory
+ .params({ intent: { type: 'completePorting' } })
+ .unauthenticated()
+ .build()
+ expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: Session has no token./,
+ )
+})
+
+it('throws without a url in the session', async () => {
+ const csn = connectSessionFactory
+ .params({ url: null, intent: { type: 'completePorting' } })
+ .build()
+ expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: Session has no URL/,
+ )
+})
+
+it('throws with an expired session', async () => {
+ const csn = connectSessionFactory
+ .params({ intent: { type: 'completePorting' } })
+ .transient({ token: 'expired' })
+ .build()
+ expect(exchangeSessionWithToken(csn, 'completePorting')).rejects.toThrow(
+ /INVALID_SESSION: Session is expired/,
+ )
+})
diff --git a/lib/core/assert.ts b/lib/core/assert.ts
new file mode 100644
index 0000000..3410093
--- /dev/null
+++ b/lib/core/assert.ts
@@ -0,0 +1,9 @@
+export function assert(
+ condition: unknown,
+ message: string,
+ cause?: Error,
+): asserts condition {
+ if (!condition) {
+ throw new Error(message, { cause })
+ }
+}
diff --git a/lib/core/subscription.ts b/lib/core/subscription.ts
new file mode 100644
index 0000000..ab29cb9
--- /dev/null
+++ b/lib/core/subscription.ts
@@ -0,0 +1,30 @@
+import { Subscription } from '../types'
+import { assert } from './assert'
+
+type FetchSubOptions = {
+ /** Project id of the subscription. */
+ project: string
+ /** User token. */
+ token: string
+}
+
+/**
+ * Fetch the user's subscription.
+ * @param sub The subscription id.
+ * @param opts Additional options.
+ */
+export async function fetchSubscription(sub: string, opts: FetchSubOptions) {
+ const res = await fetch(
+ `https://api.gigs.com/projects/${opts.project}/subscriptions/${sub}`,
+ { headers: { authorization: `bearer ${opts.token} ` } },
+ )
+ const body = await res.json().catch(() => res.text())
+ assert(
+ res.status !== 404,
+ 'SUB_NOT_FOUND: Subscription could not be fetched.',
+ )
+ assert(res.ok, `FETCH_FAILED: ${body?.message || body?.toString()}`)
+ const subscription = body as Subscription
+
+ return subscription
+}
diff --git a/lib/core/token.ts b/lib/core/token.ts
new file mode 100644
index 0000000..a21273b
--- /dev/null
+++ b/lib/core/token.ts
@@ -0,0 +1,76 @@
+import { ConnectSession, ConnectSessionIntent } from '../types'
+import { assert } from './assert'
+
+type Tokens = {
+ access_token: string
+}
+
+/**
+ * Exchange an authenticated ConnectSession with a user token.
+ * Returns the token, together with the validated ConnectSession for the sake
+ * of easier type-safety.
+ */
+export async function exchangeSessionWithToken(
+ session: unknown,
+ type: ConnectSessionIntent['type'],
+) {
+ const { connectSession, token } = parseConnectSession(session, type)
+
+ const res = await fetch(`https://connect.gigs.com/api/embeds/auth`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ secret: token }),
+ })
+ const body: { token?: Tokens; error?: string } = await res
+ .json()
+ .catch(async () => ({ error: await res.text() }))
+ assert(res.status !== 422, 'INVALID_SESSION: Session is expired.')
+ assert(res.ok, `FETCH_FAILED: ${body.error}`)
+ assert(
+ body.token?.access_token,
+ 'Expected user token to be returned in response of token exchange, but was not found.',
+ )
+
+ return { connectSession, token: body.token.access_token }
+}
+
+function parseConnectSession(
+ session: unknown,
+ type: ConnectSessionIntent['type'],
+) {
+ // The session which is used to initilialize an embed is intentionally typed
+ // as unknown.
+ // 1. For non-typescript usage, we need to ensure that the passed in object is
+ // correct anyways.
+ // 2. To not require the caller to cast the response as a ConnectSession.
+ // Especially since fetch returns any. Makes it a little easier to use.
+
+ assert(
+ session &&
+ typeof session === 'object' &&
+ 'object' in session &&
+ 'intent' in session &&
+ 'url' in session &&
+ session.object === 'connectSession',
+ 'INVALID_SESSION: The object you passed in is not a ConnectSession resoure. Make sure to pass in the complete resource.',
+ )
+ const connectSession = session as ConnectSession
+
+ assert(
+ connectSession.intent.type === type,
+ `INVALID_SESSION: PortingEmbed must be initialized with the "${type}" intent, but got "${connectSession.intent.type}" instead.`,
+ )
+ assert(
+ connectSession.url,
+ 'INVALID_SESSION: Session has no URL. Did you pass in the created session?',
+ )
+
+ const url = new URL(connectSession.url)
+ const token = url.searchParams.get('token')
+ assert(
+ token,
+ 'INVALID_SESSION: Session has no token. Is it an authenticated session?',
+ )
+
+ return { connectSession, token }
+}
diff --git a/lib/main.ts b/lib/index.ts
similarity index 50%
rename from lib/main.ts
rename to lib/index.ts
index d1b8cf0..d9b3287 100644
--- a/lib/main.ts
+++ b/lib/index.ts
@@ -1 +1,2 @@
export { WelcomeEmbed } from './WelcomeEmbed'
+export { PortingEmbed } from './PortingEmbed'
diff --git a/lib/types/connectSession.ts b/lib/types/connectSession.ts
new file mode 100644
index 0000000..069ec11
--- /dev/null
+++ b/lib/types/connectSession.ts
@@ -0,0 +1,17 @@
+export type ConnectSession = {
+ object: 'connectSession'
+ id: string
+ callbackUrl: string | null
+ intent: ConnectSessionIntent
+ url: string | null
+ user: string | null
+}
+
+export type ConnectSessionIntent = ConnectSessionCompletePortingIntent
+
+export type ConnectSessionCompletePortingIntent = {
+ type: 'completePorting'
+ completePorting: {
+ subscription: string
+ }
+}
diff --git a/lib/types/index.ts b/lib/types/index.ts
new file mode 100644
index 0000000..71119f6
--- /dev/null
+++ b/lib/types/index.ts
@@ -0,0 +1,3 @@
+export * from './connectSession'
+export * from './porting'
+export * from './subscription'
diff --git a/lib/types/porting.ts b/lib/types/porting.ts
new file mode 100644
index 0000000..5330131
--- /dev/null
+++ b/lib/types/porting.ts
@@ -0,0 +1,64 @@
+export type Porting = {
+ object: 'porting'
+ id: string
+ accountNumber: string | null
+ accountPinExists: boolean
+ address: PortingAddress | null
+ birthday: string | null
+ declinedAttempts: number
+ declinedCode: string | null
+ declinedMessage: string | null
+ donorProvider: ServiceProvider | null
+ donorProviderApproval: boolean | null
+ firstName: string | null
+ lastName: string | null
+ phoneNumber: string
+ provider: string
+ recipientProvider: ServiceProvider
+ required: PortingRequiredField[]
+ status: PortingStatus
+ subscription: string | null
+ user: string
+ canceledAt: string | null
+ completedAt: string | null
+ createdAt: string
+ expiredAt: string | null
+ lastDeclinedAt: string | null
+ lastRequestedAt: string | null
+}
+
+export type PortingAddress = {
+ city: string
+ country: string
+ line1: string
+ line2: string
+ postalCode: string
+ state: string | null
+}
+
+export type ServiceProvider = {
+ object: 'serviceProvider'
+ id: string
+ name: string
+ recipientProviders: string[]
+}
+
+export type PortingRequiredField =
+ | 'accountNumber'
+ | 'accountPin'
+ | 'address'
+ | 'birthday'
+ | 'donorProvider'
+ | 'donorProviderApproval'
+ | 'firstName'
+ | 'lastName'
+
+export type PortingStatus =
+ | 'draft'
+ | 'pending'
+ | 'informationRequired'
+ | 'requested'
+ | 'declined'
+ | 'completed'
+ | 'canceled'
+ | 'expired'
diff --git a/lib/types/subscription.ts b/lib/types/subscription.ts
new file mode 100644
index 0000000..dda709c
--- /dev/null
+++ b/lib/types/subscription.ts
@@ -0,0 +1,8 @@
+import { Porting } from '.'
+
+// Reduced version of a subscription because we only care about the porting
+export type Subscription = {
+ object: 'subscription'
+ id: string
+ porting: Porting | null
+}
diff --git a/package-lock.json b/package-lock.json
index 2f9dfaf..b5a319a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^0.6.15",
+ "fishery": "^2.2.2",
"jsdom": "^24.0.0",
"mitt": "^3.0.1",
"msw": "^2.2.0",
@@ -11560,6 +11561,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/fishery": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz",
+ "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==",
+ "dev": true,
+ "dependencies": {
+ "lodash.mergewith": "^4.6.2"
+ }
+ },
"node_modules/flat-cache": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
@@ -13957,6 +13967,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
+ "node_modules/lodash.mergewith": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+ "dev": true
+ },
"node_modules/lodash.uniqby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz",
diff --git a/package.json b/package.json
index 6ea0d48..e1ec15a 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^0.6.15",
+ "fishery": "^2.2.2",
"jsdom": "^24.0.0",
"mitt": "^3.0.1",
"msw": "^2.2.0",
diff --git a/scripts/setupTests.ts b/scripts/setupTests.ts
index c6e5fd6..80b9f8e 100644
--- a/scripts/setupTests.ts
+++ b/scripts/setupTests.ts
@@ -4,6 +4,8 @@ import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/preact'
+import { clearDb } from '@/testing/db'
+
import { server } from '../testing/http'
afterEach(() => cleanup())
@@ -12,3 +14,5 @@ afterEach(() => cleanup())
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
+
+afterEach(() => clearDb())
diff --git a/src/App.css b/src/App.css
index b9d355d..74b17e3 100644
--- a/src/App.css
+++ b/src/App.css
@@ -2,7 +2,6 @@
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
- text-align: center;
}
.logo {
diff --git a/src/App.tsx b/src/App.tsx
index bf7e0c8..2882945 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,33 +1,51 @@
import './App.css'
-import { useEffect, useRef } from 'preact/hooks'
+import { useRef, useState } from 'preact/hooks'
+import * as React from 'react'
-import { WelcomeEmbed } from '../lib/main'
+import { PortingEmbed } from '../lib'
function App() {
- const $welcomeEmbedEl = useRef(null)
-
- useEffect(() => {
- async function main() {
- const embed = await WelcomeEmbed('abc', { name: 'Jerry' })
- embed.mount($welcomeEmbedEl.current!)
-
- embed.on('count', (count) => {
- if (count > 5) {
- embed.update({ name: `Elaine ${count}` })
- }
- })
+ const $portingEmbedEl = useRef(null)
+ const [loading, setLoading] = useState<'idle' | 'loading' | 'loaded'>('idle')
+
+ async function handleSubmit(
+ event: React.JSX.TargetedSubmitEvent,
+ ) {
+ event.preventDefault()
+
+ try {
+ setLoading('loading')
+ const formData = new FormData(event.currentTarget)
+ const csn = formData.get('csn')!.toString()
+ const project = formData.get('project')!.toString()
+
+ const embed = await PortingEmbed(JSON.parse(csn), { project })
+ embed.mount($portingEmbedEl.current!)
+ } catch (error) {
+ console.error(error)
+ setLoading('idle')
}
-
- main()
- }, [])
+ }
return (
<>
-
+
+
+ {loading === 'idle' && Fill out form first
}
+ {loading === 'loading' && Loading...
}
+
>
)
}
diff --git a/testing/db.ts b/testing/db.ts
new file mode 100644
index 0000000..b1c32a0
--- /dev/null
+++ b/testing/db.ts
@@ -0,0 +1,19 @@
+import { ConnectSession, Porting, Subscription } from '../lib/types'
+
+type TestDb = {
+ subscriptions: Subscription[]
+ portings: Porting[]
+ connectSessions: ConnectSession[]
+}
+
+export const db: TestDb = {
+ subscriptions: [],
+ portings: [],
+ connectSessions: [],
+}
+
+export function clearDb() {
+ db.subscriptions = []
+ db.portings = []
+ db.connectSessions = []
+}
diff --git a/testing/factories/connectSession.ts b/testing/factories/connectSession.ts
new file mode 100644
index 0000000..ee3701e
--- /dev/null
+++ b/testing/factories/connectSession.ts
@@ -0,0 +1,46 @@
+import { Factory } from 'fishery'
+
+import { ConnectSession } from '../../lib/types'
+
+type ConnectSessionTransientParams = {
+ token: string
+}
+
+class ConnectSessionFactory extends Factory<
+ ConnectSession,
+ ConnectSessionTransientParams
+> {
+ completePorting(subscription?: string) {
+ return this.params({
+ intent: {
+ type: 'completePorting' as const,
+ completePorting: {
+ subscription: subscription || `sub_${this.sequence()}`,
+ },
+ },
+ })
+ }
+
+ unauthenticated() {
+ return this.params({
+ url: `https://connect.gigs.com/portal/entry?session=csn_${this.sequence()}`,
+ user: null,
+ })
+ }
+}
+
+export const connectSessionFactory = ConnectSessionFactory.define(
+ ({ sequence, transientParams }) => ({
+ object: 'connectSession' as const,
+ id: `csn_${sequence}`,
+ intent: {
+ type: 'completePorting' as const,
+ completePorting: {
+ subscription: 'sub_123',
+ },
+ },
+ callbackUrl: null,
+ url: `https://connect.gigs.com/portal/entry?session=csn_${sequence}&token=${transientParams.token || 'secrettoken'}`,
+ user: 'usr_123',
+ }),
+)
diff --git a/testing/factories/porting.ts b/testing/factories/porting.ts
new file mode 100644
index 0000000..e3db637
--- /dev/null
+++ b/testing/factories/porting.ts
@@ -0,0 +1,44 @@
+import { Factory } from 'fishery'
+
+import { Porting } from '../../lib/types'
+import { serviceProviderFactory } from './serviceProvider'
+
+type PortingTransientParams = never
+
+class PortingFactory extends Factory {
+ declined() {
+ return this.params({ status: 'declined' })
+ }
+}
+
+export const portingFactory = PortingFactory.define(
+ ({ sequence, associations }) => ({
+ object: 'porting' as const,
+ id: `prt_${sequence}`,
+ accountNumber: null,
+ accountPinExists: false,
+ address: null,
+ birthday: null,
+ declinedAttempts: 0,
+ declinedCode: null,
+ declinedMessage: null,
+ donorProvider: associations.donorProvider || serviceProviderFactory.build(),
+ donorProviderApproval: null,
+ firstName: null,
+ lastName: null,
+ phoneNumber: '+19591234567',
+ provider: 'p7',
+ recipientProvider:
+ associations.recipientProvider || serviceProviderFactory.build(),
+ required: [],
+ status: 'informationRequired' as const,
+ subscription: null,
+ user: 'usr_123',
+ canceledAt: null,
+ completedAt: null,
+ createdAt: new Date().toISOString(),
+ expiredAt: null,
+ lastDeclinedAt: null,
+ lastRequestedAt: null,
+ }),
+)
diff --git a/testing/factories/serviceProvider.ts b/testing/factories/serviceProvider.ts
new file mode 100644
index 0000000..dac23dc
--- /dev/null
+++ b/testing/factories/serviceProvider.ts
@@ -0,0 +1,19 @@
+import { Factory } from 'fishery'
+
+import { ServiceProvider } from '../../lib/types'
+
+type ServiceProviderTransientParams = never
+
+class ServiceProviderFactory extends Factory<
+ ServiceProvider,
+ ServiceProviderTransientParams
+> {}
+
+export const serviceProviderFactory = ServiceProviderFactory.define(
+ ({ sequence }) => ({
+ object: 'serviceProvider' as const,
+ id: `svp_${sequence}`,
+ name: 'Example Provider',
+ recipientProviders: [],
+ }),
+)
diff --git a/testing/factories/subscription.ts b/testing/factories/subscription.ts
new file mode 100644
index 0000000..c0a0d84
--- /dev/null
+++ b/testing/factories/subscription.ts
@@ -0,0 +1,32 @@
+import { Factory } from 'fishery'
+
+import { Subscription } from '../../lib/types'
+import { db } from '../db'
+import { portingFactory } from './porting'
+
+type SubscriptionTransientParams = never
+
+class SubscriptionFactory extends Factory<
+ Subscription,
+ SubscriptionTransientParams
+> {
+ withoutPorting() {
+ return this.params({ porting: null })
+ }
+}
+
+export const subscriptionFactory = SubscriptionFactory.define(
+ ({ sequence, associations, onCreate }) => {
+ onCreate((sub) => {
+ db.subscriptions.push(sub)
+ if (sub.porting) db.portings.push(sub.porting)
+ return sub
+ })
+
+ return {
+ object: 'subscription' as const,
+ id: `sub_${sequence}`,
+ porting: associations.porting || portingFactory.build(),
+ }
+ },
+)
diff --git a/testing/http.ts b/testing/http.ts
index 07d793b..bebe420 100644
--- a/testing/http.ts
+++ b/testing/http.ts
@@ -1,10 +1,46 @@
import { http, HttpHandler, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
+import { db } from './db'
+
export const handlers: HttpHandler[] = [
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json({ name: 'George' })
}),
+
+ http.get<{ project: string; id: string }>(
+ 'https://api.gigs.com/projects/:project/subscriptions/:id',
+ async ({ params }) => {
+ const sub = db.subscriptions.find((sub) => sub.id === params.id)
+
+ if (!sub) {
+ return HttpResponse.json(
+ { object: 'error', type: 'notFound' },
+ { status: 404 },
+ )
+ }
+
+ return HttpResponse.json(sub)
+ },
+ ),
+
+ http.post(
+ 'https://connect.gigs.com/api/embeds/auth',
+ async ({ request }) => {
+ const body = await request.json()
+ if (!body.secret) {
+ return HttpResponse.json({ error: 'No secret.' }, { status: 422 })
+ }
+
+ if (body.secret === 'expired') {
+ return HttpResponse.json({ error: 'Invalid secret.' }, { status: 422 })
+ }
+
+ return HttpResponse.json({
+ token: { access_token: `exchanged:${body.secret}` },
+ })
+ },
+ ),
]
export const server = setupServer(...handlers)
diff --git a/tsconfig.json b/tsconfig.json
index d7d61de..7701dd0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vitest/globals"],
@@ -23,6 +23,7 @@
/* Linting */
"strict": true,
+ "strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
diff --git a/vite.config.ts b/vite.config.ts
index b453928..9a016a5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -18,7 +18,7 @@ export default defineConfig({
copyPublicDir: false,
minify: 'esbuild',
lib: {
- entry: resolve(__dirname, 'lib/main.ts'),
+ entry: resolve(__dirname, 'lib/index.ts'),
name: 'GigsEmbeds',
fileName: 'gigs-embeds',
},