From 0911cb8e7584e6452fbd40e34e607e45f54292eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 23:41:45 +0000 Subject: [PATCH 1/2] prefill invite URL during onboarding for users signing up from invite link Store the join URL in localStorage when an unauthenticated user clicks Sign Up to Join on the invite page. During onboarding, consume that URL to pre-select the Join Group option, prefill the invitation link field, and auto-fetch group details. The URL is removed from localStorage immediately on consumption, with all reads/writes wrapped in try/catch for graceful degradation. https://claude.ai/code/session_01MQRju3rB8hFsZ26SQ3dnJs --- .../_components/join-group-form.tsx | 28 +++++++++++-- .../onboarding/_lib/onboarding-context.tsx | 15 +++++++ .../[groupId]/_components/invite-buttons.tsx | 15 ++++++- app/(standalone)/join/[groupId]/page.tsx | 2 +- lib/utils/pending-invite.ts | 40 +++++++++++++++++++ 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 lib/utils/pending-invite.ts diff --git a/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx b/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx index 637ff7d..6144ced 100644 --- a/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx +++ b/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx @@ -57,13 +57,16 @@ export default function JoinGroupForm() { { message: 'Invalid invite link' }, ), }) + + const initialUrl = state.joinGroupScreenData?.id + ? `https://gridtipapp.com/join/${state.joinGroupScreenData.id}` + : state.pendingInviteUrl || '' + const form = useForm({ mode: 'onBlur', resolver: zodResolver(schema), defaultValues: { - url: state.joinGroupScreenData?.id - ? `https://gridtipapp.com/join/${state.joinGroupScreenData.id}` - : '', + url: initialUrl, }, }) @@ -92,6 +95,19 @@ export default function JoinGroupForm() { const [isPending, startTransition] = React.useTransition() + // Auto-fetch group details if we have a pending invite URL from the join page + const hasFetchedPendingInvite = React.useRef(false) + React.useEffect(() => { + if ( + state.pendingInviteUrl && + !state.joinGroupScreenData && + !hasFetchedPendingInvite.current + ) { + hasFetchedPendingInvite.current = true + fetchGroupFromUrl(state.pendingInviteUrl) + } + }, [state.pendingInviteUrl]) // eslint-disable-line react-hooks/exhaustive-deps + return (
@@ -189,10 +205,14 @@ export default function JoinGroupForm() { ) function onSubmit(data: FormSchema) { + fetchGroupFromUrl(data.url) + } + + function fetchGroupFromUrl(url: string) { setGroupState(undefined) startTransition(async () => { - const groupId = getId(data.url) + const groupId = getId(url) if (!groupId) { setGroupState({ state: 'error', diff --git a/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx b/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx index 4bc90af..d903ebd 100644 --- a/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx +++ b/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx @@ -5,6 +5,7 @@ import { ReactNode, useCallback, useContext, + useEffect, useState, } from 'react' import { GroupAction } from '../_components/onboarding-client' @@ -19,6 +20,7 @@ import { } from '@/actions/complete-onboarding' import { toast } from 'sonner' import { OnboardingCreateGroupFormData } from '../_components/create-group-form' +import { consumePendingInviteUrl } from '@/lib/utils/pending-invite' type ComponentKey = | 'welcome-initial' @@ -35,6 +37,8 @@ export type OnboardingState = { createGroupScreenData?: OnboardingCreateGroupFormData joinGroupScreenData?: JoinGroupData + pendingInviteUrl?: string + globalGroupScreenData?: { isJoin: boolean } profileDefaultData?: ProfileState @@ -72,6 +76,17 @@ export function OnboardingProvider({ profileCreateGroupData: defaultProfileData, }) + useEffect(() => { + const url = consumePendingInviteUrl() + if (url) { + setState((prev) => ({ + ...prev, + pendingInviteUrl: url, + welcomeScreenSelectedGroupStep: 'join', + })) + } + }, []) + const goToScreen = useCallback((component: ComponentKey) => { setState((prevState) => ({ ...prevState, diff --git a/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx b/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx index fa9d266..3c6d8c0 100644 --- a/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx +++ b/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx @@ -5,13 +5,24 @@ import Link from 'next/link' import { Path } from '@/lib/utils/path' import posthog from 'posthog-js' import { AnalyticsEvent } from '@/lib/posthog/events' +import { savePendingInviteUrl } from '@/lib/utils/pending-invite' -export function InviteButtons({ loginHref }: { loginHref: string }) { +export function InviteButtons({ + loginHref, + groupId, +}: { + loginHref: string + groupId: string +}) { return (
diff --git a/app/(standalone)/join/[groupId]/page.tsx b/app/(standalone)/join/[groupId]/page.tsx index c63e9ef..2c4b4fd 100644 --- a/app/(standalone)/join/[groupId]/page.tsx +++ b/app/(standalone)/join/[groupId]/page.tsx @@ -143,7 +143,7 @@ export default async function JoinGroup({ const href = withQuery(getAuthLinkWithOrigin(QueryOrigin.Join), { redirect: `/join/${groupId}`, }) - return + return } return } diff --git a/lib/utils/pending-invite.ts b/lib/utils/pending-invite.ts new file mode 100644 index 0000000..8449976 --- /dev/null +++ b/lib/utils/pending-invite.ts @@ -0,0 +1,40 @@ +const STORAGE_KEY = 'gridtip:pendingInviteUrl' + +/** + * Stores a join invite URL in localStorage so it can be prefilled + * during onboarding after a new user signs up from an invite link. + */ +export function savePendingInviteUrl(joinUrl: string) { + try { + localStorage.setItem(STORAGE_KEY, joinUrl) + } catch { + // localStorage unavailable or full — silently ignore + } +} + +/** + * Reads and removes the pending invite URL from localStorage. + * Returns null if nothing is stored or localStorage is unavailable. + */ +export function consumePendingInviteUrl(): string | null { + try { + const url = localStorage.getItem(STORAGE_KEY) + if (url) { + localStorage.removeItem(STORAGE_KEY) + } + return url + } catch { + return null + } +} + +/** + * Removes the pending invite URL from localStorage without reading it. + */ +export function clearPendingInviteUrl() { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // silently ignore + } +} From 4414afba42c179a4ce4191861f7647d568d03b02 Mon Sep 17 00:00:00 2001 From: Joschua <70809675+selfire1@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:42:11 +1000 Subject: [PATCH 2/2] small code improvements --- .../_components/join-group-form.tsx | 35 +++++++++---------- .../onboarding/_lib/onboarding-context.tsx | 19 +++++----- .../[groupId]/_components/invite-buttons.tsx | 8 ++--- lib/utils/pending-invite.ts | 17 ++------- 4 files changed, 34 insertions(+), 45 deletions(-) diff --git a/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx b/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx index 6144ced..e8a98d1 100644 --- a/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx +++ b/app/(onboarding)/tipping/onboarding/_components/join-group-form.tsx @@ -1,9 +1,23 @@ 'use client' -import { InputGroup, InputGroupAddon, InputGroupInput } from '@ui/input-group' import { zodResolver } from '@hookform/resolvers/zod' +import { isCuid } from '@paralleldrive/cuid2' +import { Button } from '@ui/button' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@ui/input-group' +import { + LinkIcon, + LucideAlertCircle, + LucideCheck, + LucideChevronRight, + LucideSearch, +} from 'lucide-react' +import posthog from 'posthog-js' +import React from 'react' import { Controller, useForm } from 'react-hook-form' import { z } from 'zod' +import { findGroup } from '@/actions/join-group' +import Alert from '@/components/alert' +import { IconFromName } from '@/components/icon-from-name' import { Card, CardContent, @@ -19,23 +33,9 @@ import { FieldLabel, FieldSet, } from '@/components/ui/field' -import { Button } from '@ui/button' -import { - LinkIcon, - LucideAlertCircle, - LucideCheck, - LucideChevronRight, - LucideSearch, -} from 'lucide-react' -import React from 'react' import { Spinner } from '@/components/ui/spinner' -import { isCuid } from '@paralleldrive/cuid2' -import { findGroup } from '@/actions/join-group' -import Alert from '@/components/alert' -import { IconFromName } from '@/components/icon-from-name' -import { useOnboarding } from '../_lib/onboarding-context' -import posthog from 'posthog-js' import { AnalyticsEvent } from '@/lib/posthog/events' +import { useOnboarding } from '../_lib/onboarding-context' export type JoinGroupData = NonNullable< Awaited>['data'] @@ -205,12 +205,11 @@ export default function JoinGroupForm() { ) function onSubmit(data: FormSchema) { + setGroupState(undefined) fetchGroupFromUrl(data.url) } function fetchGroupFromUrl(url: string) { - setGroupState(undefined) - startTransition(async () => { const groupId = getId(url) if (!groupId) { diff --git a/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx b/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx index d903ebd..4e1a295 100644 --- a/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx +++ b/app/(onboarding)/tipping/onboarding/_lib/onboarding-context.tsx @@ -8,19 +8,19 @@ import { useEffect, useState, } from 'react' -import { GroupAction } from '../_components/onboarding-client' -import { JoinGroupData } from '../_components/join-group-form' -import { ProfileState } from '../_components/screens/profile-screen' -import type { DalUser } from '@/lib/dal' +import { toast } from 'sonner' import { - joinOrCreateGroupAndUpdateImage, - joinGlobalGroupIfDesiredAndUpdateImage, completeProfileOnboardingAction, + joinGlobalGroupIfDesiredAndUpdateImage, + joinOrCreateGroupAndUpdateImage, type Log, } from '@/actions/complete-onboarding' -import { toast } from 'sonner' +import type { DalUser } from '@/lib/dal' +import { consumePendingInviteUrlFromLocalStorage } from '@/lib/utils/pending-invite' import { OnboardingCreateGroupFormData } from '../_components/create-group-form' -import { consumePendingInviteUrl } from '@/lib/utils/pending-invite' +import { JoinGroupData } from '../_components/join-group-form' +import { GroupAction } from '../_components/onboarding-client' +import { ProfileState } from '../_components/screens/profile-screen' type ComponentKey = | 'welcome-initial' @@ -77,8 +77,9 @@ export function OnboardingProvider({ }) useEffect(() => { - const url = consumePendingInviteUrl() + const url = consumePendingInviteUrlFromLocalStorage() if (url) { + // eslint-disable-next-line react-hooks/set-state-in-effect setState((prev) => ({ ...prev, pendingInviteUrl: url, diff --git a/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx b/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx index 3c6d8c0..28fad74 100644 --- a/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx +++ b/app/(standalone)/join/[groupId]/_components/invite-buttons.tsx @@ -1,11 +1,11 @@ 'use client' -import { Button } from '@/components/ui/button' import Link from 'next/link' -import { Path } from '@/lib/utils/path' import posthog from 'posthog-js' +import { Button } from '@/components/ui/button' import { AnalyticsEvent } from '@/lib/posthog/events' -import { savePendingInviteUrl } from '@/lib/utils/pending-invite' +import { Path } from '@/lib/utils/path' +import { savePendingInviteUrlToLocalStorage } from '@/lib/utils/pending-invite' export function InviteButtons({ loginHref, @@ -20,7 +20,7 @@ export function InviteButtons({ asChild onClick={() => { const joinUrl = `${window.location.origin}/join/${groupId}` - savePendingInviteUrl(joinUrl) + savePendingInviteUrlToLocalStorage(joinUrl) posthog.capture(AnalyticsEvent.INVITE_SIGNUP_CLICKED) }} > diff --git a/lib/utils/pending-invite.ts b/lib/utils/pending-invite.ts index 8449976..4045021 100644 --- a/lib/utils/pending-invite.ts +++ b/lib/utils/pending-invite.ts @@ -1,10 +1,6 @@ const STORAGE_KEY = 'gridtip:pendingInviteUrl' -/** - * Stores a join invite URL in localStorage so it can be prefilled - * during onboarding after a new user signs up from an invite link. - */ -export function savePendingInviteUrl(joinUrl: string) { +export function savePendingInviteUrlToLocalStorage(joinUrl: string) { try { localStorage.setItem(STORAGE_KEY, joinUrl) } catch { @@ -12,11 +8,7 @@ export function savePendingInviteUrl(joinUrl: string) { } } -/** - * Reads and removes the pending invite URL from localStorage. - * Returns null if nothing is stored or localStorage is unavailable. - */ -export function consumePendingInviteUrl(): string | null { +export function consumePendingInviteUrlFromLocalStorage(): string | null { try { const url = localStorage.getItem(STORAGE_KEY) if (url) { @@ -28,10 +20,7 @@ export function consumePendingInviteUrl(): string | null { } } -/** - * Removes the pending invite URL from localStorage without reading it. - */ -export function clearPendingInviteUrl() { +export function clearPendingInviteUrlFromLocalStorage() { try { localStorage.removeItem(STORAGE_KEY) } catch {