feat: add OpenTelemetry observability, Faro frontend monitoring, remove legacy Next.js app
- Add OpenTelemetry SDK with tracing, metrics, and OTLP export for API and worker - Integrate Grafana Faro for frontend real-user monitoring - Instrument health checks, database, Bull queues, and HTTP exception filter - Add Grafana dashboard JSON for service overview - Remove deprecated apps/web-nj (Next.js) — fully replaced by Vite+React frontend - Update nginx config with OTEL collector proxy - Minor UI fixes in schema viewer, category components, and subscription flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafana/faro-web-sdk": "^2.2.4",
|
||||
"@grafana/faro-web-tracing": "^2.2.4",
|
||||
"@remotion/player": "^4.0.422",
|
||||
"@sase/shared": "workspace:*",
|
||||
"@sase/ui": "workspace:*",
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Prefetch schema images for leaf categories without images (sequential to avoid PL24 session conflicts)
|
||||
// Prefetch schema images for leaf categories in batches of 2
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const leafsWithoutImage = currentCategories.filter(
|
||||
@@ -47,34 +47,40 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
if (leafsWithoutImage.length === 0) return;
|
||||
|
||||
for (const c of leafsWithoutImage) prefetchedRef.current.add(c.id);
|
||||
setPrefetchingIds(new Set(leafsWithoutImage.map((c) => c.id)));
|
||||
|
||||
const parentId = currentCategories[0]?.parentId;
|
||||
let didCancel = false;
|
||||
|
||||
// Sequential fetch to avoid PL24 session conflicts
|
||||
const BATCH_SIZE = 2;
|
||||
(async () => {
|
||||
for (const c of leafsWithoutImage) {
|
||||
for (let i = 0; i < leafsWithoutImage.length; i += BATCH_SIZE) {
|
||||
if (didCancel) break;
|
||||
try {
|
||||
await api.get(`/vehicles/${vehicleId}/categories/${c.id}`);
|
||||
} catch {}
|
||||
const batch = leafsWithoutImage.slice(i, i + BATCH_SIZE);
|
||||
setPrefetchingIds(new Set(batch.map((c) => c.id)));
|
||||
|
||||
await Promise.allSettled(
|
||||
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
|
||||
);
|
||||
|
||||
// Refresh after each batch to show images progressively
|
||||
if (!didCancel && parentId) {
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(
|
||||
`/categories/${parentId}/children`,
|
||||
);
|
||||
if (!didCancel && refreshed?.length) {
|
||||
setCurrentCategories((prev) =>
|
||||
prev.map((c) => {
|
||||
const updated = refreshed.find((r) => r.id === c.id);
|
||||
return updated?.schemaImageUrl
|
||||
? { ...c, schemaImageUrl: updated.schemaImageUrl }
|
||||
: c;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (didCancel || !parentId) return;
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(
|
||||
`/categories/${parentId}/children`,
|
||||
);
|
||||
if (didCancel || !refreshed?.length) return;
|
||||
setCurrentCategories((prev) =>
|
||||
prev.map((c) => {
|
||||
const updated = refreshed.find((r) => r.id === c.id);
|
||||
return updated?.schemaImageUrl
|
||||
? { ...c, schemaImageUrl: updated.schemaImageUrl }
|
||||
: c;
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
if (!didCancel) setPrefetchingIds(new Set());
|
||||
})();
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
|
||||
}
|
||||
}, [expanded, fetched, category.id, queryClient]);
|
||||
|
||||
// Prefetch schema images for leaf children when expanded
|
||||
// Prefetch schema images for leaf children in batches of 2 when expanded
|
||||
useEffect(() => {
|
||||
if (!expanded || prefetchedRef.current) return;
|
||||
const leafs = children.filter(
|
||||
@@ -73,21 +73,29 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
|
||||
setPrefetching(true);
|
||||
const parentId = children[0]?.parentId;
|
||||
let cancelled = false;
|
||||
|
||||
const BATCH_SIZE = 2;
|
||||
(async () => {
|
||||
for (const c of leafs) {
|
||||
for (let i = 0; i < leafs.length; i += BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
try { await api.get(`/vehicles/${vehicleId}/categories/${c.id}`); } catch {}
|
||||
}
|
||||
if (cancelled || !parentId) { if (!cancelled) setPrefetching(false); return; }
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!cancelled && refreshed?.length) {
|
||||
setChildren((prev) => prev.map((c) => {
|
||||
const u = refreshed.find((r) => r.id === c.id);
|
||||
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
|
||||
}));
|
||||
const batch = leafs.slice(i, i + BATCH_SIZE);
|
||||
await Promise.allSettled(
|
||||
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
|
||||
);
|
||||
|
||||
// Refresh after each batch to show images progressively
|
||||
if (!cancelled && parentId) {
|
||||
try {
|
||||
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||
if (!cancelled && refreshed?.length) {
|
||||
setChildren((prev) => prev.map((c) => {
|
||||
const u = refreshed.find((r) => r.id === c.id);
|
||||
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!cancelled) setPrefetching(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
@@ -177,15 +178,18 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
|
||||
}
|
||||
|
||||
function handlePayWithCard() {
|
||||
startAction("payment-iyzico", { plan: planKey, period, amount: String(totalAmount) });
|
||||
iyzicoMutation.mutate();
|
||||
}
|
||||
|
||||
function handleEftProceed() {
|
||||
startAction("payment-eft", { plan: planKey, period, amount: String(totalAmount) });
|
||||
eftMutation.mutate();
|
||||
}
|
||||
|
||||
function handleUploadReceipt() {
|
||||
if (uploadedFile) {
|
||||
startAction("receipt-upload", { paymentId: eftPaymentId || "" });
|
||||
uploadMutation.mutate(uploadedFile);
|
||||
}
|
||||
}
|
||||
@@ -334,11 +338,11 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
|
||||
onValueChange={(v) => setPaymentMethod(v as "iyzico" | "eft")}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="iyzico" className="flex-1">
|
||||
<TabsTrigger value="iyzico" data-faro-user-action-name="payment-tab-card" className="flex-1">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("payment.creditCard")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="eft" className="flex-1">
|
||||
<TabsTrigger value="eft" data-faro-user-action-name="payment-tab-eft" className="flex-1">
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{t("payment.eftTransfer")}
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function HotspotOverlay({
|
||||
: hotspot.coordinates[1] - 4;
|
||||
|
||||
return (
|
||||
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
|
||||
<g key={hotspot.id} style={{ pointerEvents: "auto" }} data-faro-user-action-name="hotspot-click">
|
||||
<HotspotShape
|
||||
hotspot={hotspot}
|
||||
isHighlighted={isHighlighted}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function PartsPanel({ parts }: PartsPanelProps) {
|
||||
return (
|
||||
<tr
|
||||
key={part.id}
|
||||
data-faro-user-action-name="select-part"
|
||||
ref={(el) => {
|
||||
// Store ref for the first part in each group (for scroll-to)
|
||||
if (group != null && el && !rowRefs.current.has(group)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export function SchemaToolbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-faro-user-action-name="schema-zoom-out"
|
||||
onClick={() => setZoom(zoom - 0.25)}
|
||||
disabled={zoom <= 0.5}
|
||||
title="Uzaklaştır"
|
||||
@@ -25,6 +26,7 @@ export function SchemaToolbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-faro-user-action-name="schema-zoom-in"
|
||||
onClick={() => setZoom(zoom + 0.25)}
|
||||
disabled={zoom >= 5}
|
||||
title="Yakınlaştır"
|
||||
@@ -37,6 +39,7 @@ export function SchemaToolbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-faro-user-action-name="schema-reset"
|
||||
onClick={resetView}
|
||||
title="Görünümü sıfırla"
|
||||
>
|
||||
@@ -46,6 +49,7 @@ export function SchemaToolbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-faro-user-action-name="schema-fullscreen"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Tam ekrandan çık" : "Tam ekran"}
|
||||
>
|
||||
|
||||
@@ -57,11 +57,11 @@ export function SchemaViewer({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[600px] gap-4 rounded-lg border border-border">
|
||||
<div className="flex w-[60%] items-center justify-center">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
|
||||
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
<div className="w-[40%] space-y-3 p-4">
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
@@ -75,12 +75,12 @@ export function SchemaViewer({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"flex rounded-lg border border-border bg-background",
|
||||
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "h-[700px]",
|
||||
"flex flex-col rounded-lg border border-border bg-background md:flex-row",
|
||||
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "md:h-[700px]",
|
||||
)}
|
||||
>
|
||||
{/* Left side: Schema image + hotspot overlay (60%) */}
|
||||
<div className="relative flex w-[60%] flex-col border-r border-border">
|
||||
<div className="relative flex h-[400px] flex-col border-b border-border md:h-auto md:w-[60%] md:border-b-0 md:border-r">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute left-3 top-3 z-20">
|
||||
<SchemaToolbar />
|
||||
@@ -134,7 +134,7 @@ export function SchemaViewer({
|
||||
</div>
|
||||
|
||||
{/* Right side: Parts panel (40%) */}
|
||||
<div className="w-[40%]">
|
||||
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
|
||||
<PartsPanel parts={parts} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getFaro } from "./faro";
|
||||
|
||||
const API_URL = "/api";
|
||||
|
||||
type RequestOptions = {
|
||||
@@ -29,11 +31,15 @@ class ApiClient {
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
const error = new ApiError(
|
||||
data?.error?.message || "İstek başarısız",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
getFaro()?.api.pushError(error, {
|
||||
context: { method, path, statusCode: String(res.status) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.data !== undefined ? data.data : data;
|
||||
@@ -65,11 +71,15 @@ class ApiClient {
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
const error = new ApiError(
|
||||
data?.error?.message || "Yükleme başarısız",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
getFaro()?.api.pushError(error, {
|
||||
context: { method: "POST", path, statusCode: String(res.status) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.data !== undefined ? data.data : data;
|
||||
|
||||
59
apps/web/src/lib/faro.ts
Normal file
59
apps/web/src/lib/faro.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Faro } from "@grafana/faro-web-sdk";
|
||||
|
||||
let faro: Faro | null = null;
|
||||
|
||||
export async function initFaro() {
|
||||
if (import.meta.env.VITE_FARO_ENABLED !== "true") return;
|
||||
|
||||
const collectorUrl = import.meta.env.VITE_FARO_COLLECTOR_URL;
|
||||
if (!collectorUrl) {
|
||||
console.warn("[faro] VITE_FARO_COLLECTOR_URL is required when Faro is enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { initializeFaro, getWebInstrumentations } = await import("@grafana/faro-web-sdk");
|
||||
const { TracingInstrumentation } = await import("@grafana/faro-web-tracing");
|
||||
|
||||
faro = initializeFaro({
|
||||
url: collectorUrl,
|
||||
app: {
|
||||
name: "saseweb",
|
||||
version: "2.0.0",
|
||||
environment: import.meta.env.MODE,
|
||||
},
|
||||
instrumentations: [
|
||||
...getWebInstrumentations({ captureConsole: false }),
|
||||
new TracingInstrumentation({
|
||||
instrumentationOptions: {
|
||||
propagateTraceHeaderCorsUrls: [/\/api\//],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
console.log("[faro] Frontend observability initialized");
|
||||
} catch (err) {
|
||||
console.warn("[faro] Initialization failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function getFaro() {
|
||||
return faro;
|
||||
}
|
||||
|
||||
/** Start a programmatic user action (auto-completes 100ms after last linked event) */
|
||||
export function startAction(
|
||||
name: string,
|
||||
attributes?: Record<string, string>,
|
||||
) {
|
||||
faro?.api.startUserAction(name, attributes);
|
||||
}
|
||||
|
||||
/** Push a Faro event (for non-action tracking like page-level events) */
|
||||
export function pushEvent(
|
||||
name: string,
|
||||
attributes?: Record<string, string>,
|
||||
) {
|
||||
faro?.api.pushEvent(name, attributes);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initFaro } from "./lib/faro";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
@@ -5,6 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./globals.css";
|
||||
|
||||
// Initialize frontend observability (async, non-blocking)
|
||||
initFaro();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export const Route = createFileRoute("/_auth/login")({
|
||||
@@ -18,6 +19,7 @@ function LoginPage() {
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
startAction("login", { method: "email" });
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -104,7 +106,10 @@ function LoginPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/search" })}
|
||||
onClick={() => {
|
||||
startAction("login", { method: "google" });
|
||||
signIn.social({ provider: "google", callbackURL: "/dashboard/search" });
|
||||
}}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signIn, signUp } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
@@ -19,6 +20,7 @@ function RegisterPage() {
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
startAction("register", { method: "email" });
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -107,7 +109,10 @@ function RegisterPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" })}
|
||||
onClick={() => {
|
||||
startAction("register", { method: "google" });
|
||||
signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" });
|
||||
}}
|
||||
>
|
||||
Google ile Kayıt Ol
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
@@ -125,6 +126,7 @@ function SearchPage() {
|
||||
setError(null);
|
||||
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
startAction("vin-decode", { vin: cleanVin });
|
||||
if (!isValidVin(cleanVin)) {
|
||||
setError(
|
||||
"Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||
@@ -234,6 +236,7 @@ function SearchPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillExampleVin}
|
||||
data-faro-user-action-name="fill-example-vin"
|
||||
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||
>
|
||||
Örnek şase deneyin →
|
||||
@@ -348,6 +351,7 @@ function SearchPage() {
|
||||
setVin(v.vin);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
data-faro-user-action-name="select-history-vin"
|
||||
className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
@@ -232,6 +233,7 @@ function SubscriptionPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
startAction("proceed-to-payment", { plan: selectedPlanKey, period: billingPeriod });
|
||||
navigate({
|
||||
to: "/dashboard/subscription/pay",
|
||||
search: {
|
||||
@@ -469,7 +471,10 @@ function SubscriptionPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
onClick={() => {
|
||||
startAction("subscription-cancel");
|
||||
cancelMutation.mutate();
|
||||
}}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
{cancelMutation.isPending
|
||||
@@ -518,7 +523,10 @@ function SubscriptionPage() {
|
||||
</ul>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => trialMutation.mutate()}
|
||||
onClick={() => {
|
||||
startAction("trial-start");
|
||||
trialMutation.mutate();
|
||||
}}
|
||||
disabled={trialMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
@@ -549,6 +557,7 @@ function SubscriptionPage() {
|
||||
<Button
|
||||
variant={billingPeriod === "monthly" ? "default" : "outline"}
|
||||
size="sm"
|
||||
data-faro-user-action-name="billing-monthly"
|
||||
onClick={() => setBillingPeriod("monthly")}
|
||||
>
|
||||
{t("common.monthly")}
|
||||
@@ -556,6 +565,7 @@ function SubscriptionPage() {
|
||||
<Button
|
||||
variant={billingPeriod === "yearly" ? "default" : "outline"}
|
||||
size="sm"
|
||||
data-faro-user-action-name="billing-yearly"
|
||||
onClick={() => setBillingPeriod("yearly")}
|
||||
>
|
||||
{t("common.yearly")}
|
||||
@@ -575,6 +585,7 @@ function SubscriptionPage() {
|
||||
return (
|
||||
<Card
|
||||
key={plan.key}
|
||||
data-faro-user-action-name={`select-plan-${plan.key}`}
|
||||
className={`relative cursor-pointer transition-all hover:shadow-md ${
|
||||
isSelected ? "border-primary ring-2 ring-primary/20" : ""
|
||||
} ${isCurrentPlan ? "border-green-500/50 bg-green-50/50 dark:bg-green-950/10" : ""} ${
|
||||
|
||||
@@ -13,11 +13,11 @@ const SchemaViewer = lazy(() =>
|
||||
|
||||
function SchemaViewerFallback() {
|
||||
return (
|
||||
<div className="flex h-[600px] gap-4 rounded-lg border border-border">
|
||||
<div className="flex w-[60%] items-center justify-center">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
|
||||
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
<div className="w-[40%] space-y-3 p-4">
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
|
||||
|
||||
@@ -452,13 +452,14 @@ function HomePage() {
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-faro-user-action-name="hero-login"
|
||||
className="rounded-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Giriş Yap
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
<Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -594,6 +595,7 @@ function HomePage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleVinSearch}
|
||||
data-faro-user-action-name="hero-vin-search"
|
||||
className="h-12 rounded-full bg-foreground px-8 text-sm font-semibold text-background hover:bg-foreground/90 sm:h-14 sm:text-base"
|
||||
>
|
||||
Ara
|
||||
@@ -664,6 +666,7 @@ function HomePage() {
|
||||
Şase numaranız yok mu?{" "}
|
||||
<button
|
||||
onClick={fillExampleVin}
|
||||
data-faro-user-action-name="hero-free-trial"
|
||||
className="text-foreground underline underline-offset-4 transition hover:text-foreground/80"
|
||||
>
|
||||
Örnek aramayı deneyin →
|
||||
|
||||
Reference in New Issue
Block a user