299 lines
8.7 KiB
TypeScript
299 lines
8.7 KiB
TypeScript
/**
|
|
* Onboarding by interests, on every platform.
|
|
*
|
|
* The state is one JSON settings column, `onboardingState`, per account:
|
|
* - `stage` moves `interests` (prompt pending) → `matches` (interests kept,
|
|
* results not shown yet) → `save` (results seen; the nudge to save a profile
|
|
* is on) → `done`. "Not now" on the prompt goes straight to
|
|
* `done`.
|
|
* - `interests`, `lat`, `lon` are the draft of a public profile. They are kept
|
|
* past `done` and cleared only by a profile save or "Forget"; `lat` and `lon`
|
|
* are always rounded ({@link roundCoordinate}).
|
|
* - `welcomeTour` makes Home, Discover, and Projects open the three welcome
|
|
* pages, which open only as a tour started from Help.
|
|
*
|
|
* The nudge asks for a saved profile everywhere, and for a daily alert only
|
|
* where the app offers alerts ({@link offersAlerts}).
|
|
*/
|
|
|
|
import type { BoundingBox } from "@/db/tables/settings";
|
|
|
|
export type OnboardingStage = "interests" | "matches" | "save" | "done";
|
|
|
|
export interface OnboardingState {
|
|
stage: OnboardingStage;
|
|
interests?: string;
|
|
lat?: number;
|
|
lon?: number;
|
|
matchesShownAt?: string | null;
|
|
bannerShownAt?: string | null;
|
|
declinedAt?: string | null;
|
|
welcomeTour?: boolean;
|
|
}
|
|
|
|
/** The four corners the partner server's location filters take. */
|
|
export interface LocBox {
|
|
minLocLat: number;
|
|
minLocLon: number;
|
|
maxLocLat: number;
|
|
maxLocLon: number;
|
|
}
|
|
|
|
const STAGES: ReadonlyArray<OnboardingStage> = [
|
|
"interests",
|
|
"matches",
|
|
"save",
|
|
"done",
|
|
];
|
|
|
|
/** Radius of the area searched around the person's point. */
|
|
export const SEARCH_RADIUS_KM = 50;
|
|
|
|
const KM_PER_DEGREE_LAT = 111;
|
|
|
|
export function parseOnboardingState(
|
|
text: string | undefined | null,
|
|
): OnboardingState {
|
|
if (!text) return { stage: "interests" };
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (!parsed || typeof parsed !== "object") return { stage: "interests" };
|
|
const stage = STAGES.includes(parsed.stage) ? parsed.stage : "interests";
|
|
return { ...parsed, stage };
|
|
} catch {
|
|
return { stage: "interests" };
|
|
}
|
|
}
|
|
|
|
export function serializeOnboardingState(state: OnboardingState): string {
|
|
return JSON.stringify(state);
|
|
}
|
|
|
|
/**
|
|
* The settings to write for a state: the column, plus `finishedOnboarding`
|
|
* once the stage is `done` so everything that reads that flag keeps working.
|
|
*/
|
|
export function onboardingSettingsUpdate(state: OnboardingState): {
|
|
onboardingState: string;
|
|
finishedOnboarding?: boolean;
|
|
} {
|
|
return {
|
|
onboardingState: serializeOnboardingState(state),
|
|
...(state.stage === "done" ? { finishedOnboarding: true } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True on the Android and iOS apps, the only ones with New Activity alerts,
|
|
* so the only ones where the nudge offers one.
|
|
*/
|
|
export function offersAlerts(capabilities: {
|
|
isNativeApp?: boolean;
|
|
isMobile?: boolean;
|
|
}): boolean {
|
|
return !!capabilities?.isNativeApp && !!capabilities?.isMobile;
|
|
}
|
|
|
|
/** Two decimals, about 1 km: the precision of everything stored or saved. */
|
|
export function roundCoordinate(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
export function hasPoint(
|
|
state: Pick<OnboardingState, "lat" | "lon">,
|
|
): state is { lat: number; lon: number } {
|
|
return typeof state.lat === "number" && typeof state.lon === "number";
|
|
}
|
|
|
|
/**
|
|
* "Find matches": keep the text and the rounded point, before any network
|
|
* call. From `interests` or `done` the flow (re)starts at `matches`; from
|
|
* `matches` or `save` the stage stays, so a re-entry from Help doesn't undo
|
|
* a nudge already on.
|
|
*/
|
|
export function withInterests(
|
|
state: OnboardingState,
|
|
interests: string,
|
|
point: { lat: number; lon: number } | null,
|
|
): OnboardingState {
|
|
const stage =
|
|
state.stage === "interests" || state.stage === "done"
|
|
? "matches"
|
|
: state.stage;
|
|
const next: OnboardingState = {
|
|
...state,
|
|
stage,
|
|
interests: interests.trim(),
|
|
};
|
|
if (point) {
|
|
next.lat = roundCoordinate(point.lat);
|
|
next.lon = roundCoordinate(point.lon);
|
|
} else {
|
|
delete next.lat;
|
|
delete next.lon;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/** The first successful results render turns the nudge on. */
|
|
export function withMatchesShown(
|
|
state: OnboardingState,
|
|
now: Date,
|
|
): OnboardingState {
|
|
if (state.stage !== "matches") return state;
|
|
return {
|
|
...state,
|
|
stage: "save",
|
|
matchesShownAt: state.matchesShownAt || now.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* "Not now" on the prompt. Text typed before declining is kept as a profile
|
|
* draft; with nothing typed, any earlier draft stays as it was.
|
|
*/
|
|
export function withDeclined(
|
|
state: OnboardingState,
|
|
now: Date,
|
|
typed?: { interests: string; point: { lat: number; lon: number } | null },
|
|
): OnboardingState {
|
|
const next: OnboardingState = {
|
|
...state,
|
|
stage: "done",
|
|
declinedAt: now.toISOString(),
|
|
};
|
|
if (typed && typed.interests.trim()) {
|
|
return {
|
|
...withInterests(next, typed.interests, typed.point),
|
|
stage: "done",
|
|
};
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/** "Done", "No thanks", or both card steps finished. The draft stays. */
|
|
export function withDone(state: OnboardingState): OnboardingState {
|
|
return { ...state, stage: "done" };
|
|
}
|
|
|
|
/** A saved profile or "Forget": the phone no longer holds a draft. */
|
|
export function withoutInterests(state: OnboardingState): OnboardingState {
|
|
const next = { ...state };
|
|
delete next.interests;
|
|
delete next.lat;
|
|
delete next.lon;
|
|
return next;
|
|
}
|
|
|
|
export function withBannerShown(
|
|
state: OnboardingState,
|
|
now: Date,
|
|
): OnboardingState {
|
|
return { ...state, bannerShownAt: state.bannerShownAt || now.toISOString() };
|
|
}
|
|
|
|
export function withWelcomeTour(
|
|
state: OnboardingState,
|
|
welcomeTour: boolean,
|
|
): OnboardingState {
|
|
return { ...state, welcomeTour };
|
|
}
|
|
|
|
/** The dot, banner, and card: results seen, and registered to act on them. */
|
|
export function isNudgeOn(
|
|
state: OnboardingState,
|
|
isRegistered: boolean,
|
|
): boolean {
|
|
return state.stage === "save" && isRegistered;
|
|
}
|
|
|
|
export function shouldAnimateBanner(
|
|
state: OnboardingState,
|
|
isRegistered: boolean,
|
|
): boolean {
|
|
return isNudgeOn(state, isRegistered) && !state.bannerShownAt;
|
|
}
|
|
|
|
/** Whether a page's welcome dialog opens: only during a tour started from Help. */
|
|
export function shouldOpenWelcome(settings: {
|
|
onboardingState?: string;
|
|
}): boolean {
|
|
return !!parseOnboardingState(settings.onboardingState).welcomeTour;
|
|
}
|
|
|
|
/**
|
|
* A box of about `radiusKm` around a point, the form the partner server
|
|
* filters on. Longitude is widened by 1/cos(lat) so the box stays square on
|
|
* the ground; near the poles it is capped at the whole range.
|
|
*/
|
|
export function boundingBoxAround(
|
|
lat: number,
|
|
lon: number,
|
|
radiusKm: number = SEARCH_RADIUS_KM,
|
|
): LocBox {
|
|
const dLat = radiusKm / KM_PER_DEGREE_LAT;
|
|
const cosLat = Math.cos((lat * Math.PI) / 180);
|
|
const dLon = cosLat > 0.01 ? Math.min(dLat / cosLat, 180) : 180;
|
|
return {
|
|
minLocLat: Math.max(lat - dLat, -90),
|
|
maxLocLat: Math.min(lat + dLat, 90),
|
|
minLocLon: Math.max(lon - dLon, -180),
|
|
maxLocLon: Math.min(lon + dLon, 180),
|
|
};
|
|
}
|
|
|
|
/** The same box as a search area (`settings.searchBoxes`). */
|
|
export function locBoxToBoundingBox(box: LocBox): BoundingBox {
|
|
return {
|
|
eastLong: box.maxLocLon,
|
|
maxLat: box.maxLocLat,
|
|
minLat: box.minLocLat,
|
|
westLong: box.minLocLon,
|
|
};
|
|
}
|
|
|
|
/** The /similar score at and above which a match is shown by default. */
|
|
export const STRONG_SIMILARITY = 0.85;
|
|
|
|
/**
|
|
* A short word for a /similar score, or "" for a weak match, which shows no
|
|
* label. The cuts follow the server's embedding spec: 0.78 is the search
|
|
* minimum and 0.85 the alert minimum.
|
|
*/
|
|
export function similarityLabel(similarity: number): "strong" | "weak" | "" {
|
|
if (similarity >= STRONG_SIMILARITY) return "strong";
|
|
if (similarity >= 0.81) return "weak";
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* What the matches view says under "No strong matches yet":
|
|
* - `register`: not registered, so no saved profile and no alerts
|
|
* - `alertsOn`: a saved profile and a daily alert, so strong matches arrive
|
|
* - `setUp`: registered, missing the profile save or (on the phone apps) an
|
|
* alert; the Profile page's follow-up card does both
|
|
* - `profileOnly`: a saved profile where the app has no alerts (the web)
|
|
*/
|
|
export type NoStrongFollowUp =
|
|
| "register"
|
|
| "alertsOn"
|
|
| "setUp"
|
|
| "profileOnly";
|
|
|
|
export function noStrongFollowUp(input: {
|
|
isRegistered: boolean;
|
|
profileSaved: boolean;
|
|
alertsOn: boolean;
|
|
offersAlerts: boolean;
|
|
}): NoStrongFollowUp {
|
|
if (!input.isRegistered) return "register";
|
|
if (!input.profileSaved) return "setUp";
|
|
if (input.alertsOn) return "alertsOn";
|
|
return input.offersAlerts ? "setUp" : "profileOnly";
|
|
}
|
|
|
|
/** Whether a match is strong enough to show without expanding. */
|
|
export function isStrongMatch(similarity: number): boolean {
|
|
return similarity >= STRONG_SIMILARITY;
|
|
}
|