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:
@@ -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[]>();
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user