feat: landing page CRO revision, demo page, auto-trial, multi-catalog messaging
- Rewrite landing page copy with Jobs-to-be-Done and loss framing - Add VIN live preview via backend pl24+emex decode chain - Add comparison table (mobile-responsive), stats, pricing teaser, referral banner - Replace testimonials with aggregate social proof stats - Create demo page with 3-step VIN decode flow for unregistered users - Add public /vehicles/preview/:vin endpoint (no auth required) - Auto-create 7-day trial subscription on user registration - Highlight multi-catalog cross-querying advantage in OEM feature and comparison - Update register page with trial messaging and VIN param support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { eq, asc } from "drizzle-orm";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "../database/schema/core";
|
import * as schema from "../database/schema/core";
|
||||||
|
|
||||||
@@ -56,6 +57,40 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string)
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
try {
|
||||||
|
// Find the lowest-tier plan for trial
|
||||||
|
const [plan] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.plans)
|
||||||
|
.where(eq(schema.plans.isActive, true))
|
||||||
|
.orderBy(asc(schema.plans.priceMonthly))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endDate = new Date(now);
|
||||||
|
endDate.setDate(endDate.getDate() + 7);
|
||||||
|
|
||||||
|
await db.insert(schema.userSubscriptions).values({
|
||||||
|
userId: user.id,
|
||||||
|
planId: plan.id,
|
||||||
|
status: "trial",
|
||||||
|
billingPeriod: "monthly",
|
||||||
|
startDate: now,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create trial subscription:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||||
"http://localhost:4000",
|
"http://localhost:4000",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc, or, inArray } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, Database } from "../database/database.provider";
|
||||||
import { userSubscriptions, userBrands, plans, brands } from "../database/schema/core";
|
import { userSubscriptions, userBrands, plans, brands } from "../database/schema/core";
|
||||||
|
|
||||||
@@ -25,6 +25,12 @@ export class SubscriptionsService {
|
|||||||
throw new ConflictException("Already have an active subscription");
|
throw new ConflictException("Already have an active subscription");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expire any existing trial subscription
|
||||||
|
await this.db
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ status: "expired", updatedAt: new Date() })
|
||||||
|
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "trial")));
|
||||||
|
|
||||||
// Validate plan
|
// Validate plan
|
||||||
const plan = await this.db.select().from(plans).where(eq(plans.id, data.planId)).limit(1);
|
const plan = await this.db.select().from(plans).where(eq(plans.id, data.planId)).limit(1);
|
||||||
if (plan.length === 0) throw new NotFoundException("Plan not found");
|
if (plan.length === 0) throw new NotFoundException("Plan not found");
|
||||||
@@ -198,7 +204,12 @@ export class SubscriptionsService {
|
|||||||
const [sub] = await this.db
|
const [sub] = await this.db
|
||||||
.select()
|
.select()
|
||||||
.from(userSubscriptions)
|
.from(userSubscriptions)
|
||||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "active")))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSubscriptions.userId, userId),
|
||||||
|
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!sub || !sub.endDate) return;
|
if (!sub || !sub.endDate) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { VehiclesService } from "./vehicles.service";
|
|||||||
import { CategoriesService } from "../categories/categories.service";
|
import { CategoriesService } from "../categories/categories.service";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
|
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
|
||||||
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
|
|
||||||
@Controller("vehicles")
|
@Controller("vehicles")
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
@@ -11,6 +12,12 @@ export class VehiclesController {
|
|||||||
private categoriesService: CategoriesService,
|
private categoriesService: CategoriesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get("preview/:vin")
|
||||||
|
async preview(@Param("vin", VinValidationPipe) vin: string) {
|
||||||
|
return this.vehiclesService.previewVin(vin);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("decode")
|
@Post("decode")
|
||||||
async decode(
|
async decode(
|
||||||
@CurrentUser("id") userId: string,
|
@CurrentUser("id") userId: string,
|
||||||
|
|||||||
@@ -176,6 +176,59 @@ export class VehiclesService {
|
|||||||
return savedVehicle;
|
return savedVehicle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public VIN preview — no auth, no DB save, no brand access check.
|
||||||
|
* Uses Corgi → PL24 → EMEX decode chain, returns basic vehicle info.
|
||||||
|
*/
|
||||||
|
async previewVin(vin: string) {
|
||||||
|
if (!isValidVin(vin)) {
|
||||||
|
throw new BadRequestException("Invalid VIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Corgi decode (offline)
|
||||||
|
const corgiResult = this.corgiService.decodeVin(vin);
|
||||||
|
let brandName = corgiResult?.isKnown ? corgiResult.brandName : null;
|
||||||
|
|
||||||
|
// 2. PL24 decode
|
||||||
|
let pl24Vehicle: any = null;
|
||||||
|
if (this.pl24Service.isSupported(vin)) {
|
||||||
|
try {
|
||||||
|
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||||
|
if (!brandName && pl24Vehicle) {
|
||||||
|
brandName = this.pl24Service.getBrandName(vin) || null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`PL24 preview failed for ${vin}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. EMEX fallback
|
||||||
|
let emexVehicle: import('../integrations/emex/emex.types').DecodedVehicle | null = null;
|
||||||
|
if (!pl24Vehicle) {
|
||||||
|
try {
|
||||||
|
const emexResult = await this.emexService.decodeVin(vin);
|
||||||
|
if (emexResult && emexResult.brand !== 'UNKNOWN') {
|
||||||
|
emexVehicle = emexResult;
|
||||||
|
if (!brandName) brandName = emexResult.brand || null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`EMEX preview failed for ${vin}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pl24Vehicle && !emexVehicle && !corgiResult?.isKnown) {
|
||||||
|
throw new BadRequestException("VIN not recognized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
|
||||||
|
model: pl24Vehicle?.model || emexVehicle?.model || null,
|
||||||
|
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || null,
|
||||||
|
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || null,
|
||||||
|
source: pl24Vehicle ? "pl24" : emexVehicle ? "emex" : "corgi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getHistory(userId: string, page = 1, limit = 20) {
|
async getHistory(userId: string, page = 1, limit = 20) {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
return this.db
|
return this.db
|
||||||
|
|||||||
@@ -11,11 +11,27 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
// Apply theme before React renders to prevent FOUC
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var s = JSON.parse(localStorage.getItem("userSettings") || "{}");
|
||||||
|
var t = s.theme || "dark";
|
||||||
|
var dark =
|
||||||
|
t === "dark" ||
|
||||||
|
(t === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
if (dark) document.documentElement.classList.add("dark");
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
--color-accent-foreground: #171717;
|
--color-accent-foreground: #171717;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-surface: #f5f5f5;
|
||||||
|
--color-surface-foreground: #171717;
|
||||||
|
--color-surface-alt: #eaeaea;
|
||||||
--color-card: #ffffff;
|
--color-card: #ffffff;
|
||||||
--color-card-foreground: #0a0a0a;
|
--color-card-foreground: #0a0a0a;
|
||||||
--color-popover: #ffffff;
|
--color-popover: #ffffff;
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.5rem;
|
||||||
--radius-xl: 0.75rem;
|
--radius-xl: 0.75rem;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -42,6 +46,27 @@
|
|||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes scroll-left {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scroll-left { animation: scroll-left 30s linear infinite; }
|
||||||
|
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||||
|
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
|
||||||
|
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||||
|
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--color-background: #0a0a0a;
|
--color-background: #0a0a0a;
|
||||||
--color-foreground: #fafafa;
|
--color-foreground: #fafafa;
|
||||||
@@ -58,6 +83,9 @@
|
|||||||
--color-accent-foreground: #fafafa;
|
--color-accent-foreground: #fafafa;
|
||||||
--color-destructive: #dc2626;
|
--color-destructive: #dc2626;
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-surface: #1a1a1a;
|
||||||
|
--color-surface-foreground: #fafafa;
|
||||||
|
--color-surface-alt: #0f0f0f;
|
||||||
--color-card: #0a0a0a;
|
--color-card: #0a0a0a;
|
||||||
--color-card-foreground: #fafafa;
|
--color-card-foreground: #fafafa;
|
||||||
--color-popover: #0a0a0a;
|
--color-popover: #0a0a0a;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const STORAGE_KEY = "userSettings";
|
|||||||
interface UserSettings {
|
interface UserSettings {
|
||||||
categoryViewMode?: "grid" | "tree";
|
categoryViewMode?: "grid" | "tree";
|
||||||
sidebarCollapsed?: boolean;
|
sidebarCollapsed?: boolean;
|
||||||
|
theme?: "light" | "dark" | "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: UserSettings = {
|
const defaults: UserSettings = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from "./routes/__root"
|
import { Route as rootRouteImport } from "./routes/__root"
|
||||||
import { Route as PricingRouteImport } from "./routes/pricing"
|
import { Route as PricingRouteImport } from "./routes/pricing"
|
||||||
|
import { Route as DemoRouteImport } from "./routes/demo"
|
||||||
import { Route as DashboardRouteImport } from "./routes/dashboard"
|
import { Route as DashboardRouteImport } from "./routes/dashboard"
|
||||||
import { Route as AuthRouteImport } from "./routes/_auth"
|
import { Route as AuthRouteImport } from "./routes/_auth"
|
||||||
import { Route as IndexRouteImport } from "./routes/index"
|
import { Route as IndexRouteImport } from "./routes/index"
|
||||||
@@ -36,6 +37,11 @@ const PricingRoute = PricingRouteImport.update({
|
|||||||
path: "/pricing",
|
path: "/pricing",
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DemoRoute = DemoRouteImport.update({
|
||||||
|
id: "/demo",
|
||||||
|
path: "/demo",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DashboardRoute = DashboardRouteImport.update({
|
const DashboardRoute = DashboardRouteImport.update({
|
||||||
id: "/dashboard",
|
id: "/dashboard",
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
@@ -143,6 +149,7 @@ const DashboardVehiclesIdCategoriesCategoryIdRoute =
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/dashboard": typeof DashboardRouteWithChildren
|
"/dashboard": typeof DashboardRouteWithChildren
|
||||||
|
"/demo": typeof DemoRoute
|
||||||
"/pricing": typeof PricingRoute
|
"/pricing": typeof PricingRoute
|
||||||
"/forgot-password": typeof AuthForgotPasswordRoute
|
"/forgot-password": typeof AuthForgotPasswordRoute
|
||||||
"/login": typeof AuthLoginRoute
|
"/login": typeof AuthLoginRoute
|
||||||
@@ -165,6 +172,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/dashboard": typeof DashboardRouteWithChildren
|
"/dashboard": typeof DashboardRouteWithChildren
|
||||||
|
"/demo": typeof DemoRoute
|
||||||
"/pricing": typeof PricingRoute
|
"/pricing": typeof PricingRoute
|
||||||
"/forgot-password": typeof AuthForgotPasswordRoute
|
"/forgot-password": typeof AuthForgotPasswordRoute
|
||||||
"/login": typeof AuthLoginRoute
|
"/login": typeof AuthLoginRoute
|
||||||
@@ -189,6 +197,7 @@ export interface FileRoutesById {
|
|||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/_auth": typeof AuthRouteWithChildren
|
"/_auth": typeof AuthRouteWithChildren
|
||||||
"/dashboard": typeof DashboardRouteWithChildren
|
"/dashboard": typeof DashboardRouteWithChildren
|
||||||
|
"/demo": typeof DemoRoute
|
||||||
"/pricing": typeof PricingRoute
|
"/pricing": typeof PricingRoute
|
||||||
"/_auth/forgot-password": typeof AuthForgotPasswordRoute
|
"/_auth/forgot-password": typeof AuthForgotPasswordRoute
|
||||||
"/_auth/login": typeof AuthLoginRoute
|
"/_auth/login": typeof AuthLoginRoute
|
||||||
@@ -213,6 +222,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| "/"
|
| "/"
|
||||||
| "/dashboard"
|
| "/dashboard"
|
||||||
|
| "/demo"
|
||||||
| "/pricing"
|
| "/pricing"
|
||||||
| "/forgot-password"
|
| "/forgot-password"
|
||||||
| "/login"
|
| "/login"
|
||||||
@@ -235,6 +245,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| "/"
|
| "/"
|
||||||
| "/dashboard"
|
| "/dashboard"
|
||||||
|
| "/demo"
|
||||||
| "/pricing"
|
| "/pricing"
|
||||||
| "/forgot-password"
|
| "/forgot-password"
|
||||||
| "/login"
|
| "/login"
|
||||||
@@ -258,6 +269,7 @@ export interface FileRouteTypes {
|
|||||||
| "/"
|
| "/"
|
||||||
| "/_auth"
|
| "/_auth"
|
||||||
| "/dashboard"
|
| "/dashboard"
|
||||||
|
| "/demo"
|
||||||
| "/pricing"
|
| "/pricing"
|
||||||
| "/_auth/forgot-password"
|
| "/_auth/forgot-password"
|
||||||
| "/_auth/login"
|
| "/_auth/login"
|
||||||
@@ -282,6 +294,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
DashboardRoute: typeof DashboardRouteWithChildren
|
DashboardRoute: typeof DashboardRouteWithChildren
|
||||||
|
DemoRoute: typeof DemoRoute
|
||||||
PricingRoute: typeof PricingRoute
|
PricingRoute: typeof PricingRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +307,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof PricingRouteImport
|
preLoaderRoute: typeof PricingRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
"/demo": {
|
||||||
|
id: "/demo"
|
||||||
|
path: "/demo"
|
||||||
|
fullPath: "/demo"
|
||||||
|
preLoaderRoute: typeof DemoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
"/dashboard": {
|
"/dashboard": {
|
||||||
id: "/dashboard"
|
id: "/dashboard"
|
||||||
path: "/dashboard"
|
path: "/dashboard"
|
||||||
@@ -494,6 +514,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
DashboardRoute: DashboardRouteWithChildren,
|
DashboardRoute: DashboardRouteWithChildren,
|
||||||
|
DemoRoute: DemoRoute,
|
||||||
PricingRoute: PricingRoute,
|
PricingRoute: PricingRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getUserSettings } from "@/lib/user-settings";
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
@@ -10,7 +12,27 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyTheme(theme: "light" | "dark" | "system") {
|
||||||
|
const isDark =
|
||||||
|
theme === "dark" ||
|
||||||
|
(theme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", isDark);
|
||||||
|
}
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
useEffect(() => {
|
||||||
|
const theme = getUserSettings().theme ?? "dark";
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handler = () => applyTheme("system");
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase
|
|||||||
import { Label } from "@sase/ui";
|
import { Label } from "@sase/ui";
|
||||||
import { signUp } from "@/lib/auth-client";
|
import { signUp } from "@/lib/auth-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/register")({
|
export const Route = createFileRoute("/_auth/register")({
|
||||||
component: RegisterPage,
|
component: RegisterPage,
|
||||||
@@ -13,6 +14,7 @@ export const Route = createFileRoute("/_auth/register")({
|
|||||||
|
|
||||||
function RegisterPage() {
|
function RegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const vin = new URLSearchParams(window.location.search).get("vin");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -25,7 +27,11 @@ function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
await signUp.email({ name, email, password });
|
await signUp.email({ name, email, password });
|
||||||
toast.success("Hesap oluşturuldu!");
|
toast.success("Hesap oluşturuldu!");
|
||||||
navigate({ to: "/dashboard/search" });
|
if (vin) {
|
||||||
|
navigate({ to: "/dashboard/search" });
|
||||||
|
} else {
|
||||||
|
navigate({ to: "/dashboard/search" });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Kayıt başarısız. Bu e-posta zaten kullanılıyor olabilir.");
|
toast.error("Kayıt başarısız. Bu e-posta zaten kullanılıyor olabilir.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,6 +45,12 @@ function RegisterPage() {
|
|||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl">Kayıt Ol</CardTitle>
|
<CardTitle className="text-2xl">Kayıt Ol</CardTitle>
|
||||||
<CardDescription>Yeni bir Sase.tr hesabı oluşturun</CardDescription>
|
<CardDescription>Yeni bir Sase.tr hesabı oluşturun</CardDescription>
|
||||||
|
|
||||||
|
{/* Trial messaging */}
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
<ShieldCheck className="size-4 shrink-0" />
|
||||||
|
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -81,6 +93,10 @@ function RegisterPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-3 text-center text-xs text-muted-foreground">
|
||||||
|
Kayıt olunca hemen VIN aramaya başlayın
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Zaten hesabınız var mı?{" "}
|
Zaten hesabınız var mı?{" "}
|
||||||
<Link to="/login" className="font-medium hover:underline">
|
<Link to="/login" className="font-medium hover:underline">
|
||||||
|
|||||||
392
apps/web/src/routes/demo.tsx
Normal file
392
apps/web/src/routes/demo.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { Button, Input } from "@sase/ui";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Car,
|
||||||
|
ArrowRight,
|
||||||
|
Lock,
|
||||||
|
Loader2,
|
||||||
|
FolderTree,
|
||||||
|
MousePointerClick,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/demo")({
|
||||||
|
component: DemoPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXAMPLE_CATEGORIES = [
|
||||||
|
{ name: "Motor", subcategories: ["Silindir Kapağı", "Krank Mili", "Piston", "Yağ Pompası"] },
|
||||||
|
{ name: "Şasi & Süspansiyon", subcategories: ["Amortisör", "Salıncak", "Rotil", "Viraj Demiri"] },
|
||||||
|
{ name: "Elektrik", subcategories: ["Alternatör", "Marş Motoru", "Kablo Tesisatı", "Sensörler"] },
|
||||||
|
{ name: "Karoseri", subcategories: ["Kapı Paneli", "Tampon", "Ayna", "Far"] },
|
||||||
|
{ name: "Klima & Isıtma", subcategories: ["Kompresör", "Kalorifer", "Radyatör", "Fan Motoru"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXAMPLE_SCHEMA_PARTS = [
|
||||||
|
{ code: "1J0 820 803F", name: "Klima Kompresörü", position: "A1" },
|
||||||
|
{ code: "1J0 819 031A", name: "Kalorifer Motoru", position: "B3" },
|
||||||
|
{ code: "1J0 698 151G", name: "Ön Fren Balatası", position: "C2" },
|
||||||
|
{ code: "1J0 407 271J", name: "Alt Salıncak", position: "D1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DemoPage() {
|
||||||
|
const [vin, setVin] = useState("");
|
||||||
|
const [vinPreview, setVinPreview] = useState<{
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
year: string;
|
||||||
|
engine: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [vinLoading, setVinLoading] = useState(false);
|
||||||
|
const [vinError, setVinError] = useState(false);
|
||||||
|
const [step, setStep] = useState<"vin" | "categories" | "schema">("vin");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [isDark, setIsDark] = useState(() => {
|
||||||
|
const theme = getUserSettings().theme ?? "dark";
|
||||||
|
if (theme === "system") {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
return theme === "dark";
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = isDark ? "light" : "dark";
|
||||||
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
|
setUserSetting("theme", next);
|
||||||
|
setIsDark(next === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
// NHTSA VIN decode
|
||||||
|
useEffect(() => {
|
||||||
|
if (vin.length !== 17) {
|
||||||
|
setVinPreview(null);
|
||||||
|
setVinError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setVinLoading(true);
|
||||||
|
setVinError(false);
|
||||||
|
|
||||||
|
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Not found");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
const r = data.data !== undefined ? data.data : data;
|
||||||
|
if (r?.brandName) {
|
||||||
|
setVinPreview({
|
||||||
|
make: r.brandName,
|
||||||
|
model: r.model || "—",
|
||||||
|
year: r.year ? String(r.year) : "—",
|
||||||
|
engine: r.engine || "—",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setVinError(true);
|
||||||
|
}
|
||||||
|
setVinLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== "AbortError") {
|
||||||
|
setVinError(true);
|
||||||
|
setVinLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [vin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4 sm:px-6">
|
||||||
|
<Link to="/" className="text-xl font-bold tracking-tight">
|
||||||
|
Sase.tr
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
aria-label="Tema değiştir"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</button>
|
||||||
|
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
|
||||||
|
Demo
|
||||||
|
</span>
|
||||||
|
<Link to="/register">
|
||||||
|
<Button size="sm" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||||
|
Tam Erişim
|
||||||
|
<ArrowRight className="ml-1.5 size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
|
||||||
|
className={`rounded-full px-3 py-1 transition ${step === "vin" ? "bg-foreground text-background" : "bg-muted"}`}
|
||||||
|
>
|
||||||
|
1. VIN Girin
|
||||||
|
</button>
|
||||||
|
<div className="h-px w-6 bg-border" />
|
||||||
|
<button
|
||||||
|
onClick={() => vinPreview && setStep("categories")}
|
||||||
|
className={`rounded-full px-3 py-1 transition ${step === "categories" ? "bg-foreground text-background" : "bg-muted"} ${!vinPreview ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
disabled={!vinPreview}
|
||||||
|
>
|
||||||
|
2. Kategori Seçin
|
||||||
|
</button>
|
||||||
|
<div className="h-px w-6 bg-border" />
|
||||||
|
<button
|
||||||
|
onClick={() => selectedCategory && setStep("schema")}
|
||||||
|
className={`rounded-full px-3 py-1 transition ${step === "schema" ? "bg-foreground text-background" : "bg-muted"} ${!selectedCategory ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
disabled={!selectedCategory}
|
||||||
|
>
|
||||||
|
3. Şema & Parçalar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: VIN Input */}
|
||||||
|
{step === "vin" && (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
VIN ile Araç Tanımlama
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-muted-foreground">
|
||||||
|
17 haneli VIN numaranızı girin, aracınızı tanıyalım.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={vin}
|
||||||
|
onChange={(e) => setVin(e.target.value.toUpperCase())}
|
||||||
|
placeholder="Örnek: WVWZZZ1JZ3W597935"
|
||||||
|
maxLength={17}
|
||||||
|
className="h-14 rounded-2xl border-border bg-muted pl-12 pr-4 font-mono text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{Array.from({ length: 17 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
||||||
|
i < vin.length ? "bg-emerald-500" : "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vinLoading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-surface p-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vinPreview && !vinLoading && (
|
||||||
|
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Car className="size-6 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">
|
||||||
|
{vinPreview.make} {vinPreview.model}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{vinPreview.year} {vinPreview.engine !== "—" ? `• ${vinPreview.engine}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setStep("categories")}
|
||||||
|
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Parça Kataloğuna Devam Et
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vinError && !vinLoading && (
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-4 text-center text-sm text-muted-foreground">
|
||||||
|
VIN bilgisi bulunamadı. Lütfen kontrol edin.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!vin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVin("WVWZZZ1JZ3W597935")}
|
||||||
|
className="mx-auto block text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
>
|
||||||
|
Örnek VIN ile deneyin →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Categories (static mockup) */}
|
||||||
|
{step === "categories" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||||
|
Parça Kategorileri
|
||||||
|
</h2>
|
||||||
|
{vinPreview && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{vinPreview.make} {vinPreview.model} ({vinPreview.year})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
|
||||||
|
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Farklı VIN dene
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{EXAMPLE_CATEGORIES.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.name}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCategory(cat.name);
|
||||||
|
setStep("schema");
|
||||||
|
}}
|
||||||
|
className="group rounded-2xl border border-border bg-surface p-5 text-left transition hover:border-foreground/20"
|
||||||
|
>
|
||||||
|
<div className="mb-3 inline-flex rounded-lg bg-muted p-2.5">
|
||||||
|
<FolderTree className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{cat.name}</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{cat.subcategories.join(" • ")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground/70 transition group-hover:text-foreground">
|
||||||
|
{cat.subcategories.length} alt kategori →
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Schema & Parts (static mockup with overlay) */}
|
||||||
|
{step === "schema" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||||
|
{selectedCategory} — Şema & Parçalar
|
||||||
|
</h2>
|
||||||
|
{vinPreview && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{vinPreview.make} {vinPreview.model} ({vinPreview.year})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("categories")}
|
||||||
|
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Kategorilere dön
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Schema mockup with watermark */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-border bg-surface">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<MousePointerClick className="size-4" />
|
||||||
|
İnteraktif Şema
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative aspect-square bg-muted/50 p-6">
|
||||||
|
{/* Simplified schema grid */}
|
||||||
|
<div className="grid h-full grid-cols-4 grid-rows-4 gap-2">
|
||||||
|
{Array.from({ length: 16 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
|
||||||
|
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Watermark overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-[2px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<Lock className="mx-auto size-8 text-muted-foreground" />
|
||||||
|
<p className="mt-2 text-sm font-medium text-muted-foreground">
|
||||||
|
Tam şema erişimi için kayıt olun
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parts list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">OEM Parça Listesi</h3>
|
||||||
|
{EXAMPLE_SCHEMA_PARTS.map((part, idx) => (
|
||||||
|
<div
|
||||||
|
key={part.code}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-border bg-surface p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm text-foreground">{part.code}</span>
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
||||||
|
</div>
|
||||||
|
{idx < 2 ? (
|
||||||
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500">
|
||||||
|
Görünür
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
<Lock className="mr-1 inline size-3" />
|
||||||
|
Kilitli
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Signup overlay CTA */}
|
||||||
|
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
|
||||||
|
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||||
|
</p>
|
||||||
|
<Link to="/register">
|
||||||
|
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||||
|
Ücretsiz Kayıt Ol
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user