-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add option to pass signed-in user ID to Airtable forms (#1121)
* Prefill user ID when replying to an opportunity * Fix useCurrentUser hook * Add convenience hook to get currently signed-in user * Translate user IDs to target database * Add support for the requireSignIn flag * Implement the requireSignIn flag for opportunities * Comment various flag combinations * Fix case where sign-in is optional * Refactor code * Trivial copy improvement * Add option to redirect to custom page after user sign-up * Add option to route to registration page with a callback URL * Add more explicit sign-up option to sign-in page * Add support for user ID translation to DIA UX research base
- Loading branch information
Showing
15 changed files
with
298 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import Airtable from "airtable"; | ||
|
||
import { withAuthenticatedUser } from "~/src/auth"; | ||
|
||
/** The list of all synced User Profiles tables indexed by their containing DB ID */ | ||
const syncedUserTablesByDatabase: Record<string, string | undefined> = { | ||
// App -> User Profiles | ||
appkn1DkvgVI5jpME: "tbl3QK2aTskyu2rNQ", | ||
// Uživatelský výzkum DIA -> Users | ||
appKWumcDDL9KI00N: "tblTf8usuYWgIZD9x", | ||
}; | ||
|
||
/** Translate signed-in user’s ID to a different database */ | ||
export async function GET(request: Request) { | ||
return withAuthenticatedUser(async (currentUser) => { | ||
const { searchParams } = new URL(request.url); | ||
const formUrl = searchParams.get("formUrl"); | ||
if (!formUrl || typeof formUrl !== "string") { | ||
return new Response("The `formUrl` argument is missing or malformed.", { | ||
status: 400, | ||
}); | ||
} | ||
|
||
// Parse URL, extract target database ID | ||
const matches = /https:\/\/airtable.com\/(app\w+)/.exec(formUrl); | ||
if (matches?.length !== 2) { | ||
return new Response( | ||
"The `formUrl` argument does not match the expected pattern.", | ||
{ | ||
status: 400, | ||
}, | ||
); | ||
} | ||
|
||
const [_, databaseId] = matches; | ||
|
||
// If the database is Users, no user ID translation is needed | ||
if (databaseId === "apppZX1QC3fl1RTBM") { | ||
return new Response( | ||
JSON.stringify({ targetUserId: currentUser.id }, null, 2), | ||
{ | ||
status: 200, | ||
}, | ||
); | ||
} | ||
|
||
// Otherwise, look up the ID of the synced User Profiles table in the target DB | ||
const userTableId = syncedUserTablesByDatabase[databaseId]; | ||
if (!userTableId) { | ||
return new Response(`Unknown database ID: "${databaseId}".`, { | ||
status: 400, | ||
}); | ||
} | ||
|
||
// And once we have that, look up the record ID of currently signed-in user | ||
const airtable = new Airtable(); | ||
const table = airtable.base(databaseId)(userTableId); | ||
const targetUserId = await table | ||
.select({ filterByFormula: `{id} = "${currentUser.id}"`, maxRecords: 1 }) | ||
.all() | ||
.then((records) => records[0].id); | ||
|
||
return new Response(JSON.stringify({ targetUserId }, null, 2), { | ||
status: 200, | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
"use client"; | ||
|
||
import { useEffect, useState } from "react"; | ||
|
||
import { signIn, useSession } from "next-auth/react"; | ||
import { record, string } from "typescript-json-decoder"; | ||
|
||
import { useSignedInUser } from "~/components/hooks/user"; | ||
import { SidebarCTA } from "~/components/Sidebar"; | ||
import { type Opportunity } from "~/src/data/opportunity"; | ||
|
||
type Props = { | ||
role: Pick<Opportunity, "responseUrl" | "prefillUserId" | "requireSignIn">; | ||
}; | ||
|
||
export const ResponseButton = ({ role }: Props) => { | ||
const { status: sessionStatus } = useSession(); | ||
const translatedUserId = useTranslatedUserId(role.responseUrl); | ||
|
||
const shouldPrefill = | ||
role.prefillUserId && role.responseUrl.startsWith("https://"); | ||
|
||
const prefillUserId = (responseUrl: string, userId: string) => { | ||
const prefilledUrl = new URL(responseUrl); | ||
prefilledUrl.searchParams.append("prefill_User", userId); | ||
prefilledUrl.searchParams.append("hide_User", "true"); | ||
return prefilledUrl.toString(); | ||
}; | ||
|
||
const { requireSignIn } = role; | ||
|
||
if (requireSignIn && shouldPrefill) { | ||
// | ||
// 1. Both sign-in and prefill are on. This is expected to be the | ||
// default for most use cases – users are required to sign in and after | ||
// that we pass their ID to the form. | ||
// | ||
if (sessionStatus === "loading") { | ||
return <LoadingSpinner />; | ||
} else if (sessionStatus === "unauthenticated") { | ||
return <SignInButton />; | ||
} else if (!translatedUserId) { | ||
// TBD: If we fail to translate the user ID we’re stuck here forever | ||
return <LoadingSpinner />; | ||
} else { | ||
return ( | ||
<SidebarCTA | ||
href={prefillUserId(role.responseUrl, translatedUserId)} | ||
label="Mám zájem ✨" | ||
/> | ||
); | ||
} | ||
} else if (!requireSignIn && shouldPrefill) { | ||
// | ||
// 2. Prefill is on, but sign-in is optional. If the user is signed in, | ||
// we pass their ID to the form. Not sure if this is going to be used in | ||
// practice. | ||
// | ||
if (sessionStatus === "loading") { | ||
return <LoadingSpinner />; | ||
} else if (sessionStatus === "unauthenticated" || !translatedUserId) { | ||
return <SidebarCTA href={role.responseUrl} label="Mám zájem" />; | ||
} else { | ||
return ( | ||
<SidebarCTA | ||
href={prefillUserId(role.responseUrl, translatedUserId)} | ||
label="Mám zájem ✨" | ||
/> | ||
); | ||
} | ||
} else if (requireSignIn && !shouldPrefill) { | ||
// | ||
// 3. Sign-in is required, but user ID is not passed to the form. This may be | ||
// handy for fully custom forms where you don’t want any autofilling, but | ||
// want to be sure users sign in (and therefore accept our general T&C) | ||
// before filling the form. | ||
// | ||
if (sessionStatus === "authenticated") { | ||
return <SidebarCTA href={role.responseUrl} label="Mám zájem 🔓" />; | ||
} else if (sessionStatus === "unauthenticated") { | ||
return <SignInButton />; | ||
} else { | ||
return <LoadingSpinner />; | ||
} | ||
} else { | ||
// 4. No fancy processing needed, just use the response URL from the DB | ||
return <SidebarCTA href={role.responseUrl} label="Mám zájem" />; | ||
} | ||
}; | ||
|
||
const LoadingSpinner = () => ( | ||
<SidebarCTA href="" label="Malý moment…" disabled /> | ||
); | ||
|
||
const SignInButton = () => ( | ||
<div className="flex flex-col gap-2"> | ||
<button className="btn-primary block text-center" onClick={() => signIn()}> | ||
Mám zájem 🔒 | ||
</button> | ||
<p className="typo-caption text-balance text-center"> | ||
Pokud máš o nabízenou roli zájem, musíš se nejdřív přihlásit nebo | ||
registrovat. | ||
</p> | ||
</div> | ||
); | ||
|
||
function useTranslatedUserId(responseUrl: string) { | ||
const signedInUser = useSignedInUser(); | ||
const [translatedId, setTranslatedId] = useState<string | undefined>(); | ||
|
||
useEffect(() => { | ||
if (!signedInUser) { | ||
return; | ||
} | ||
const decodeResponse = record({ targetUserId: string }); | ||
async function fetchTranslatedId() { | ||
return fetch(`/api/translate-user-id?formUrl=${responseUrl}`) | ||
.then((response) => response.json()) | ||
.then(decodeResponse) | ||
.then((response) => setTranslatedId(response.targetUserId)) | ||
.catch((e) => console.error(e)); | ||
} | ||
void fetchTranslatedId(); | ||
}, [signedInUser, responseUrl]); | ||
|
||
return translatedId; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.