feat: sase.tr v2 full application implementation
Complete rewrite of sase.tr VIN lookup platform with modern stack: Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ): - 34 DB models (core + PL24 + EMEX schemas) - Auth via Better Auth (email/password + social) - Brands, Plans, Subscriptions, Payments (iyzico + EFT) - VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA) - Interactive schema viewer backend (MinIO storage) - EMEX scraping integration (Puppeteer + BullMQ workers) - Translation module (EN→TR automotive dictionary) - Admin dashboard API (stats, user mgmt, payment approval) - Rate limiting, Helmet security, file upload validation Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand): - 20 routes: auth, dashboard, VIN search, schema viewer, admin - Interactive schema viewer with zoom/pan/hotspot highlighting - Subscription management with brand selector - Payment flow (iyzico 3D Secure + EFT with receipt upload) - i18n support (TR/EN) - Error boundaries, loading skeletons, 404 page Infrastructure: - 85 tests (52 backend + 33 frontend, Vitest) - CI/CD (GitHub Actions: lint, typecheck, test, build, deploy) - Zero-downtime deploy script (PM2) - Env validation script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
packages/config/package.json
Normal file
26
packages/config/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@sase/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
56
packages/config/src/index.ts
Normal file
56
packages/config/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().default(4000),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
REDIS_HOST: z.string().default("127.0.0.1"),
|
||||
REDIS_PORT: z.coerce.number().default(6379),
|
||||
REDIS_PASSWORD: z.string(),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
|
||||
MINIO_ENDPOINT: z.string(),
|
||||
MINIO_ACCESS_KEY: z.string(),
|
||||
MINIO_SECRET_KEY: z.string(),
|
||||
MINIO_BUCKET_NAME: z.string().default("sase-schemas"),
|
||||
MINIO_PUBLIC_URL: z.string(),
|
||||
MINIO_USE_SSL: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
|
||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||
|
||||
IYZICO_API_KEY: z.string().optional(),
|
||||
IYZICO_SECRET_KEY: z.string().optional(),
|
||||
IYZICO_BASE_URL: z.string().optional(),
|
||||
|
||||
PL24_API_URL: z.string().optional(),
|
||||
PL24_USERNAME: z.string().optional(),
|
||||
PL24_PASSWORD: z.string().optional(),
|
||||
|
||||
EMEX_USERNAME: z.string().optional(),
|
||||
EMEX_PASSWORD: z.string().optional(),
|
||||
|
||||
ML_PREDICTION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(env: Record<string, unknown> = process.env as Record<string, unknown>): Env {
|
||||
const result = envSchema.safeParse(env);
|
||||
if (!result.success) {
|
||||
const formatted = result.error.format();
|
||||
console.error("Environment validation failed:");
|
||||
console.error(JSON.stringify(formatted, null, 2));
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
20
packages/config/tsconfig.json
Normal file
20
packages/config/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
26
packages/shared/package.json
Normal file
26
packages/shared/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@sase/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
39
packages/shared/src/constants/error-codes.ts
Normal file
39
packages/shared/src/constants/error-codes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const ERROR_CODES = {
|
||||
// Auth
|
||||
INVALID_CREDENTIALS: "AUTH_001",
|
||||
EMAIL_ALREADY_EXISTS: "AUTH_002",
|
||||
SESSION_EXPIRED: "AUTH_003",
|
||||
UNAUTHORIZED: "AUTH_004",
|
||||
FORBIDDEN: "AUTH_005",
|
||||
|
||||
// VIN
|
||||
INVALID_VIN: "VIN_001",
|
||||
VIN_DECODE_FAILED: "VIN_002",
|
||||
BRAND_NOT_SUPPORTED: "VIN_003",
|
||||
|
||||
// Subscription
|
||||
NO_ACTIVE_SUBSCRIPTION: "SUB_001",
|
||||
BRAND_ACCESS_DENIED: "SUB_002",
|
||||
INVALID_BRAND_COUNT: "SUB_003",
|
||||
SUBSCRIPTION_ALREADY_ACTIVE: "SUB_004",
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED: "PAY_001",
|
||||
IYZICO_ERROR: "PAY_002",
|
||||
EFT_RECEIPT_REQUIRED: "PAY_003",
|
||||
PAYMENT_ALREADY_PROCESSED: "PAY_004",
|
||||
|
||||
// General
|
||||
NOT_FOUND: "GEN_001",
|
||||
VALIDATION_ERROR: "GEN_002",
|
||||
INTERNAL_ERROR: "GEN_003",
|
||||
RATE_LIMITED: "GEN_004",
|
||||
CONFLICT: "GEN_005",
|
||||
|
||||
// Integration
|
||||
PL24_ERROR: "INT_001",
|
||||
EMEX_ERROR: "INT_002",
|
||||
CORGI_ERROR: "INT_003",
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
33
packages/shared/src/constants/plans.ts
Normal file
33
packages/shared/src/constants/plans.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PLANS = {
|
||||
SINGLE: {
|
||||
name: "1 Marka",
|
||||
brandCount: 1,
|
||||
priceMonthly: 200_00,
|
||||
priceYearly: 2000_00,
|
||||
},
|
||||
DOUBLE: {
|
||||
name: "2 Marka",
|
||||
brandCount: 2,
|
||||
priceMonthly: 350_00,
|
||||
priceYearly: 3500_00,
|
||||
},
|
||||
TRIPLE: {
|
||||
name: "3 Marka",
|
||||
brandCount: 3,
|
||||
priceMonthly: 500_00,
|
||||
priceYearly: 5000_00,
|
||||
},
|
||||
FULL: {
|
||||
name: "Full Paket",
|
||||
brandCount: 0,
|
||||
priceMonthly: 999_00,
|
||||
priceYearly: 9990_00,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const REFERRAL_REWARDS = {
|
||||
TIER_1: { count: 3, extensionDays: 7 },
|
||||
TIER_2: { count: 5, extensionDays: 30 },
|
||||
} as const;
|
||||
|
||||
export const CURRENCY = "TRY" as const;
|
||||
5
packages/shared/src/constants/regex.ts
Normal file
5
packages/shared/src/constants/regex.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const OEM_CODE_REGEX = /^[A-Z0-9\-.\s]{3,30}$/i;
|
||||
43
packages/shared/src/index.ts
Normal file
43
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Types
|
||||
export type { User, UserProfile, UserSubscriptionSummary } from "./types/user.js";
|
||||
export type { Vehicle, VinDecodeResult, CategoryNode, VehicleSource } from "./types/vehicle.js";
|
||||
export type { Brand } from "./types/brand.js";
|
||||
export type { Part, PartSearchResult, PartSource } from "./types/part.js";
|
||||
export type { Category, CategoryWithSchema, SchemaPic, Hotspot } from "./types/category.js";
|
||||
export type {
|
||||
Plan,
|
||||
Subscription,
|
||||
UserBrand,
|
||||
CreateSubscriptionInput,
|
||||
SubscriptionStatus,
|
||||
} from "./types/subscription.js";
|
||||
export type {
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
IyzicoInitializeInput,
|
||||
EftPaymentInput,
|
||||
} from "./types/payment.js";
|
||||
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
|
||||
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";
|
||||
|
||||
// Schemas
|
||||
export { vinSchema } from "./schemas/vin.js";
|
||||
export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema } from "./schemas/auth.js";
|
||||
export { paginationSchema } from "./schemas/pagination.js";
|
||||
|
||||
// Constants
|
||||
export { VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX } from "./constants/regex.js";
|
||||
export { PLANS, REFERRAL_REWARDS, CURRENCY } from "./constants/plans.js";
|
||||
export { ERROR_CODES } from "./constants/error-codes.js";
|
||||
export type { ErrorCode } from "./constants/error-codes.js";
|
||||
|
||||
// Utils
|
||||
export {
|
||||
isValidVin,
|
||||
validateVinCheckDigit,
|
||||
extractWmi,
|
||||
extractModelYear,
|
||||
} from "./utils/vin-validator.js";
|
||||
export { formatTRY, kurusToLira, liraToKurus } from "./utils/currency.js";
|
||||
export { formatVin, formatDate, formatDateTime, slugify, generateReferralCode } from "./utils/formatters.js";
|
||||
38
packages/shared/src/schemas/auth.ts
Normal file
38
packages/shared/src/schemas/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters").max(100),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
8
packages/shared/src/schemas/pagination.ts
Normal file
8
packages/shared/src/schemas/pagination.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type PaginationQuery = z.infer<typeof paginationSchema>;
|
||||
10
packages/shared/src/schemas/vin.ts
Normal file
10
packages/shared/src/schemas/vin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { VIN_REGEX } from "../constants/regex.js";
|
||||
|
||||
export const vinSchema = z
|
||||
.string()
|
||||
.length(17, "VIN must be exactly 17 characters")
|
||||
.regex(VIN_REGEX, "VIN contains invalid characters (I, O, Q are not allowed)")
|
||||
.transform((v) => v.toUpperCase());
|
||||
|
||||
export type VinInput = z.input<typeof vinSchema>;
|
||||
21
packages/shared/src/types/api-response.ts
Normal file
21
packages/shared/src/types/api-response.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
9
packages/shared/src/types/brand.ts
Normal file
9
packages/shared/src/types/brand.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
34
packages/shared/src/types/category.ts
Normal file
34
packages/shared/src/types/category.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentId: string | null;
|
||||
externalId: string | null;
|
||||
source: "pl24" | "emex";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CategoryWithSchema extends Category {
|
||||
schemaPics: SchemaPic[];
|
||||
}
|
||||
|
||||
export interface SchemaPic {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
imageUrl: string;
|
||||
hotspots: Hotspot[];
|
||||
source: "pl24" | "emex";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Hotspot {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
partId: string | null;
|
||||
shape: "rect" | "circle" | "polygon";
|
||||
points?: { x: number; y: number }[];
|
||||
}
|
||||
14
packages/shared/src/types/pagination.ts
Normal file
14
packages/shared/src/types/pagination.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface PaginationInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
26
packages/shared/src/types/part.ts
Normal file
26
packages/shared/src/types/part.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface Part {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
categoryId: string;
|
||||
oemCode: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
description: string | null;
|
||||
quantity: number | null;
|
||||
position: string | null;
|
||||
hotspotIndex: number | null;
|
||||
source: PartSource;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type PartSource = "pl24" | "emex";
|
||||
|
||||
export interface PartSearchResult {
|
||||
part: Part;
|
||||
vehicleInfo: {
|
||||
vin: string;
|
||||
brandName: string;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
};
|
||||
}
|
||||
30
packages/shared/src/types/payment.ts
Normal file
30
packages/shared/src/types/payment.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Payment {
|
||||
id: string;
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
iyzicoPaymentId: string | null;
|
||||
eftReceiptUrl: string | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type PaymentMethod = "iyzico" | "eft";
|
||||
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
|
||||
|
||||
export interface IyzicoInitializeInput {
|
||||
subscriptionId: string;
|
||||
cardHolderName: string;
|
||||
cardNumber: string;
|
||||
expireMonth: string;
|
||||
expireYear: string;
|
||||
cvc: string;
|
||||
}
|
||||
|
||||
export interface EftPaymentInput {
|
||||
subscriptionId: string;
|
||||
}
|
||||
38
packages/shared/src/types/subscription.ts
Normal file
38
packages/shared/src/types/subscription.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
brandCount: number;
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
planId: string;
|
||||
status: SubscriptionStatus;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
cancelledAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
|
||||
|
||||
export interface UserBrand {
|
||||
id: string;
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
brandId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionInput {
|
||||
planId: string;
|
||||
brandIds: string[];
|
||||
billingPeriod: "monthly" | "yearly";
|
||||
}
|
||||
31
packages/shared/src/types/user.ts
Normal file
31
packages/shared/src/types/user.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
role: "user" | "admin";
|
||||
referralCode: string | null;
|
||||
referredBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
role: "user" | "admin";
|
||||
referralCode: string | null;
|
||||
subscription: UserSubscriptionSummary | null;
|
||||
}
|
||||
|
||||
export interface UserSubscriptionSummary {
|
||||
planName: string;
|
||||
status: SubscriptionStatus;
|
||||
brands: string[];
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
|
||||
33
packages/shared/src/types/vehicle.ts
Normal file
33
packages/shared/src/types/vehicle.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
engine: string | null;
|
||||
transmission: string | null;
|
||||
bodyType: string | null;
|
||||
market: string | null;
|
||||
rawData: Record<string, unknown> | null;
|
||||
source: VehicleSource;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type VehicleSource = "pl24" | "emex" | "corgi" | "vin-api";
|
||||
|
||||
export interface VinDecodeResult {
|
||||
vehicle: Vehicle;
|
||||
categories: CategoryNode[];
|
||||
}
|
||||
|
||||
export interface CategoryNode {
|
||||
id: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentId: string | null;
|
||||
children: CategoryNode[];
|
||||
hasSchema: boolean;
|
||||
}
|
||||
18
packages/shared/src/utils/currency.ts
Normal file
18
packages/shared/src/utils/currency.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const TRY_FORMATTER = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatTRY(amountInKurus: number): string {
|
||||
return TRY_FORMATTER.format(amountInKurus / 100);
|
||||
}
|
||||
|
||||
export function kurusToLira(kurus: number): number {
|
||||
return kurus / 100;
|
||||
}
|
||||
|
||||
export function liraToKurus(lira: number): number {
|
||||
return Math.round(lira * 100);
|
||||
}
|
||||
45
packages/shared/src/utils/formatters.ts
Normal file
45
packages/shared/src/utils/formatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function formatVin(vin: string): string {
|
||||
return vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[çÇ]/g, "c")
|
||||
.replace(/[ğĞ]/g, "g")
|
||||
.replace(/[ıİ]/g, "i")
|
||||
.replace(/[öÖ]/g, "o")
|
||||
.replace(/[şŞ]/g, "s")
|
||||
.replace(/[üÜ]/g, "u")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function generateReferralCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
48
packages/shared/src/utils/vin-validator.ts
Normal file
48
packages/shared/src/utils/vin-validator.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { VIN_REGEX } from "../constants/regex.js";
|
||||
|
||||
const TRANSLITERATION: Record<string, number> = {
|
||||
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8,
|
||||
J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9,
|
||||
S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9,
|
||||
};
|
||||
|
||||
const POSITION_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
export function isValidVin(vin: string): boolean {
|
||||
if (!vin || vin.length !== 17) return false;
|
||||
const upper = vin.toUpperCase();
|
||||
return VIN_REGEX.test(upper);
|
||||
}
|
||||
|
||||
export function validateVinCheckDigit(vin: string): boolean {
|
||||
const upper = vin.toUpperCase();
|
||||
if (!isValidVin(upper)) return false;
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const char = upper[i];
|
||||
const value = /\d/.test(char) ? Number.parseInt(char, 10) : TRANSLITERATION[char];
|
||||
if (value === undefined) return false;
|
||||
sum += value * POSITION_WEIGHTS[i];
|
||||
}
|
||||
|
||||
const remainder = sum % 11;
|
||||
const checkChar = remainder === 10 ? "X" : String(remainder);
|
||||
return upper[8] === checkChar;
|
||||
}
|
||||
|
||||
export function extractWmi(vin: string): string {
|
||||
return vin.toUpperCase().slice(0, 3);
|
||||
}
|
||||
|
||||
export function extractModelYear(vin: string): number | null {
|
||||
const yearChar = vin.toUpperCase()[9];
|
||||
const yearMap: Record<string, number> = {
|
||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014, F: 2015, G: 2016, H: 2017,
|
||||
J: 2018, K: 2019, L: 2020, M: 2021, N: 2022, P: 2023, R: 2024, S: 2025,
|
||||
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
||||
};
|
||||
return yearMap[yearChar] ?? null;
|
||||
}
|
||||
20
packages/shared/tsconfig.json
Normal file
20
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
37
packages/ui/package.json
Normal file
37
packages/ui/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@sase/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
30
packages/ui/src/badge.tsx
Normal file
30
packages/ui/src/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
46
packages/ui/src/button.tsx
Normal file
46
packages/ui/src/button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
46
packages/ui/src/card.tsx
Normal file
46
packages/ui/src/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
91
packages/ui/src/dialog.tsx
Normal file
91
packages/ui/src/dialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
28
packages/ui/src/index.ts
Normal file
28
packages/ui/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { cn } from "./utils";
|
||||
export { Button, buttonVariants, type ButtonProps } from "./button";
|
||||
export { Input } from "./input";
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "./card";
|
||||
export { Badge, badgeVariants } from "./badge";
|
||||
export { Skeleton } from "./skeleton";
|
||||
export { Label } from "./label";
|
||||
export { Separator } from "./separator";
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "./dialog";
|
||||
21
packages/ui/src/input.tsx
Normal file
21
packages/ui/src/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
18
packages/ui/src/label.tsx
Normal file
18
packages/ui/src/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
23
packages/ui/src/separator.tsx
Normal file
23
packages/ui/src/separator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
7
packages/ui/src/skeleton.tsx
Normal file
7
packages/ui/src/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
52
packages/ui/src/tabs.tsx
Normal file
52
packages/ui/src/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
6
packages/ui/src/utils.ts
Normal file
6
packages/ui/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
21
packages/ui/tsconfig.json
Normal file
21
packages/ui/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user