metatron-open-seo/src/routes/_auth.sign-up.tsx
Ben Senescu ad3b732f60 hosted: add product analytics (#83)
* track core product analytics flows

Track auth, search, export, audit, and credit-consumption events with canonical route IDs so PostHog funnels and usage dashboards stay low-noise and privacy-safe.

* fix: keep auth actions usable after session loss

* refactor: simplify analytics and auth helpers

- Replace isRecord/getActiveOrganizationId type guards with simple cast
- Refactor getAnalyticsRouteContext from if/return chain to route tables
- Replace toVerificationIssueType switch with zod enum
- Merge duplicate credits_consume events into single event per API call
- Merge two PostHogBootstrap useEffects into one

* refactor: add projectId to middleware context to reduce boilerplate

The requireProjectContext middleware now includes projectId directly,
eliminating repeated manual construction of BillingCustomerContext
objects across all server function handlers.

* remove unused BILLING_* env var fallbacks from cost profile script

* remove before_send event enrichment to preserve native PostHog URL tracking

The before_send hook was stripping $pathname, $current_url, $referrer and
other URL properties, which breaks PostHog web analytics dashboards, paths
analysis, session replay, and attribution. The route_id/route_group injection
it provided is unnecessary since PostHog already captures $pathname natively.

* remove route mapping layer, pass raw redirect paths to analytics events

The route ID registry (STATIC_ROUTES, PROJECT_ROUTES, getAnalyticsRouteContext,
getRedirectRouteId) duplicated what PostHog already captures via $pathname.
Replace redirect_route_id with redirect_to containing the raw path, and remove
~80 lines of route mapping infrastructure.

* clean up analytics events: drop redundant submit events and derived properties

- Remove search_submit events for keywords, domain overview, and backlinks
  (the search_complete events capture the meaningful outcome data)
- Remove target_type from backlinks events (derived 1:1 from search_scope)
- Remove result_limit from keyword research (requested limit, not useful
  alongside actual result_count)
- Remove export_format from data:export events (always "csv")

* refactor: inline wrappers, colocate helpers, deduplicate getActiveOrganizationId

- Inline toVerificationIssueType into verify-email.tsx (single-use wrapper)
- Move mapDataforseoPathToCreditFeature into dataforseoClient.ts (only consumer)
- Extract shared getActiveOrganizationId into lib/auth-session.ts (was
  duplicated in __root.tsx and middleware/ensure-user/hosted.ts)
- Rename shared/analytics.ts → shared/internal-user.ts (only email helpers
  remain after removing route mapping, verification, and dataforseo helpers)

* remove internal user tracking and email domain properties

Drop is_internal_user super property, email_domain person property, and all
supporting code (shared/internal-user.ts, getEmailDomain, isInternalUserEmail).
Simplifies initPostHog and identifyAnalyticsUser signatures.

* remove backlinks:search_complete effect-based tracking

The reactive useEffect + useRef dedup pattern added ~30 lines of plumbing
inside a data hook for a single analytics event. Not worth the complexity.

* simplify: replace manual type guards with zod, deduplicate posthog and sign-out helpers

- Replace hand-rolled typeof checks in getActiveOrganizationId and
  isAuthenticatedServerFunctionContext with zod safeParse
- Extract withPostHogClient helper to deduplicate client posthog wrapper
- Move apiKey guard into getServerPostHogClient factory
- Extract signOutAndRedirect to avoid duplicated sign-out logic
- Drop derivable has_results from analytics events
- Remove unnecessary path normalization in mapDataforseoPathToCreditFeature

* fix: strip email from pageview URLs, restore sign-out guard, harden server posthog, fix path mapper

- Sanitize $current_url on pageviews to remove email query param (PII)
- Restore onSuccess for sign-out redirect to avoid bounce-back on failure
- Swallow shutdown() errors so PostHog outages can't fail billed work
- Rewrite mapDataforseoPathToCreditFeature to match real API path structure
  (path[1] = module, path[3] = endpoint) instead of scanning all segments

* simplify: remove redundant refs in verify-email, infer middleware context type

- Remove unnecessary useRef guards in verify-email effects (deps already prevent re-firing)
- Use z.ZodType<EnsuredUserContext> annotation to infer return type instead of casting
- Add comment explaining one-shot PostHog client on Workers

* fix: reset PostHog identity on sign-out before redirect

* fix: require POSTHOG_HOST env var instead of defaulting to us.i.posthog.com

* fix: annotate url as unknown to satisfy no-unsafe-assignment

* format
2026-04-08 14:09:02 -04:00

283 lines
8.6 KiB
TypeScript

import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
AuthPageCard,
authRedirectSearchSchema,
getFieldError,
getFormError,
useAuthPageState,
} from "@/client/features/auth/AuthPage";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect";
import {
HOSTED_PASSWORD_MAX_LENGTH,
HOSTED_PASSWORD_MIN_LENGTH,
} from "@/lib/auth-options";
import { z } from "zod";
const signUpSchema = z
.object({
name: z.string().trim(),
email: z.string().trim().email("Enter a valid email address."),
password: z
.string()
.min(
HOSTED_PASSWORD_MIN_LENGTH,
`Password must be at least ${HOSTED_PASSWORD_MIN_LENGTH} characters.`,
)
.max(
HOSTED_PASSWORD_MAX_LENGTH,
`Password must be at most ${HOSTED_PASSWORD_MAX_LENGTH} characters.`,
),
confirmPassword: z.string(),
})
.refine((value) => value.password === value.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"],
});
export const Route = createFileRoute("/_auth/sign-up")({
validateSearch: authRedirectSearchSchema,
component: SignUpPage,
});
function SignUpPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
const form = useForm({
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
validators: {
onSubmit: signUpSchema,
},
onSubmit: async ({ formApi, value }) => {
try {
const email = value.email.trim();
captureClientEvent("auth:sign_up_submit", {
redirect_to: redirectTo,
});
const resolvedName =
value.name.trim() || email.split("@")[0] || "OpenSEO User";
const result = await authClient.signUp.email({
name: resolvedName,
email,
password: value.password,
callbackURL: (() => {
const url = new URL("/verify-email", window.location.origin);
if (redirectTo !== "/")
url.searchParams.set("redirect", redirectTo);
return url.toString();
})(),
});
if (result.error) {
formApi.setErrorMap({
onSubmit: {
form: result.error.message || "Unable to create account.",
fields: {},
},
});
return;
}
captureClientEvent("auth:sign_up_success", {
redirect_to: redirectTo,
});
void navigate({
to: "/verify-email",
search: { email, ...getSignInSearch(redirectTo) },
});
} catch {
formApi.setErrorMap({
onSubmit: {
form: "Unable to create account right now. Please try again.",
fields: {},
},
});
}
},
});
return (
<AuthPageCard
title="Create your account"
footer={
isHostedMode ? (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-base-content/60">
By signing up, you agree to our{" "}
<a
href="https://openseo.so/terms-and-conditions"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Terms
</a>{" "}
and{" "}
<a
href="https://openseo.so/privacy"
target="_blank"
rel="noreferrer"
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Privacy Policy
</a>
.
</p>
<p className="text-sm text-base-content/50">
Already have an account?{" "}
<Link
to="/sign-in"
search={getSignInSearch(redirectTo)}
className="text-base-content underline underline-offset-2 hover:text-base-content/80 transition-colors"
>
Sign in
</Link>
</p>
</div>
) : null
}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="text"
className="input input-bordered w-full"
placeholder="Name (optional)..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="name"
disabled={!isHostedMode}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="email">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="email"
className="input input-bordered w-full"
placeholder="Email address..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="email"
disabled={!isHostedMode}
required
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="password">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Field name="confirmPassword">
{(field) => {
const error = getFieldError(field.state.meta.errors);
return (
<div>
<input
type="password"
className="input input-bordered w-full"
placeholder="Confirm password..."
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
autoComplete="new-password"
disabled={!isHostedMode}
required
minLength={HOSTED_PASSWORD_MIN_LENGTH}
maxLength={HOSTED_PASSWORD_MAX_LENGTH}
/>
{error ? (
<p className="mt-1 text-sm text-error">{error}</p>
) : null}
</div>
);
}}
</form.Field>
<form.Subscribe
selector={(state) => ({
submitError: state.errorMap.onSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ submitError, isSubmitting }) => {
const errorMessage = getFormError(submitError);
return (
<>
{errorMessage ? (
<p className="text-sm text-error">{errorMessage}</p>
) : null}
<button
className="btn btn-soft w-full"
disabled={!isHostedMode || isSubmitting}
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</>
);
}}
</form.Subscribe>
</form>
</AuthPageCard>
);
}