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 { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
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: [
|
||||
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
|
||||
"http://localhost:4000",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} 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 { userSubscriptions, userBrands, plans, brands } from "../database/schema/core";
|
||||
|
||||
@@ -25,6 +25,12 @@ export class SubscriptionsService {
|
||||
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
|
||||
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");
|
||||
@@ -198,7 +204,12 @@ export class SubscriptionsService {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.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);
|
||||
|
||||
if (!sub || !sub.endDate) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { VehiclesService } from "./vehicles.service";
|
||||
import { CategoriesService } from "../categories/categories.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
|
||||
@Controller("vehicles")
|
||||
export class VehiclesController {
|
||||
@@ -11,6 +12,12 @@ export class VehiclesController {
|
||||
private categoriesService: CategoriesService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get("preview/:vin")
|
||||
async preview(@Param("vin", VinValidationPipe) vin: string) {
|
||||
return this.vehiclesService.previewVin(vin);
|
||||
}
|
||||
|
||||
@Post("decode")
|
||||
async decode(
|
||||
@CurrentUser("id") userId: string,
|
||||
|
||||
@@ -176,6 +176,59 @@ export class VehiclesService {
|
||||
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) {
|
||||
const offset = (page - 1) * limit;
|
||||
return this.db
|
||||
|
||||
Reference in New Issue
Block a user