feat(analytics): enrich PostHog VIN/parts/subscription events (SASE-PH-001 Faz 0/1 additive subset)

Per sase-posthog-events-prd.md, additive-only pass (no renames, no dead-feature events):

- search.tsx: add search_input_focused, search_input_validation_failed,
  search_paste_detected, search_history_item_selected; track query_source
  (manual|paste|history) and propagate to vin_decoded/success/error; add
  response_time_ms, source, status_code on success/error; add selected_index,
  candidates_count, time_to_select_ms to vin_decode_candidate_selected.
- parts-panel.tsx: add parts_panel_viewed with vehicle_id, category_id,
  parts_count, has_prices, available_groups_count (one-shot per mount).
- subscription/index.tsx: enrich plan_selected (period, from_plan, status),
  checkout_started (from_plan, brands_count, trial_available, status),
  subscription_cancelled (from_plan, billing_period, status_before_cancel,
  days_since_start, had_downgrade_offer).
This commit is contained in:
Sase Dev
2026-05-13 22:05:50 +00:00
parent 5b9af5a6a5
commit 0b6d4bb0a7
3 changed files with 114 additions and 7 deletions

View File

@@ -16,10 +16,26 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const hasPrices = parts.some((p) => p.price != null);
useEffect(() => {
const key = `${vehicleId ?? ""}|${categoryId ?? ""}`;
if (viewedKeyRef.current === key) return;
viewedKeyRef.current = key;
capture("parts_panel_viewed", {
vehicle_id: vehicleId,
category_id: categoryId,
parts_count: parts.length,
has_prices: hasPrices,
available_groups_count: new Set(
parts.filter((p) => p.hotspotIndex != null && !p.unavailable).map((p) => p.hotspotIndex),
).size,
});
}, [vehicleId, categoryId, parts.length, hasPrices, parts]);
// Map group → IDs of available (non-unavailable) parts
const availableByGroup = useMemo(() => {
const map = new Map<number, string[]>();

View File

@@ -47,6 +47,9 @@ export const Route = createFileRoute("/dashboard/search")({
function SearchPage() {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const focusFiredRef = useRef(false);
const querySourceRef = useRef<"manual" | "paste" | "history">("manual");
const candidatesShownAtRef = useRef<number | null>(null);
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -138,40 +141,64 @@ function SearchPage() {
setError(null);
const cleanVin = vin.toUpperCase().trim();
const querySource = querySourceRef.current;
startAction("vin-decode", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin, query_source: querySource });
if (!isValidVin(cleanVin)) {
capture("search_input_validation_failed", {
field: "vin",
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
input_length: cleanVin.length,
});
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
setLoading(true);
const decodeStart = performance.now();
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
const responseTimeMs = Math.round(performance.now() - decodeStart);
// Handle multiple vehicle candidates (PartsCatalogs or EMEX)
if (data.candidates && Array.isArray(data.candidates)) {
setCandidates(data.candidates);
setCandidateVin(cleanVin);
setCandidateSource(data.source ?? "parts-catalogs");
candidatesShownAtRef.current = performance.now();
capture("vin_decode_candidates", {
vin: cleanVin,
count: data.candidates.length,
source: data.source,
response_time_ms: responseTimeMs,
query_source: querySource,
});
setLoading(false);
return;
}
capture("vin_decode_success", { vin: cleanVin, vehicle_id: data.id });
capture("vin_decode_success", {
vin: cleanVin,
vehicle_id: data.id,
response_time_ms: responseTimeMs,
source: data.source ?? null,
query_source: querySource,
});
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
});
} catch (err) {
const responseTimeMs = Math.round(performance.now() - decodeStart);
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
capture("vin_decode_error", { vin: cleanVin, error: message });
capture("vin_decode_error", {
vin: cleanVin,
error: message,
response_time_ms: responseTimeMs,
status_code: err instanceof ApiError ? err.status : null,
query_source: querySource,
});
if (err instanceof ApiError) {
setError(err.message);
} else {
@@ -214,6 +241,13 @@ function SearchPage() {
async function handleCandidateSelect(carId: string) {
setSelectLoading(true);
const selectedIndex =
candidates?.findIndex(
(c) => String(c.id ?? c.carId ?? c.carIndex ?? c.index) === String(carId),
) ?? -1;
const timeToSelectMs = candidatesShownAtRef.current
? Math.round(performance.now() - candidatesShownAtRef.current)
: null;
try {
const payload: Record<string, unknown> = { vin: candidateVin };
if (candidateSource === "emex") {
@@ -227,7 +261,11 @@ function SearchPage() {
source: candidateSource,
carId,
vehicle_id: data.id,
selected_index: selectedIndex,
candidates_count: candidates?.length ?? null,
time_to_select_ms: timeToSelectMs,
});
candidatesShownAtRef.current = null;
setCandidates(null);
setCandidateSource(null);
navigate({
@@ -280,6 +318,25 @@ function SearchPage() {
placeholder="Şase numarasını girin (17 karakter)"
value={vin}
onChange={(e) => handleVinChange(e.target.value)}
onFocus={() => {
if (focusFiredRef.current) return;
focusFiredRef.current = true;
capture("search_input_focused", { field: "vin", empty: vin.length === 0 });
}}
onPaste={(e) => {
const pasted = e.clipboardData?.getData("text") ?? "";
if (!pasted) return;
querySourceRef.current = "paste";
capture("search_paste_detected", {
field: "vin",
length: pasted.length,
source_hint: /\s/.test(pasted)
? pasted.includes("\n")
? "multiline"
: "with_spaces"
: "clean",
});
}}
maxLength={17}
className={`h-14 rounded-xl bg-muted/50 pl-12 font-mono tracking-wider ${vin.length === 0 ? "pr-24" : "pr-4"}`}
/>
@@ -441,11 +498,21 @@ function SearchPage() {
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{history.slice(0, 6).map((v: any) => (
{history.slice(0, 6).map((v: any, idx: number) => (
<button
key={v.id}
type="button"
onClick={() => {
querySourceRef.current = "history";
const ageDays = v.lastAccessedAt
? Math.floor(
(Date.now() - new Date(v.lastAccessedAt).getTime()) / (1000 * 60 * 60 * 24),
)
: null;
capture("search_history_item_selected", {
position: idx,
age_days: ageDays,
});
setVin(v.vin);
inputRef.current?.focus();
}}

View File

@@ -323,7 +323,12 @@ export function SubscriptionPage() {
}, [onboardingPhase]);
function handleSelectPlan(planKey: string) {
capture("plan_selected", { plan: planKey });
capture("plan_selected", {
plan: planKey,
period: billingPeriod,
from_plan: currentPlanKey,
subscription_status: subscription?.status ?? "none",
});
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
}
@@ -341,7 +346,14 @@ export function SubscriptionPage() {
}
startAction("proceed-to-payment", { plan: selectedPlanKey, period: billingPeriod });
capture("checkout_started", { plan: selectedPlanKey, period: billingPeriod });
capture("checkout_started", {
plan: selectedPlanKey,
period: billingPeriod,
from_plan: currentPlanKey,
brands_count: selectedBrandIds.length,
trial_available: eligibleForTrial,
subscription_status: subscription?.status ?? "none",
});
navigate({
to: "/dashboard/subscription/pay",
search: {
@@ -894,7 +906,19 @@ export function SubscriptionPage() {
variant="destructive"
onClick={() => {
startAction("subscription-cancel");
capture("subscription_cancelled");
const daysSinceStart = subscription?.startDate
? Math.floor(
(Date.now() - new Date(subscription.startDate).getTime()) /
(1000 * 60 * 60 * 24),
)
: null;
capture("subscription_cancelled", {
from_plan: currentPlanKey,
billing_period: subscription?.billingPeriod ?? null,
status_before_cancel: subscription?.status ?? null,
days_since_start: daysSinceStart,
had_downgrade_offer: downgradePlan !== null,
});
cancelMutation.mutate();
}}
disabled={cancelMutation.isPending}