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:
Sase Dev
2026-02-12 02:03:56 +00:00
parent 7fc47ce9cc
commit 56a3c8bfaa
215 changed files with 25043 additions and 0 deletions

View 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"
}
}

View 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;
}

View 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"]
}

View 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"
}
}

View 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];

View 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;

View 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;

View 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";

View 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>;

View 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>;

View 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>;

View 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;
}

View File

@@ -0,0 +1,9 @@
export interface Brand {
id: string;
name: string;
slug: string;
logoUrl: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}

View 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 }[];
}

View 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;
};
}

View 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;
};
}

View 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;
}

View 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";
}

View 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";

View 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;
}

View 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);
}

View 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;
}

View 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;
}

View 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
View 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
View 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 };

View 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
View 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 };

View 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
View 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
View 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
View 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 };

View 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 };

View 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
View 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
View 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
View 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"]
}