* 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
283 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|