feat: onboarding trial flow, Google sign-in, dark mode hotspot fixes
- Move trial creation from auth hook to dedicated /subscriptions/trial endpoint with eligibility checks (3-day Full Paket trial) - Add animated onboarding progress (Remotion) with confetti on completion - Register redirects to subscription page with welcome flow - Add Google social login/signup support with config injection - Improve hotspot overlay visibility in dark mode - Show "Panele Git" on landing page when authenticated - Turkish translations for API error messages - Add toast utility wrapper, UUID generation for auth IDs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,7 @@ export class AdminService {
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException("User not found");
|
||||
throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
}
|
||||
|
||||
const [subscriptions, userPayments] = await Promise.all([
|
||||
|
||||
@@ -16,6 +16,8 @@ export class AuthModule implements OnModuleInit {
|
||||
const databaseUrl = this.configService.get<string>("database.url")!;
|
||||
const secret = this.configService.get<string>("auth.secret")!;
|
||||
const baseUrl = this.configService.get<string>("auth.url")!;
|
||||
createAuth(databaseUrl, secret, baseUrl);
|
||||
const googleClientId = this.configService.get<string>("auth.googleClientId");
|
||||
const googleClientSecret = this.configService.get<string>("auth.googleClientSecret");
|
||||
createAuth(databaseUrl, secret, baseUrl, { googleClientId, googleClientSecret });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
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 { randomUUID } from "crypto";
|
||||
|
||||
import * as schema from "../database/schema/core";
|
||||
|
||||
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
||||
|
||||
export function createAuth(databaseUrl: string, secret: string, baseUrl: string) {
|
||||
interface SocialCredentials {
|
||||
googleClientId?: string;
|
||||
googleClientSecret?: string;
|
||||
}
|
||||
|
||||
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, social?: SocialCredentials) {
|
||||
if (authInstance) return authInstance;
|
||||
|
||||
const client = postgres(databaseUrl, { max: 5 });
|
||||
@@ -18,6 +24,11 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string)
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: () => randomUUID(),
|
||||
},
|
||||
},
|
||||
secret,
|
||||
baseURL: baseUrl,
|
||||
basePath: "/api/auth",
|
||||
@@ -25,13 +36,16 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string)
|
||||
enabled: true,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
enabled: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
||||
},
|
||||
},
|
||||
...(social?.googleClientId && social?.googleClientSecret
|
||||
? {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: social.googleClientId,
|
||||
clientSecret: social.googleClientSecret,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
@@ -57,40 +71,6 @@ 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",
|
||||
|
||||
@@ -16,7 +16,7 @@ export class BrandsService {
|
||||
|
||||
async findById(id: string) {
|
||||
const result = await this.db.select().from(brands).where(eq(brands.id, id)).limit(1);
|
||||
if (result.length === 0) throw new NotFoundException("Brand not found");
|
||||
if (result.length === 0) throw new NotFoundException("Marka bulunamadı");
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class BrandsService {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(brands.id, id))
|
||||
.returning();
|
||||
if (!brand) throw new NotFoundException("Brand not found");
|
||||
if (!brand) throw new NotFoundException("Marka bulunamadı");
|
||||
return brand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class CategoriesService {
|
||||
.where(eq(vehicles.id, vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (!vehicle) throw new NotFoundException("Vehicle not found");
|
||||
if (!vehicle) throw new NotFoundException("Araç bulunamadı");
|
||||
|
||||
// Check DB first
|
||||
let dbCategories = await this.db
|
||||
@@ -205,7 +205,7 @@ export class CategoriesService {
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
if (!category) throw new NotFoundException("Kategori bulunamadı");
|
||||
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
@@ -213,7 +213,7 @@ export class CategoriesService {
|
||||
.where(eq(vehicles.id, category.vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (!vehicle) throw new NotFoundException("Vehicle not found");
|
||||
if (!vehicle) throw new NotFoundException("Araç bulunamadı");
|
||||
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
@@ -286,7 +286,7 @@ export class CategoriesService {
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
if (!category) throw new NotFoundException("Kategori bulunamadı");
|
||||
|
||||
// Check if this category has children
|
||||
const children = await this.db
|
||||
@@ -542,7 +542,7 @@ export class CategoriesService {
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
if (!category) throw new NotFoundException("Kategori bulunamadı");
|
||||
|
||||
const pics = await this.db
|
||||
.select()
|
||||
|
||||
@@ -28,7 +28,7 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
const session = await auth.api.getSession({ headers });
|
||||
if (!session?.user) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
throw new UnauthorizedException("Kimlik doğrulama gerekli");
|
||||
}
|
||||
|
||||
request.user = session.user;
|
||||
@@ -36,7 +36,7 @@ export class AuthGuard implements CanActivate {
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) throw error;
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
throw new UnauthorizedException("Kimlik doğrulama gerekli");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../../database/database.provider";
|
||||
import { userSubscriptions, userBrands, plans } from "../../database/schema/core";
|
||||
|
||||
@@ -13,7 +13,7 @@ export class BrandAccessGuard implements CanActivate {
|
||||
const brandId = request.params.brandId || request.body?.brandId;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Authentication required");
|
||||
throw new ForbiddenException("Kimlik doğrulama gerekli");
|
||||
}
|
||||
|
||||
if (user.role === "admin") return true;
|
||||
@@ -28,11 +28,11 @@ export class BrandAccessGuard implements CanActivate {
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(and(eq(userSubscriptions.userId, user.id), eq(userSubscriptions.status, "active")))
|
||||
.where(and(eq(userSubscriptions.userId, user.id), or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial"))))
|
||||
.limit(1);
|
||||
|
||||
if (activeSub.length === 0) {
|
||||
throw new ForbiddenException("No active subscription");
|
||||
throw new ForbiddenException("Aktif abonelik bulunamadı");
|
||||
}
|
||||
|
||||
const subscription = activeSub[0];
|
||||
|
||||
@@ -16,11 +16,11 @@ export class RolesGuard implements CanActivate {
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Access denied");
|
||||
throw new ForbiddenException("Erişim reddedildi");
|
||||
}
|
||||
|
||||
if (!requiredRoles.includes(user.role)) {
|
||||
throw new ForbiddenException("Insufficient permissions");
|
||||
throw new ForbiddenException("Yetersiz yetki");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class TimeoutInterceptor implements NestInterceptor {
|
||||
timeout(this.timeoutMs),
|
||||
catchError((err) => {
|
||||
if (err instanceof TimeoutError) {
|
||||
return throwError(() => new RequestTimeoutException("Request timed out"));
|
||||
return throwError(() => new RequestTimeoutException("İstek zaman aşımına uğradı"));
|
||||
}
|
||||
return throwError(() => err);
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,17 @@ export interface TransformedResponse<T> {
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stripSource(data: unknown): unknown {
|
||||
if (Array.isArray(data)) return data.map(stripSource);
|
||||
if (data && typeof data === "object" && !(data instanceof Date)) {
|
||||
const { source, ...rest } = data as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.entries(rest).map(([k, v]) => [k, stripSource(v)]),
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, TransformedResponse<T>> {
|
||||
intercept(
|
||||
@@ -17,22 +28,22 @@ export class TransformInterceptor<T> implements NestInterceptor<T, TransformedRe
|
||||
map((data) => {
|
||||
// If already wrapped, pass through
|
||||
if (data && typeof data === "object" && "success" in data) {
|
||||
return data;
|
||||
return stripSource(data) as TransformedResponse<T>;
|
||||
}
|
||||
|
||||
// Handle pagination response
|
||||
if (data && typeof data === "object" && "items" in data && "meta" in data) {
|
||||
return {
|
||||
success: true,
|
||||
data: data.items,
|
||||
data: stripSource(data.items),
|
||||
meta: data.meta,
|
||||
};
|
||||
} as TransformedResponse<T>;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
data: stripSource(data),
|
||||
} as TransformedResponse<T>;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ import { isValidVin } from "@sase/shared";
|
||||
export class VinValidationPipe implements PipeTransform<string, string> {
|
||||
transform(value: string): string {
|
||||
if (!value || typeof value !== "string") {
|
||||
throw new BadRequestException("VIN is required");
|
||||
throw new BadRequestException("Şase numarası gerekli");
|
||||
}
|
||||
|
||||
const vin = value.toUpperCase().trim();
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException(
|
||||
"Invalid VIN. Must be 17 characters, letters I, O, Q are not allowed.",
|
||||
"Geçersiz şase numarası. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export default () => ({
|
||||
auth: {
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
url: process.env.BETTER_AUTH_URL,
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
minio: {
|
||||
endpoint: process.env.MINIO_ENDPOINT,
|
||||
|
||||
@@ -38,6 +38,7 @@ const PLANS_DATA = [
|
||||
{ name: "2 Marka", brandCount: 2, priceMonthly: 350_00, priceYearly: 3500_00 },
|
||||
{ name: "3 Marka", brandCount: 3, priceMonthly: 500_00, priceYearly: 5000_00 },
|
||||
{ name: "Full Paket", brandCount: 0, priceMonthly: 999_00, priceYearly: 9990_00 },
|
||||
{ name: "Full Paket Deneme", brandCount: 0, priceMonthly: 0, priceYearly: 0 },
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
@@ -60,7 +61,12 @@ async function seed() {
|
||||
|
||||
// Seed plans
|
||||
console.log("Seeding plans...");
|
||||
await db.insert(plans).values(PLANS_DATA).onConflictDoNothing();
|
||||
const activePlans = PLANS_DATA.filter((p) => p.name !== "Full Paket Deneme");
|
||||
const trialPlans = PLANS_DATA.filter((p) => p.name === "Full Paket Deneme").map((p) => ({
|
||||
...p,
|
||||
isActive: false,
|
||||
}));
|
||||
await db.insert(plans).values([...activePlans, ...trialPlans]).onConflictDoNothing();
|
||||
|
||||
// Seed admin user
|
||||
console.log("Seeding admin user...");
|
||||
|
||||
@@ -29,7 +29,7 @@ export class PartsService {
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
if (!category) throw new NotFoundException("Kategori bulunamadı");
|
||||
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
@@ -37,7 +37,7 @@ export class PartsService {
|
||||
.where(eq(vehicles.id, category.vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (!vehicle) throw new NotFoundException("Vehicle not found");
|
||||
if (!vehicle) throw new NotFoundException("Araç bulunamadı");
|
||||
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
@@ -132,7 +132,7 @@ export class PartsService {
|
||||
|
||||
async getById(id: string) {
|
||||
const [part] = await this.db.select().from(parts).where(eq(parts.id, id)).limit(1);
|
||||
if (!part) throw new NotFoundException("Part not found");
|
||||
if (!part) throw new NotFoundException("Parça bulunamadı");
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class PaymentsService {
|
||||
|
||||
private async resolvePlanId(planKey: string): Promise<string> {
|
||||
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
|
||||
if (brandCount === undefined) throw new BadRequestException(`Invalid plan key: ${planKey}`);
|
||||
if (brandCount === undefined) throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
|
||||
|
||||
const [plan] = await this.db
|
||||
.select()
|
||||
@@ -40,7 +40,7 @@ export class PaymentsService {
|
||||
.where(and(eq(plans.brandCount, brandCount), eq(plans.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!plan) throw new NotFoundException("Plan not found");
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
return plan.id;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class PaymentsService {
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Payment not found");
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
|
||||
const newStatus = status === "success" ? "completed" : "failed";
|
||||
|
||||
@@ -151,8 +151,8 @@ export class PaymentsService {
|
||||
.where(and(eq(payments.id, paymentId), eq(payments.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Payment not found");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Not an EFT payment");
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Bu bir EFT ödemesi değil");
|
||||
|
||||
const key = `receipts/${paymentId}/${filename}`;
|
||||
const url = await this.storageService.upload(key, file, "application/pdf");
|
||||
@@ -172,8 +172,8 @@ export class PaymentsService {
|
||||
.where(eq(payments.id, paymentId))
|
||||
.limit(1);
|
||||
|
||||
if (!payment) throw new NotFoundException("Payment not found");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Not an EFT payment");
|
||||
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
|
||||
if (payment.method !== "eft") throw new BadRequestException("Bu bir EFT ödemesi değil");
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
|
||||
@@ -16,7 +16,7 @@ export class PlansService {
|
||||
|
||||
async findById(id: string) {
|
||||
const result = await this.db.select().from(plans).where(eq(plans.id, id)).limit(1);
|
||||
if (result.length === 0) throw new NotFoundException("Plan not found");
|
||||
if (result.length === 0) throw new NotFoundException("Plan bulunamadı");
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class PlansService {
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(plans.id, id))
|
||||
.returning();
|
||||
if (!plan) throw new NotFoundException("Plan not found");
|
||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export class ReferralsService {
|
||||
|
||||
async getStats(userId: string) {
|
||||
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (user.length === 0) throw new NotFoundException("User not found");
|
||||
if (user.length === 0) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -36,7 +36,7 @@ export class ReferralsService {
|
||||
|
||||
async getMyReferrals(userId: string) {
|
||||
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (user.length === 0) throw new NotFoundException("User not found");
|
||||
if (user.length === 0) throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
|
||||
const myReferrals = await this.db
|
||||
.select()
|
||||
@@ -58,8 +58,8 @@ export class ReferralsService {
|
||||
.where(eq(users.referralCode, code))
|
||||
.limit(1);
|
||||
|
||||
if (!referrer) throw new NotFoundException("Invalid referral code");
|
||||
if (referrer.id === userId) throw new BadRequestException("Cannot use own referral code");
|
||||
if (!referrer) throw new NotFoundException("Geçersiz referans kodu");
|
||||
if (referrer.id === userId) throw new BadRequestException("Kendi referans kodunuzu kullanamazsınız");
|
||||
|
||||
// Check if already referred
|
||||
const existing = await this.db
|
||||
@@ -69,7 +69,7 @@ export class ReferralsService {
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException("Already used a referral code");
|
||||
throw new BadRequestException("Zaten bir referans kodu kullanılmış");
|
||||
}
|
||||
|
||||
// Create referral
|
||||
|
||||
@@ -29,7 +29,14 @@ export class SubscriptionsController {
|
||||
|
||||
@Get("me")
|
||||
async getMySubscription(@CurrentUser("id") userId: string) {
|
||||
return this.subscriptionsService.getMySubscription(userId);
|
||||
const subscription = await this.subscriptionsService.getMySubscription(userId);
|
||||
const eligibleForTrial = await this.subscriptionsService.checkTrialEligibility(userId);
|
||||
return { subscription, eligibleForTrial };
|
||||
}
|
||||
|
||||
@Post("trial")
|
||||
async startTrial(@CurrentUser("id") userId: string) {
|
||||
return this.subscriptionsService.startFullTrial(userId);
|
||||
}
|
||||
|
||||
@Patch("cancel")
|
||||
|
||||
@@ -22,7 +22,7 @@ export class SubscriptionsService {
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException("Already have an active subscription");
|
||||
throw new ConflictException("Zaten aktif bir aboneliğiniz var");
|
||||
}
|
||||
|
||||
// Expire any existing trial subscription
|
||||
@@ -33,7 +33,7 @@ export class SubscriptionsService {
|
||||
|
||||
// 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");
|
||||
if (plan.length === 0) throw new NotFoundException("Plan bulunamadı");
|
||||
|
||||
const selectedPlan = plan[0];
|
||||
|
||||
@@ -41,7 +41,7 @@ export class SubscriptionsService {
|
||||
if (selectedPlan.brandCount > 0) {
|
||||
if (data.brandIds.length !== selectedPlan.brandCount) {
|
||||
throw new BadRequestException(
|
||||
`Plan requires exactly ${selectedPlan.brandCount} brand(s), got ${data.brandIds.length}`,
|
||||
`Bu plan tam olarak ${selectedPlan.brandCount} marka gerektirir, ${data.brandIds.length} seçildi`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export class SubscriptionsService {
|
||||
const validBrandIds = new Set(existingBrands.map((b) => b.id));
|
||||
for (const brandId of data.brandIds) {
|
||||
if (!validBrandIds.has(brandId)) {
|
||||
throw new BadRequestException(`Invalid brand ID: ${brandId}`);
|
||||
throw new BadRequestException(`Geçersiz marka ID: ${brandId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export class SubscriptionsService {
|
||||
.where(eq(userSubscriptions.id, subscriptionId))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) throw new NotFoundException("Subscription not found");
|
||||
if (!sub) throw new NotFoundException("Abonelik bulunamadı");
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
@@ -171,7 +171,7 @@ export class SubscriptionsService {
|
||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) throw new NotFoundException("No active subscription");
|
||||
if (!sub) throw new NotFoundException("Aktif abonelik bulunamadı");
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSubscriptions)
|
||||
@@ -189,7 +189,7 @@ export class SubscriptionsService {
|
||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "cancelled")))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) throw new NotFoundException("No cancelled subscription to resume");
|
||||
if (!sub) throw new NotFoundException("Devam ettirilecek iptal edilmiş abonelik bulunamadı");
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(userSubscriptions)
|
||||
@@ -223,6 +223,101 @@ export class SubscriptionsService {
|
||||
.where(eq(userSubscriptions.id, sub.id));
|
||||
}
|
||||
|
||||
async checkTrialEligibility(userId: string): Promise<boolean> {
|
||||
// Check if user ever had a paid subscription (active, pending, cancelled)
|
||||
const paidSub = await this.db
|
||||
.select({ id: userSubscriptions.id })
|
||||
.from(userSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.userId, userId),
|
||||
or(
|
||||
eq(userSubscriptions.status, "active"),
|
||||
eq(userSubscriptions.status, "pending"),
|
||||
eq(userSubscriptions.status, "cancelled"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (paidSub.length > 0) return false;
|
||||
|
||||
// Check if Full Paket trial was already used (brandCount=0 trial/expired record)
|
||||
const usedTrial = await this.db
|
||||
.select({ id: userSubscriptions.id })
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.userId, userId),
|
||||
eq(plans.brandCount, 0),
|
||||
or(eq(userSubscriptions.status, "trial"), eq(userSubscriptions.status, "expired")),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (usedTrial.length > 0) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async startFullTrial(userId: string) {
|
||||
const eligible = await this.checkTrialEligibility(userId);
|
||||
if (!eligible) {
|
||||
throw new ConflictException("Deneme hakkınız bulunmuyor");
|
||||
}
|
||||
|
||||
// Expire any existing trial (leftover auto-trials)
|
||||
await this.db
|
||||
.update(userSubscriptions)
|
||||
.set({ status: "expired", updatedAt: new Date() })
|
||||
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "trial")));
|
||||
|
||||
// Find the dedicated trial plan
|
||||
const [trialPlan] = await this.db
|
||||
.select()
|
||||
.from(plans)
|
||||
.where(eq(plans.name, "Full Paket Deneme"))
|
||||
.limit(1);
|
||||
|
||||
if (!trialPlan) throw new NotFoundException("Deneme planı bulunamadı");
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
endDate.setDate(endDate.getDate() + 3);
|
||||
|
||||
// Create trial subscription
|
||||
const [subscription] = await this.db
|
||||
.insert(userSubscriptions)
|
||||
.values({
|
||||
userId,
|
||||
planId: trialPlan.id,
|
||||
status: "trial",
|
||||
billingPeriod: "trial",
|
||||
startDate: now,
|
||||
endDate,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add all active brands to userBrands
|
||||
const allBrands = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.isActive, true));
|
||||
|
||||
if (allBrands.length > 0) {
|
||||
await this.db.insert(userBrands).values(
|
||||
allBrands.map((b) => ({
|
||||
userId,
|
||||
subscriptionId: subscription.id,
|
||||
brandId: b.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
const items = await this.db
|
||||
|
||||
@@ -11,7 +11,7 @@ export class UsersService {
|
||||
async findById(id: string) {
|
||||
const result = await this.db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("User not found");
|
||||
throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class UsersService {
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("User not found");
|
||||
throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
}
|
||||
return result[0];
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export class UsersService {
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("Connection not found");
|
||||
throw new NotFoundException("Bağlantı bulunamadı");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -76,16 +76,16 @@ export class UsersService {
|
||||
.limit(1);
|
||||
|
||||
if (!account?.password) {
|
||||
throw new BadRequestException("No password set for this account");
|
||||
throw new BadRequestException("Bu hesap için şifre belirlenmemiş");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword({ hash: account.password, password: currentPassword });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Current password is incorrect");
|
||||
throw new BadRequestException("Mevcut şifre hatalı");
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
throw new BadRequestException("Password must be at least 8 characters");
|
||||
throw new BadRequestException("Şifre en az 8 karakter olmalıdır");
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
@@ -104,7 +104,7 @@ export class UsersService {
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException("User not found");
|
||||
throw new NotFoundException("Kullanıcı bulunamadı");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { vehicles, queryLogs, brands, userBrands, userSubscriptions, plans } from "../database/schema/core";
|
||||
import { CorgiService } from "../integrations/corgi/corgi.service";
|
||||
@@ -31,7 +31,7 @@ export class VehiclesService {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Invalid VIN");
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// 1. Cache check
|
||||
@@ -127,7 +127,7 @@ export class VehiclesService {
|
||||
// If nothing recognized this VIN at all, give up
|
||||
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
|
||||
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
|
||||
throw new BadRequestException("VIN not recognized. Brand not supported.");
|
||||
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
|
||||
}
|
||||
|
||||
// 6. Fallback to VIN API if PL24 and EMEX not available
|
||||
@@ -182,7 +182,7 @@ export class VehiclesService {
|
||||
*/
|
||||
async previewVin(vin: string) {
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Invalid VIN");
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// 1. Corgi decode (offline)
|
||||
@@ -217,7 +217,7 @@ export class VehiclesService {
|
||||
}
|
||||
|
||||
if (!pl24Vehicle && !emexVehicle && !corgiResult?.isKnown) {
|
||||
throw new BadRequestException("VIN not recognized");
|
||||
throw new BadRequestException("Şase numarası tanınamadı");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -247,7 +247,7 @@ export class VehiclesService {
|
||||
.where(and(eq(vehicles.id, id), eq(vehicles.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) throw new NotFoundException("Vehicle not found");
|
||||
if (result.length === 0) throw new NotFoundException("Araç bulunamadı");
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ export class VehiclesService {
|
||||
.where(and(eq(vehicles.id, id), eq(vehicles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) throw new NotFoundException("Vehicle not found");
|
||||
if (result.length === 0) throw new NotFoundException("Araç bulunamadı");
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -269,11 +269,11 @@ export class VehiclesService {
|
||||
})
|
||||
.from(userSubscriptions)
|
||||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||||
.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) {
|
||||
throw new ForbiddenException("No active subscription. Please subscribe to access vehicle data.");
|
||||
throw new ForbiddenException("Aktif aboneliğiniz yok. Araç verilerine erişmek için abone olun.");
|
||||
}
|
||||
|
||||
// brandCount === 0 means unlimited (Full Paket) — skip per-brand check
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"remotion": "^4.0.422",
|
||||
"sonner": "^1.7.0",
|
||||
"sileo": "^0.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
@@ -39,6 +40,7 @@
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import type { Hotspot } from "@/hooks/use-parts";
|
||||
|
||||
function useIsDark() {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
const obs = new MutationObserver(cb);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => obs.disconnect();
|
||||
},
|
||||
() => document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
}
|
||||
|
||||
interface HotspotOverlayProps {
|
||||
hotspots: Hotspot[];
|
||||
imageWidth: number;
|
||||
@@ -11,6 +23,7 @@ function HotspotShape({
|
||||
hotspot,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
isDark,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
@@ -18,22 +31,23 @@ function HotspotShape({
|
||||
hotspot: Hotspot;
|
||||
isHighlighted: boolean;
|
||||
isSelected: boolean;
|
||||
isDark: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const fillOpacity = isSelected ? 0.35 : isHighlighted ? 0.25 : 0.08;
|
||||
const fillOpacity = isSelected ? 0.4 : isHighlighted ? 0.3 : isDark ? 0.18 : 0.08;
|
||||
const strokeColor = isSelected
|
||||
? "#ef4444"
|
||||
: isHighlighted
|
||||
? "#3b82f6"
|
||||
: "#6b7280";
|
||||
? "#60a5fa"
|
||||
: isDark ? "#a5b4fc" : "#6b7280";
|
||||
const fillColor = isSelected
|
||||
? "#ef4444"
|
||||
: isHighlighted
|
||||
? "#3b82f6"
|
||||
: "#9ca3af";
|
||||
const strokeWidth = isSelected || isHighlighted ? 2.5 : 1.5;
|
||||
? "#60a5fa"
|
||||
: isDark ? "#818cf8" : "#9ca3af";
|
||||
const strokeWidth = isSelected || isHighlighted ? 2.5 : isDark ? 2 : 1.5;
|
||||
|
||||
const commonProps = {
|
||||
fill: fillColor,
|
||||
@@ -78,6 +92,7 @@ export function HotspotOverlay({
|
||||
}: HotspotOverlayProps) {
|
||||
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||
useSchemaStore();
|
||||
const isDark = useIsDark();
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -90,12 +105,26 @@ export function HotspotOverlay({
|
||||
const isHighlighted = highlightedGroup === hotspot.group;
|
||||
const isSelected = selectedGroup === hotspot.group;
|
||||
|
||||
const labelX =
|
||||
hotspot.shape === "circle"
|
||||
? hotspot.coordinates[0]
|
||||
: hotspot.shape === "rect"
|
||||
? hotspot.coordinates[0] + hotspot.coordinates[2] / 2
|
||||
: hotspot.coordinates[0];
|
||||
const labelY =
|
||||
hotspot.shape === "circle"
|
||||
? hotspot.coordinates[1] - hotspot.coordinates[2] - 4
|
||||
: hotspot.shape === "rect"
|
||||
? hotspot.coordinates[1] - 4
|
||||
: hotspot.coordinates[1] - 4;
|
||||
|
||||
return (
|
||||
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
|
||||
<HotspotShape
|
||||
hotspot={hotspot}
|
||||
isHighlighted={isHighlighted}
|
||||
isSelected={isSelected}
|
||||
isDark={isDark}
|
||||
onMouseEnter={() => setHighlightedGroup(hotspot.group)}
|
||||
onMouseLeave={() => setHighlightedGroup(null)}
|
||||
onClick={() =>
|
||||
@@ -105,27 +134,26 @@ export function HotspotOverlay({
|
||||
}
|
||||
/>
|
||||
{(isHighlighted || isSelected) && hotspot.label && (
|
||||
<text
|
||||
x={
|
||||
hotspot.shape === "circle"
|
||||
? hotspot.coordinates[0]
|
||||
: hotspot.shape === "rect"
|
||||
? hotspot.coordinates[0] + hotspot.coordinates[2] / 2
|
||||
: hotspot.coordinates[0]
|
||||
}
|
||||
y={
|
||||
hotspot.shape === "circle"
|
||||
? hotspot.coordinates[1] - hotspot.coordinates[2] - 4
|
||||
: hotspot.shape === "rect"
|
||||
? hotspot.coordinates[1] - 4
|
||||
: hotspot.coordinates[1] - 4
|
||||
}
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none select-none fill-foreground text-xs font-medium"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{hotspot.label}
|
||||
</text>
|
||||
<>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none select-none text-xs font-medium"
|
||||
style={{ fontSize: 12, stroke: isDark ? "#000" : "#fff", strokeWidth: 3, strokeLinejoin: "round", fill: isDark ? "#000" : "#fff" }}
|
||||
>
|
||||
{hotspot.label}
|
||||
</text>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
textAnchor="middle"
|
||||
className="pointer-events-none select-none text-xs font-medium"
|
||||
style={{ fontSize: 12, fill: isDark ? "#e0e7ff" : "#1e293b" }}
|
||||
>
|
||||
{hotspot.label}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export function SettingsContent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -77,6 +77,19 @@
|
||||
}
|
||||
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
|
||||
|
||||
/* Sileo toast: deeper state colors for light mode */
|
||||
:root {
|
||||
--sileo-state-success: oklch(0.52 0.24 142);
|
||||
--sileo-state-error: oklch(0.48 0.26 25);
|
||||
--sileo-state-warning: oklch(0.58 0.2 70);
|
||||
--sileo-state-info: oklch(0.50 0.2 237);
|
||||
--sileo-state-loading: oklch(0.40 0 0);
|
||||
}
|
||||
|
||||
[data-sileo-description] {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
@@ -100,4 +113,20 @@
|
||||
--color-card-foreground: #fafafa;
|
||||
--color-popover: #0a0a0a;
|
||||
--color-popover-foreground: #fafafa;
|
||||
|
||||
/* Sileo toast: dark pill/body, light text, subtler shadow */
|
||||
--sileo-state-loading: oklch(0.7 0 0);
|
||||
}
|
||||
|
||||
.dark [data-sileo-pill],
|
||||
.dark [data-sileo-body] {
|
||||
fill: #1c1c1e !important;
|
||||
}
|
||||
|
||||
.dark [data-sileo-description] {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.dark [data-sileo-toast] {
|
||||
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
data?.error?.message || "Request failed",
|
||||
data?.error?.message || "İstek başarısız",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
@@ -66,7 +66,7 @@ class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
data?.error?.message || "Upload failed",
|
||||
data?.error?.message || "Yükleme başarısız",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
|
||||
25
apps/web/src/lib/toast.ts
Normal file
25
apps/web/src/lib/toast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sileo } from "sileo";
|
||||
export { Toaster } from "sileo";
|
||||
|
||||
interface ToastOptions {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
position?:
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, opts?: ToastOptions) =>
|
||||
sileo.success({ title, ...opts }),
|
||||
error: (title: string, opts?: ToastOptions) =>
|
||||
sileo.error({ title, ...opts }),
|
||||
info: (title: string, opts?: ToastOptions) =>
|
||||
sileo.info({ title, ...opts }),
|
||||
warning: (title: string, opts?: ToastOptions) =>
|
||||
sileo.warning({ title, ...opts }),
|
||||
};
|
||||
@@ -118,11 +118,28 @@
|
||||
},
|
||||
"statusLabels": {
|
||||
"active": "Active",
|
||||
"trial": "Trial",
|
||||
"pending": "Pending",
|
||||
"cancelled": "Cancelled",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"popular": "Popular"
|
||||
"popular": "Popular",
|
||||
"trialTitle": "3-Day Full Package Trial",
|
||||
"trialDescription": "Free access to all brands for 3 days. No credit card required.",
|
||||
"startTrial": "Start Free Trial",
|
||||
"trialStarted": "Your 3-day Full Package trial has started!",
|
||||
"onboarding": {
|
||||
"provisioning": "Setting up your free trial",
|
||||
"step1": "Verifying account",
|
||||
"step2": "Preparing catalogs",
|
||||
"step3": "Activating Full Package",
|
||||
"step4": "Completed!",
|
||||
"completed": "You can test all catalogs without limits!",
|
||||
"trialDuration": "3-Day Trial",
|
||||
"startSearching": "Start Searching",
|
||||
"error": "An error occurred while starting your trial.",
|
||||
"retry": "Try Again"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment",
|
||||
|
||||
@@ -118,11 +118,28 @@
|
||||
},
|
||||
"statusLabels": {
|
||||
"active": "Aktif",
|
||||
"trial": "Deneme",
|
||||
"pending": "Bekliyor",
|
||||
"cancelled": "İptal Edildi",
|
||||
"expired": "Süresi Doldu"
|
||||
},
|
||||
"popular": "Popüler"
|
||||
"popular": "Popüler",
|
||||
"trialTitle": "3 Gün Full Paket Denemesi",
|
||||
"trialDescription": "Tüm markalara 3 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||
"startTrial": "Ücretsiz Denemeyi Başlat",
|
||||
"trialStarted": "3 günlük Full Paket denemeniz başlatıldı!",
|
||||
"onboarding": {
|
||||
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
|
||||
"step1": "Hesap doğrulanıyor",
|
||||
"step2": "Kataloglar hazırlanıyor",
|
||||
"step3": "Full Paket aktif ediliyor",
|
||||
"step4": "Tamamlandı!",
|
||||
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
|
||||
"trialDuration": "3 Gün Deneme",
|
||||
"startSearching": "Şase Aramaya Başla",
|
||||
"error": "Deneme başlatılırken bir hata oluştu.",
|
||||
"retry": "Tekrar Dene"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"title": "Ödeme",
|
||||
|
||||
238
apps/web/src/remotion/OnboardingProgress.tsx
Normal file
238
apps/web/src/remotion/OnboardingProgress.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
spring,
|
||||
} from "remotion";
|
||||
|
||||
function getColors(isDark: boolean) {
|
||||
return {
|
||||
bg: isDark ? "#1a1a1a" : "#f5f5f5",
|
||||
fg: isDark ? "#fafafa" : "#0a0a0a",
|
||||
muted: isDark ? "#262626" : "#e5e5e5",
|
||||
mutedFg: isDark ? "#a3a3a3" : "#737373",
|
||||
border: isDark ? "#333333" : "#d4d4d4",
|
||||
emerald: "#10b981",
|
||||
emeraldDark: "#059669",
|
||||
surface: isDark ? "#1f1f1f" : "#ffffff",
|
||||
trackBg: isDark ? "#262626" : "#e5e5e5",
|
||||
checkBg: isDark ? "#1f1f1f" : "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Hesap doğrulanıyor", endFrame: 60 },
|
||||
{ label: "Kataloglar hazırlanıyor", endFrame: 120 },
|
||||
{ label: "Full Paket aktif ediliyor", endFrame: 175 },
|
||||
{ label: "Tamamlandı!", endFrame: 210 },
|
||||
];
|
||||
|
||||
export const OnboardingProgress: React.FC<{
|
||||
isDark: boolean;
|
||||
stepLabels?: string[];
|
||||
}> = ({ isDark, stepLabels }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const c = getColors(isDark);
|
||||
|
||||
const labels = stepLabels ?? STEPS.map((s) => s.label);
|
||||
|
||||
// Overall progress 0→1 over 210 frames with ease-in acceleration
|
||||
const rawProgress = interpolate(frame, [0, 210], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const progress = rawProgress * rawProgress * (3 - 2 * rawProgress); // smoothstep
|
||||
|
||||
const barWidth = 600;
|
||||
const barHeight = 8;
|
||||
const barX = 100;
|
||||
const barY = 100;
|
||||
|
||||
// Determine active step index
|
||||
const activeStepIndex = STEPS.findIndex((s) => frame < s.endFrame);
|
||||
const currentStep = activeStepIndex === -1 ? STEPS.length - 1 : activeStepIndex;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Active step label */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: barY - 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{labels.map((label, i) => {
|
||||
const isActive = i === currentStep;
|
||||
const labelOpacity = isActive
|
||||
? spring({
|
||||
frame: frame - Math.max(0, i === 0 ? 0 : STEPS[i - 1].endFrame),
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 120 },
|
||||
})
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
opacity: labelOpacity,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: i === STEPS.length - 1 && isActive ? c.emerald : c.fg,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Track background */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: barY,
|
||||
left: barX,
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
borderRadius: barHeight / 2,
|
||||
backgroundColor: c.trackBg,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Track fill */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: barY,
|
||||
left: barX,
|
||||
width: barWidth * progress,
|
||||
height: barHeight,
|
||||
borderRadius: barHeight / 2,
|
||||
background: `linear-gradient(90deg, ${c.emerald}, ${c.emeraldDark})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Checkpoints */}
|
||||
{STEPS.map((step, i) => {
|
||||
const stepX = barX + (barWidth / (STEPS.length - 1)) * i;
|
||||
const stepY = barY + barHeight / 2;
|
||||
const reached = frame >= step.endFrame;
|
||||
const reaching = frame >= (i === 0 ? 0 : STEPS[i - 1].endFrame);
|
||||
|
||||
const fillProgress = reached
|
||||
? 1
|
||||
: reaching
|
||||
? spring({
|
||||
frame: frame - (i === 0 ? 0 : STEPS[i - 1].endFrame),
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
durationInFrames: step.endFrame - (i === 0 ? 0 : STEPS[i - 1].endFrame),
|
||||
})
|
||||
: 0;
|
||||
|
||||
const checkScale = reached
|
||||
? spring({
|
||||
frame: frame - step.endFrame,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 200 },
|
||||
})
|
||||
: 0;
|
||||
|
||||
const circleSize = 28;
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{/* Outer circle */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: stepX - circleSize / 2,
|
||||
top: stepY - circleSize / 2,
|
||||
width: circleSize,
|
||||
height: circleSize,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: c.checkBg,
|
||||
border: `2px solid ${fillProgress > 0 ? c.emerald : c.border}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Fill background */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: c.emerald,
|
||||
opacity: fillProgress,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
{/* Checkmark */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
transform: `scale(${checkScale})`,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Step number below (visible when not yet reached) */}
|
||||
{!reached && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: stepX - circleSize / 2,
|
||||
top: stepY - circleSize / 2,
|
||||
width: circleSize,
|
||||
height: circleSize,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: fillProgress > 0.5 ? "white" : c.mutedFg,
|
||||
zIndex: 2,
|
||||
opacity: 1 - checkScale,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
import { Toaster } from "sonner";
|
||||
import { Toaster } from "@/lib/toast";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
@@ -36,7 +36,7 @@ function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<Toaster position="top-right" richColors />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ function AuthLayout() {
|
||||
{/* Bottom trial badge */}
|
||||
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
|
||||
<ShieldCheck className="size-4" />
|
||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
3 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export const Route = createFileRoute("/_auth/forgot-password")({
|
||||
component: ForgotPasswordPage,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export const Route = createFileRoute("/_auth/login")({
|
||||
component: LoginPage,
|
||||
@@ -104,7 +104,7 @@ function LoginPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google" })}
|
||||
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/search" })}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signUp } from "@/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { signIn, signUp } from "@/lib/auth-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/register")({
|
||||
@@ -12,8 +12,6 @@ export const Route = createFileRoute("/_auth/register")({
|
||||
});
|
||||
|
||||
function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const vin = new URLSearchParams(window.location.search).get("vin");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -26,11 +24,7 @@ function RegisterPage() {
|
||||
try {
|
||||
await signUp.email({ name, email, password });
|
||||
toast.success("Hesap oluşturuldu!");
|
||||
if (vin) {
|
||||
navigate({ to: "/dashboard/search" });
|
||||
} else {
|
||||
navigate({ to: "/dashboard/search" });
|
||||
}
|
||||
window.location.href = "/dashboard/subscription?welcome=1";
|
||||
} catch {
|
||||
toast.error("Kayıt başarısız. Bu e-posta zaten kullanılıyor olabilir.");
|
||||
} finally {
|
||||
@@ -52,7 +46,7 @@ function RegisterPage() {
|
||||
{/* Trial messaging */}
|
||||
<div className="mt-3 flex items-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
|
||||
3 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +91,28 @@ function RegisterPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider + Google */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
veya
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" })}
|
||||
>
|
||||
Google ile Kayıt Ol
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Kayıt olunca hemen VIN aramaya başlayın
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export const Route = createFileRoute("/_auth/reset-password")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
|
||||
@@ -31,7 +31,6 @@ interface QueryLogItem {
|
||||
vin: string;
|
||||
brandId: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
responseTimeMs: number | null;
|
||||
@@ -158,11 +157,10 @@ function AdminAnalyticsPage() {
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
{/* Table Header */}
|
||||
<div className="min-w-[900px]">
|
||||
<div className="grid grid-cols-8 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
|
||||
<div className="grid grid-cols-7 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Kullanici</span>
|
||||
<span>Şase</span>
|
||||
<span>Marka</span>
|
||||
<span>Kaynak</span>
|
||||
<span className="text-center">Durum</span>
|
||||
<span className="text-right">Yanit Suresi</span>
|
||||
<span>Tarih</span>
|
||||
@@ -174,7 +172,7 @@ function AdminAnalyticsPage() {
|
||||
{data.items.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="grid grid-cols-8 items-center gap-4 px-6 py-3 text-sm"
|
||||
className="grid grid-cols-7 items-center gap-4 px-6 py-3 text-sm"
|
||||
>
|
||||
<div className="truncate">
|
||||
<p className="truncate font-medium">{log.userName}</p>
|
||||
@@ -192,13 +190,6 @@ function AdminAnalyticsPage() {
|
||||
{log.brandName || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{log.source ? (
|
||||
<Badge variant="outline">{log.source}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{log.success ? (
|
||||
<CheckCircle className="mx-auto h-4 w-4 text-green-500" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@sase/ui";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
|
||||
@@ -174,17 +174,19 @@ function DashboardHome() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: subscription, isLoading: subLoading } = useQuery({
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<Subscription>("/subscriptions/me");
|
||||
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription ?? null;
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: async () => {
|
||||
@@ -292,7 +294,7 @@ function DashboardHome() {
|
||||
|
||||
{subLoading ? (
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
) : subscription && subscription.status === "active" ? (
|
||||
) : subscription && (subscription.status === "active" || (subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Plan Info */}
|
||||
@@ -307,7 +309,7 @@ function DashboardHome() {
|
||||
{subscription.plan?.name ?? "Aktif Plan"}
|
||||
</h3>
|
||||
<Badge variant="default" className="bg-emerald-600 text-xs">
|
||||
Aktif
|
||||
{subscription.status === "trial" ? "Deneme" : "Aktif"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,7 +52,6 @@ function SearchPage() {
|
||||
model: string;
|
||||
year: string;
|
||||
engine: string;
|
||||
source?: string;
|
||||
} | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
@@ -88,7 +87,7 @@ function SearchPage() {
|
||||
|
||||
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
if (!res.ok) throw new Error("Bulunamadı");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
@@ -99,7 +98,6 @@ function SearchPage() {
|
||||
model: r.model || "—",
|
||||
year: r.year ? String(r.year) : "—",
|
||||
engine: r.engine || "—",
|
||||
source: r.source,
|
||||
});
|
||||
} else {
|
||||
setPreviewError(true);
|
||||
@@ -260,7 +258,22 @@ function SearchPage() {
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{error.includes("abone olun") ? (
|
||||
<>
|
||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
||||
<Link
|
||||
to="/dashboard/subscription"
|
||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||
>
|
||||
abone olun
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
error
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
@@ -297,11 +310,6 @@ function SearchPage() {
|
||||
>
|
||||
Araç tanımlandı
|
||||
</Badge>
|
||||
{preview.source && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Kaynak: {preview.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@sase/ui";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Crown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Crown, Sparkles, ShieldCheck, Loader2, CheckCircle2, ArrowRight } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
const BrandSelector = lazy(() =>
|
||||
import("@/components/subscription/brand-selector").then((mod) => ({
|
||||
@@ -27,6 +28,16 @@ const BrandSelector = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const LazyPlayer = lazy(() =>
|
||||
import("@remotion/player").then((mod) => ({ default: mod.Player })),
|
||||
);
|
||||
|
||||
const LazyOnboardingProgress = lazy(() =>
|
||||
import("@/remotion/OnboardingProgress").then((mod) => ({
|
||||
default: mod.OnboardingProgress as React.FC<Record<string, unknown>>,
|
||||
})),
|
||||
);
|
||||
|
||||
function BrandSelectorFallback() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
@@ -106,16 +117,36 @@ function SubscriptionPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [welcome] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("welcome") === "1";
|
||||
});
|
||||
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
|
||||
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
|
||||
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
|
||||
const { data: subscription, isLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () => api.get<Subscription>("/subscriptions/me"),
|
||||
// Onboarding state
|
||||
const [onboardingPhase, setOnboardingPhase] = useState<"provisioning" | "completed" | null>(null);
|
||||
const [animationEnded, setAnimationEnded] = useState(false);
|
||||
const hasFiredRef = useRef(false);
|
||||
|
||||
// Dark mode detection for Remotion Player (computed once)
|
||||
const [isDark] = useState(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
return theme === "system"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
: theme === "dark";
|
||||
});
|
||||
|
||||
const { data: subData, isLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () => api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me"),
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription;
|
||||
const eligibleForTrial = subData?.eligibleForTrial ?? false;
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => api.patch("/subscriptions/cancel"),
|
||||
onSuccess: () => {
|
||||
@@ -139,6 +170,51 @@ function SubscriptionPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const trialMutation = useMutation({
|
||||
mutationFn: () => api.post("/subscriptions/trial"),
|
||||
onSuccess: () => {
|
||||
// Don't toast or invalidate here — completion handler does it
|
||||
},
|
||||
onError: () => {
|
||||
// Error shown in provisioning UI
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-trigger onboarding when welcome param is present and eligible
|
||||
useEffect(() => {
|
||||
if (!welcome || !subData || hasFiredRef.current) return;
|
||||
if (!eligibleForTrial) {
|
||||
// Not eligible — strip param silently
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
return;
|
||||
}
|
||||
hasFiredRef.current = true;
|
||||
setOnboardingPhase("provisioning");
|
||||
trialMutation.mutate();
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}, [welcome, subData, eligibleForTrial]);
|
||||
|
||||
// Transition from provisioning → completed when both animation and mutation are done
|
||||
useEffect(() => {
|
||||
if (onboardingPhase !== "provisioning") return;
|
||||
if (!animationEnded || !trialMutation.isSuccess) return;
|
||||
|
||||
setOnboardingPhase("completed");
|
||||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
confetti({
|
||||
particleCount: 150,
|
||||
spread: 80,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
}, [onboardingPhase, animationEnded, trialMutation.isSuccess]);
|
||||
|
||||
// Animation timer: 210 frames / 30fps = 7s + small buffer
|
||||
useEffect(() => {
|
||||
if (onboardingPhase !== "provisioning") return;
|
||||
const timer = setTimeout(() => setAnimationEnded(true), 7500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onboardingPhase]);
|
||||
|
||||
function handleSelectPlan(planKey: string) {
|
||||
setSelectedPlanKey(planKey);
|
||||
setSelectedBrandIds([]);
|
||||
@@ -168,11 +244,13 @@ function SubscriptionPage() {
|
||||
|
||||
const statusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
active: "default",
|
||||
trial: "default",
|
||||
pending: "secondary",
|
||||
cancelled: "destructive",
|
||||
expired: "outline",
|
||||
};
|
||||
|
||||
// ─── Loading state ─────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-4">
|
||||
@@ -185,12 +263,130 @@ function SubscriptionPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Onboarding: Provisioning ──────────────────────────────────────────────
|
||||
if (onboardingPhase === "provisioning") {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
|
||||
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
{t("subscription.onboarding.provisioning")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[200px] w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OnboardingPlayer
|
||||
isDark={isDark}
|
||||
stepLabels={[
|
||||
t("subscription.onboarding.step1"),
|
||||
t("subscription.onboarding.step2"),
|
||||
t("subscription.onboarding.step3"),
|
||||
t("subscription.onboarding.step4"),
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{/* If animation finished but mutation still pending */}
|
||||
{animationEnded && trialMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("subscription.onboarding.step4")}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mutation error */}
|
||||
{trialMutation.isError && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("subscription.onboarding.error")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => trialMutation.mutate()}
|
||||
>
|
||||
{t("subscription.onboarding.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Onboarding: Completed ─────────────────────────────────────────────────
|
||||
if (onboardingPhase === "completed") {
|
||||
const freshSub = subData?.subscription;
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
|
||||
|
||||
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
{t("subscription.onboarding.completed")}
|
||||
</h2>
|
||||
|
||||
{/* Subscription info box */}
|
||||
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
|
||||
<Badge className="bg-emerald-600 text-white">Full Paket</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
|
||||
<span className="text-sm font-medium">{t("subscription.onboarding.trialDuration")}</span>
|
||||
</div>
|
||||
{freshSub?.endDate && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.endDate")}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(freshSub.endDate).toLocaleDateString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => navigate({ to: "/dashboard/search" })}
|
||||
>
|
||||
{t("subscription.onboarding.startSearching")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Normal subscription page ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8">
|
||||
<h2 className="text-2xl font-bold">{t("subscription.title")}</h2>
|
||||
|
||||
{/* Active Subscription Status */}
|
||||
{subscription && (
|
||||
{subscription && !(eligibleForTrial && subscription.status === "trial") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -204,7 +400,11 @@ function SubscriptionPage() {
|
||||
</div>
|
||||
<CardDescription>
|
||||
{subscription.plan?.name} —{" "}
|
||||
{subscription.billingPeriod === "yearly" ? t("common.yearly") : t("common.monthly")}
|
||||
{subscription.status === "trial"
|
||||
? t("subscription.onboarding.trialDuration")
|
||||
: subscription.billingPeriod === "yearly"
|
||||
? t("common.yearly")
|
||||
: t("common.monthly")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -240,6 +440,16 @@ function SubscriptionPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subscription.status === "trial" && subscription.endDate && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{(() => {
|
||||
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
return `${days} gün kaldı`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{subscription.status === "active" && (
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||
@@ -282,8 +492,44 @@ function SubscriptionPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Trial CTA Card */}
|
||||
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
|
||||
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<CardHeader className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<CardTitle className="text-emerald-900 dark:text-emerald-100">
|
||||
{t("subscription.trialTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{t("subscription.trialDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative space-y-4">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => trialMutation.mutate()}
|
||||
disabled={trialMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{trialMutation.isPending ? t("common.loading") : t("subscription.startTrial")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Subscription Banner */}
|
||||
{!subscription && (
|
||||
{!eligibleForTrial && !subscription && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="mb-2 text-lg font-medium text-muted-foreground">
|
||||
@@ -419,3 +665,30 @@ function SubscriptionPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate component so Suspense boundary works for both Player + OnboardingProgress
|
||||
function OnboardingPlayer({
|
||||
isDark,
|
||||
stepLabels,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
stepLabels: string[];
|
||||
}) {
|
||||
return (
|
||||
<LazyPlayer
|
||||
component={LazyOnboardingProgress}
|
||||
inputProps={{ isDark, stepLabels }}
|
||||
durationInFrames={210}
|
||||
fps={30}
|
||||
compositionWidth={800}
|
||||
compositionHeight={200}
|
||||
autoPlay
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 600,
|
||||
aspectRatio: "800 / 200",
|
||||
}}
|
||||
controls={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function DemoPage() {
|
||||
|
||||
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
if (!res.ok) throw new Error("Bulunamadı");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SchemaDemo } from "@/remotion/SchemaDemo";
|
||||
import { DashboardDemo } from "@/remotion/DashboardDemo";
|
||||
import { EcommerceDemo } from "@/remotion/EcommerceDemo";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
// ─── DATA ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -330,6 +331,7 @@ export const Route = createFileRoute("/")({
|
||||
|
||||
function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [vin, setVin] = useState("");
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
@@ -369,7 +371,7 @@ function HomePage() {
|
||||
|
||||
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
if (!res.ok) throw new Error("Bulunamadı");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
@@ -439,19 +441,29 @@ function HomePage() {
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
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">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard/search">
|
||||
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
Panele Git
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
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">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
@@ -499,19 +511,29 @@ function HomePage() {
|
||||
Fiyatlar
|
||||
</Link>
|
||||
<Separator className="bg-border" />
|
||||
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-full border-border text-muted-foreground"
|
||||
>
|
||||
Giriş Yap
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full rounded-full bg-foreground text-background">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard/search" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full rounded-full bg-foreground text-background">
|
||||
Panele Git
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-full border-border text-muted-foreground"
|
||||
>
|
||||
Giriş Yap
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full rounded-full bg-foreground text-background">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
73
docker/nginx/sites/sase.tr.conf
Normal file
73
docker/nginx/sites/sase.tr.conf
Normal file
@@ -0,0 +1,73 @@
|
||||
server {
|
||||
server_name sase.tr;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Block sensitive paths
|
||||
location ~ /\.(git|env) {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ~ ^/(node_modules)/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
root /home/s/ss/apps/web/dist;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to NestJS backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Static assets with long cache
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback — all routes resolve to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/sase.tr/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/sase.tr/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = sase.tr) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name sase.tr;
|
||||
return 301 https://$host$request_uri;
|
||||
|
||||
|
||||
}
|
||||
@@ -19,12 +19,12 @@ server {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
|
||||
# CORS
|
||||
add_header Access-Control-Allow-Origin "https://v2.sase.tr" always;
|
||||
add_header Access-Control-Allow-Origin "https://sase.tr" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, Content-Type" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin "https://v2.sase.tr";
|
||||
add_header Access-Control-Allow-Origin "https://sase.tr";
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Range, Content-Type";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
|
||||
@@ -1,73 +1,15 @@
|
||||
server {
|
||||
server_name v2.sase.tr;
|
||||
return 301 https://sase.tr$request_uri;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Block sensitive paths
|
||||
location ~ /\.(git|env) {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ~ ^/(node_modules)/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
root /home/s/ss/apps/web/dist;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to NestJS backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Static assets with long cache
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback — all routes resolve to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/v2.sase.tr/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/v2.sase.tr/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/v2.sase.tr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/v2.sase.tr/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
server {
|
||||
if ($host = v2.sase.tr) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name v2.sase.tr;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
return 301 https://sase.tr$request_uri;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export const envSchema = z.object({
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
MINIO_ENDPOINT: z.string(),
|
||||
MINIO_ACCESS_KEY: z.string(),
|
||||
MINIO_SECRET_KEY: z.string(),
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -136,6 +136,9 @@ importers:
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1
|
||||
@@ -151,9 +154,9 @@ importers:
|
||||
remotion:
|
||||
specifier: ^4.0.422
|
||||
version: 4.0.422(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sonner:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sileo:
|
||||
specifier: ^0.0.7
|
||||
version: 0.0.7(lucide-react@0.468.0(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.1
|
||||
@@ -185,6 +188,9 @@ importers:
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.11
|
||||
@@ -2656,6 +2662,9 @@ packages:
|
||||
'@types/body-parser@1.19.6':
|
||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||
|
||||
'@types/canvas-confetti@1.9.0':
|
||||
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@@ -3075,6 +3084,9 @@ packages:
|
||||
caniuse-lite@1.0.30001769:
|
||||
resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==}
|
||||
|
||||
canvas-confetti@1.9.4:
|
||||
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4550,6 +4562,13 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
sileo@0.0.7:
|
||||
resolution: {integrity: sha512-AqWpkmNi6rfoyediBrSmhHgfGLEi3P9c1BjeWXpy2fXbt8WKxNR5AkRuwOq6GoVAI/uoMbFE4f5hAEk2pOJPzg==}
|
||||
peerDependencies:
|
||||
lucide-react: '>=0.300'
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
sonner@1.7.4:
|
||||
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
|
||||
peerDependencies:
|
||||
@@ -7566,6 +7585,8 @@ snapshots:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.19.11
|
||||
|
||||
'@types/canvas-confetti@1.9.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@@ -8038,6 +8059,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001769: {}
|
||||
|
||||
canvas-confetti@1.9.4: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
@@ -9513,6 +9536,12 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sileo@0.0.7(lucide-react@0.468.0(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
lucide-react: 0.468.0(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
sonner@1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
Reference in New Issue
Block a user