commit 61112bc8eda6c6553647b38890e69e277f1c2f33 Author: susan199817-spec Date: Fri Mar 27 02:46:26 2026 +0000 Fresh start without large files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12bc7fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc +*.tsbuildinfo +.expo +.expo-shared + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md + +# Replit +.cache/ +.local/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..61e34c2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=false +strict-peer-dependencies=false diff --git a/.replit b/.replit new file mode 100644 index 0000000..9b3bce7 --- /dev/null +++ b/.replit @@ -0,0 +1,36 @@ +modules = ["nodejs-24", "postgresql-16"] + +[[artifacts]] +id = "artifacts/api-server" + +[[artifacts]] +id = "artifacts/mockup-sandbox" + +[deployment] +router = "application" +deploymentTarget = "autoscale" + +[deployment.postBuild] +args = ["pnpm", "store", "prune"] +env = { "CI" = "true" } + +[workflows] +runButton = "Project" + +[agent] +stack = "PNPM_WORKSPACE" +expertMode = true + +[postMerge] +path = "scripts/post-merge.sh" +timeoutMs = 20000 + +[nix] +channel = "stable-25_05" + +[userenv] + +[userenv.shared] + +[[ports]] +localPort = 8080 diff --git a/.replitignore b/.replitignore new file mode 100644 index 0000000..9eb019c --- /dev/null +++ b/.replitignore @@ -0,0 +1,5 @@ +# The format of this file is identical to `.dockerignore`. +# It is used to reduce the size of deployed images to make the process of publishing faster. + +# No need to store the pnpm store twice. +.local diff --git a/artifacts/api-server/.replit-artifact/artifact.toml b/artifacts/api-server/.replit-artifact/artifact.toml new file mode 100644 index 0000000..814b9ad --- /dev/null +++ b/artifacts/api-server/.replit-artifact/artifact.toml @@ -0,0 +1,32 @@ +kind = "api" +previewPath = "/api" # TODO - should be excluded from preview in the first place +title = "API Server" +version = "1.0.0" +id = "3B4_FFSkEVBkAeYMFRJ2e" + +[[services]] +localPort = 8080 +name = "API Server" +paths = ["/api"] + +[services.development] +run = "pnpm --filter @workspace/api-server run dev" + +[services.production] + +[services.production.build] +args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"] + +[services.production.build.env] +NODE_ENV = "production" + +[services.production.run] +# we don't run through pnpm to make startup faster in production +args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"] + +[services.production.run.env] +PORT = "8080" +NODE_ENV = "production" + +[services.production.health.startup] +path = "/api/healthz" diff --git a/artifacts/api-server/build.mjs b/artifacts/api-server/build.mjs new file mode 100644 index 0000000..86ebf7f --- /dev/null +++ b/artifacts/api-server/build.mjs @@ -0,0 +1,126 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build as esbuild } from "esbuild"; +import esbuildPluginPino from "esbuild-plugin-pino"; +import { rm } from "node:fs/promises"; + +// Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies +globalThis.require = createRequire(import.meta.url); + +const artifactDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildAll() { + const distDir = path.resolve(artifactDir, "dist"); + await rm(distDir, { recursive: true, force: true }); + + await esbuild({ + entryPoints: [path.resolve(artifactDir, "src/index.ts")], + platform: "node", + bundle: true, + format: "esm", + outdir: distDir, + outExtension: { ".js": ".mjs" }, + logLevel: "info", + // Some packages may not be bundleable, so we externalize them, we can add more here as needed. + // Some of the packages below may not be imported or installed, but we're adding them in case they are in the future. + // Examples of unbundleable packages: + // - uses native modules and loads them dynamically (e.g. sharp) + // - use path traversal to read files (e.g. @google-cloud/secret-manager loads sibling .proto files) + external: [ + "*.node", + "sharp", + "better-sqlite3", + "sqlite3", + "canvas", + "bcrypt", + "argon2", + "fsevents", + "re2", + "farmhash", + "xxhash-addon", + "bufferutil", + "utf-8-validate", + "ssh2", + "cpu-features", + "dtrace-provider", + "isolated-vm", + "lightningcss", + "pg-native", + "oracledb", + "mongodb-client-encryption", + "nodemailer", + "handlebars", + "knex", + "typeorm", + "protobufjs", + "onnxruntime-node", + "@tensorflow/*", + "@prisma/client", + "@mikro-orm/*", + "@grpc/*", + "@swc/*", + "@aws-sdk/*", + "@azure/*", + "@opentelemetry/*", + "@google-cloud/*", + "@google/*", + "googleapis", + "firebase-admin", + "@parcel/watcher", + "@sentry/profiling-node", + "@tree-sitter/*", + "aws-sdk", + "classic-level", + "dd-trace", + "ffi-napi", + "grpc", + "hiredis", + "kerberos", + "leveldown", + "miniflare", + "mysql2", + "newrelic", + "odbc", + "piscina", + "realm", + "ref-napi", + "rocksdb", + "sass-embedded", + "sequelize", + "serialport", + "snappy", + "tinypool", + "usb", + "workerd", + "wrangler", + "zeromq", + "zeromq-prebuilt", + "playwright", + "puppeteer", + "puppeteer-core", + "electron", + ], + sourcemap: "linked", + plugins: [ + // pino relies on workers to handle logging, instead of externalizing it we use a plugin to handle it + esbuildPluginPino({ transports: ["pino-pretty"] }) + ], + // Make sure packages that are cjs only (e.g. express) but are bundled continue to work in our esm output file + banner: { + js: `import { createRequire as __bannerCrReq } from 'node:module'; +import __bannerPath from 'node:path'; +import __bannerUrl from 'node:url'; + +globalThis.require = __bannerCrReq(import.meta.url); +globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url); +globalThis.__dirname = __bannerPath.dirname(globalThis.__filename); + `, + }, + }); +} + +buildAll().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json new file mode 100644 index 0000000..1c15a36 --- /dev/null +++ b/artifacts/api-server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@workspace/api-server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", + "build": "node ./build.mjs", + "start": "node --enable-source-maps ./dist/index.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@workspace/api-zod": "workspace:*", + "@workspace/db": "workspace:*", + "cookie-parser": "^1.4.7", + "cors": "^2", + "drizzle-orm": "catalog:", + "express": "^5", + "pino": "^9", + "pino-http": "^10" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.10", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "catalog:", + "esbuild": "^0.27.3", + "esbuild-plugin-pino": "^2.3.3", + "pino-pretty": "^13", + "thread-stream": "3.1.0" + } +} diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts new file mode 100644 index 0000000..f32f71e --- /dev/null +++ b/artifacts/api-server/src/app.ts @@ -0,0 +1,34 @@ +import express, { type Express } from "express"; +import cors from "cors"; +import pinoHttp from "pino-http"; +import router from "./routes"; +import { logger } from "./lib/logger"; + +const app: Express = express(); + +app.use( + pinoHttp({ + logger, + serializers: { + req(req) { + return { + id: req.id, + method: req.method, + url: req.url?.split("?")[0], + }; + }, + res(res) { + return { + statusCode: res.statusCode, + }; + }, + }, + }), +); +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.use("/api", router); + +export default app; diff --git a/artifacts/api-server/src/config/services.ts b/artifacts/api-server/src/config/services.ts new file mode 100644 index 0000000..64f0104 --- /dev/null +++ b/artifacts/api-server/src/config/services.ts @@ -0,0 +1,81 @@ +/** + * Services Configuration + * Reads API keys and credentials from environment secrets. + */ + +function resolveCloudinary() { + const urlRaw = process.env["CLOUDINARY_URL"] ?? ""; + const apiKey = (process.env["CLOUDINARY_API_KEY"] ?? "").trim(); + const apiSecret = (process.env["CLOUDINARY_API_SECRET"] ?? "").trim(); + + // If separate key/secret env vars are set, use them with known cloud name from URL + if (apiKey && apiSecret) { + const cloudName = (() => { + const norm = normalizeCloudinaryUrl(urlRaw); + const m = norm.match(/@([^/?]+)$/); + return m ? m[1] : "dj5vxragc"; + })(); + return { + url: `cloudinary://${apiKey}:${apiSecret}@${cloudName}`, + parsed: { apiKey, apiSecret, cloudName }, + }; + } + + // Fall back to parsing CLOUDINARY_URL + const parsed = parseCloudinaryUrl(urlRaw); + return { url: urlRaw, parsed }; +} + +export const servicesConfig = { + rapidApi: { + key: process.env["RAPID_API_KEY"] ?? "", + host: "https://api.rapidapi.com", + }, + serpApi: { + key: process.env["SERP_API_KEY"] ?? "", + host: "https://serpapi.com", + }, + cloudinary: resolveCloudinary(), + apify: { + token: process.env["APIFY_TOKEN"] ?? "", + host: "https://api.apify.com", + }, + database: { + url: process.env["DATABASE_URL"] ?? "", + }, +}; + +function normalizeCloudinaryUrl(raw: string): string { + const s = raw.trim(); + const eqIdx = s.indexOf("="); + if (eqIdx > -1 && eqIdx < 30) return s.slice(eqIdx + 1).trim(); + return s; +} + +function parseCloudinaryUrl(url: string) { + if (!url) return null; + try { + const trimmed = normalizeCloudinaryUrl(url); + const match = trimmed.match(/^cloudinary:\/\/([^:]+):([^@]+)@([^/?]+)/); + if (match) { + return { apiKey: match[1], apiSecret: match[2], cloudName: match[3] }; + } + const httpsMatch = trimmed.match(/^https?:\/\/([^:]+):([^@]+)@api\.cloudinary\.com\/v1_1\/([^/?]+)/); + if (httpsMatch) { + return { apiKey: httpsMatch[1], apiSecret: httpsMatch[2], cloudName: httpsMatch[3] }; + } + return null; + } catch { + return null; + } +} + +export function validateServicesConfig() { + const issues: string[] = []; + if (!servicesConfig.rapidApi.key) issues.push("RAPID_API_KEY is missing"); + if (!servicesConfig.serpApi.key) issues.push("SERP_API_KEY is missing"); + if (!servicesConfig.cloudinary.parsed) issues.push("CLOUDINARY credentials are missing or invalid"); + if (!servicesConfig.apify.token) issues.push("APIFY_TOKEN is missing"); + if (!servicesConfig.database.url) issues.push("DATABASE_URL is missing"); + return { valid: issues.length === 0, issues }; +} diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts new file mode 100644 index 0000000..b1f024d --- /dev/null +++ b/artifacts/api-server/src/index.ts @@ -0,0 +1,25 @@ +import app from "./app"; +import { logger } from "./lib/logger"; + +const rawPort = process.env["PORT"]; + +if (!rawPort) { + throw new Error( + "PORT environment variable is required but was not provided.", + ); +} + +const port = Number(rawPort); + +if (Number.isNaN(port) || port <= 0) { + throw new Error(`Invalid PORT value: "${rawPort}"`); +} + +app.listen(port, (err) => { + if (err) { + logger.error({ err }, "Error listening on port"); + process.exit(1); + } + + logger.info({ port }, "Server listening"); +}); diff --git a/artifacts/api-server/src/lib/.gitkeep b/artifacts/api-server/src/lib/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/api-server/src/lib/integration-tests.ts b/artifacts/api-server/src/lib/integration-tests.ts new file mode 100644 index 0000000..df705fd --- /dev/null +++ b/artifacts/api-server/src/lib/integration-tests.ts @@ -0,0 +1,225 @@ +import { servicesConfig } from "../config/services"; +import { db } from "@workspace/db"; +import { sql } from "drizzle-orm"; + +export interface ServiceTestResult { + service: string; + connected: boolean; + message: string; + details?: Record; + latencyMs?: number; +} + +async function timed(fn: () => Promise): Promise<{ result: T; ms: number }> { + const start = Date.now(); + const result = await fn(); + return { result, ms: Date.now() - start }; +} + +export async function testRapidApi(): Promise { + const service = "RapidAPI"; + const key = servicesConfig.rapidApi.key; + if (!key) { + return { service, connected: false, message: "RAPID_API_KEY is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch("https://currency-exchange.p.rapidapi.com/listquotes", { + method: "GET", + headers: { + "X-RapidAPI-Key": key, + "X-RapidAPI-Host": "currency-exchange.p.rapidapi.com", + }, + signal: AbortSignal.timeout(8000), + }) + ); + if (result.status === 401) { + return { service, connected: false, message: "RapidAPI key is invalid — HTTP 401", latencyMs: ms }; + } + if (result.status === 403) { + return { + service, + connected: true, + message: "RapidAPI Connected ✅ (key valid — no subscription to test endpoint)", + details: { httpStatus: 403, note: "Key is authenticated; subscribe to an API to use it" }, + latencyMs: ms, + }; + } + if (result.ok) { + return { + service, + connected: true, + message: "RapidAPI Connected ✅", + details: { httpStatus: result.status }, + latencyMs: ms, + }; + } + return { service, connected: false, message: `RapidAPI unexpected response — HTTP ${result.status}`, latencyMs: ms }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `RapidAPI Error: ${errMsg}` }; + } +} + +export async function testSerpApi(): Promise { + const service = "SerpAPI"; + const key = servicesConfig.serpApi.key; + if (!key) { + return { service, connected: false, message: "SERP_API_KEY is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch(`https://serpapi.com/account?api_key=${key}`, { + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as Record; + return { + service, + connected: true, + message: "SerpAPI Connected ✅", + details: { + plan: data["plan_name"] ?? "unknown", + searches_left: data["searches_this_month_used"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + const text = await result.text(); + return { + service, + connected: false, + message: `SerpAPI key rejected — HTTP ${result.status}`, + details: { error: text.substring(0, 200) }, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `SerpAPI Error: ${errMsg}` }; + } +} + +export async function testCloudinary(): Promise { + const service = "Cloudinary"; + const parsed = servicesConfig.cloudinary.parsed; + if (!parsed) { + return { + service, + connected: false, + message: "CLOUDINARY_URL is missing or invalid format (expected: cloudinary://api_key:api_secret@cloud_name)", + }; + } + try { + const { cloudName, apiKey, apiSecret } = parsed; + const credentials = Buffer.from(`${apiKey}:${apiSecret}`).toString("base64"); + const { result, ms } = await timed(() => + fetch(`https://api.cloudinary.com/v1_1/${cloudName}/usage`, { + headers: { Authorization: `Basic ${credentials}` }, + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as Record; + return { + service, + connected: true, + message: "Cloudinary Connected ✅", + details: { + cloud_name: cloudName, + plan: data["plan"] ?? "unknown", + storage: data["storage"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + return { + service, + connected: false, + message: `Cloudinary auth failed — HTTP ${result.status}`, + details: { cloud_name: cloudName }, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Cloudinary Error: ${errMsg}` }; + } +} + +export async function testApify(): Promise { + const service = "Apify"; + const token = servicesConfig.apify.token; + if (!token) { + return { service, connected: false, message: "APIFY_TOKEN is not configured" }; + } + try { + const { result, ms } = await timed(() => + fetch(`https://api.apify.com/v2/users/me?token=${token}`, { + signal: AbortSignal.timeout(8000), + }) + ); + if (result.ok) { + const data = await result.json() as { data?: Record }; + const user = data.data ?? {}; + return { + service, + connected: true, + message: "Apify Connected ✅", + details: { + username: user["username"] ?? "unknown", + email: user["email"] ?? "unknown", + plan: user["plan"] ?? "unknown", + }, + latencyMs: ms, + }; + } else { + return { + service, + connected: false, + message: `Apify token rejected — HTTP ${result.status}`, + latencyMs: ms, + }; + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Apify Error: ${errMsg}` }; + } +} + +export async function testDatabase(): Promise { + const service = "PostgreSQL Database"; + try { + const { result, ms } = await timed(async () => { + const rows = await db.execute(sql`SELECT current_database() as db_name, current_user as db_user, version() as db_version`); + return rows; + }); + const row = result.rows[0] as Record | undefined; + return { + service, + connected: true, + message: "PostgreSQL Database Connected ✅", + details: { + database: row?.["db_name"] ?? "unknown", + user: row?.["db_user"] ?? "unknown", + version: String(row?.["db_version"] ?? "").split(" ").slice(0, 2).join(" "), + }, + latencyMs: ms, + }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + return { service, connected: false, message: `Database Error: ${errMsg}` }; + } +} + +export async function runAllIntegrationTests(): Promise { + const [rapidApi, serpApi, cloudinary, apify, database] = await Promise.all([ + testRapidApi(), + testSerpApi(), + testCloudinary(), + testApify(), + testDatabase(), + ]); + return [database, rapidApi, serpApi, cloudinary, apify]; +} diff --git a/artifacts/api-server/src/lib/logger.ts b/artifacts/api-server/src/lib/logger.ts new file mode 100644 index 0000000..d9c67f7 --- /dev/null +++ b/artifacts/api-server/src/lib/logger.ts @@ -0,0 +1,20 @@ +import pino from "pino"; + +const isProduction = process.env.NODE_ENV === "production"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + redact: [ + "req.headers.authorization", + "req.headers.cookie", + "res.headers['set-cookie']", + ], + ...(isProduction + ? {} + : { + transport: { + target: "pino-pretty", + options: { colorize: true }, + }, + }), +}); diff --git a/artifacts/api-server/src/lib/shein-scraper.ts b/artifacts/api-server/src/lib/shein-scraper.ts new file mode 100644 index 0000000..83ee413 --- /dev/null +++ b/artifacts/api-server/src/lib/shein-scraper.ts @@ -0,0 +1,269 @@ +import { servicesConfig } from "../config/services"; + +export interface SheinCategory { + name_ar: string; + name_en: string; + slug: string; + shein_cat_id: string; + shein_url: string; + level: number; + parent_slug?: string; + icon?: string; + sort_order: number; +} + +export interface SheinScraperResult { + success: boolean; + categories: SheinCategory[]; + totalCount: number; + error?: string; + runId?: string; +} + +async function pollApifyRun(runId: string, token: string, maxWaitMs = 120000): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < maxWaitMs) { + await new Promise(r => setTimeout(r, 5000)); + const res = await fetch(`https://api.apify.com/v2/actor-runs/${runId}?token=${token}`); + const data = await res.json() as { data?: { status?: string; defaultDatasetId?: string } }; + const status = data?.data?.status; + if (status === "SUCCEEDED") return data.data?.defaultDatasetId ?? ""; + if (status === "FAILED" || status === "TIMED-OUT" || status === "ABORTED") { + throw new Error(`Apify run ${status}`); + } + } + throw new Error("Apify run timed out"); +} + +export async function fetchSheinCategories(): Promise { + const token = servicesConfig.apify.token; + if (!token) { + return { success: false, categories: [], totalCount: 0, error: "APIFY_TOKEN not configured" }; + } + + const pageFunction = ` +async function pageFunction(context) { + const { page, log } = context; + log.info('Loading Shein SA page...'); + + await page.waitForTimeout(6000); + + const data = await page.evaluate(() => { + const result = { sections: [], allLinks: [] }; + + // Try to extract from __NUXT__ or window state + try { + const scripts = Array.from(document.querySelectorAll('script')); + for (const s of scripts) { + const text = s.textContent || ''; + if (text.includes('cat_id') && text.includes('cat_name') && text.length > 1000) { + const matches = [...text.matchAll(/"cat_id"\s*:\s*"?(\d+)"?\s*,\s*"cat_name"\s*:\s*"([^"]+)"/g)]; + matches.forEach(m => { + result.allLinks.push({ id: m[1], name: m[2] }); + }); + } + } + } catch(e) {} + + // Extract navigation items + const navSelectors = [ + '.j-nav-cate-main', + '[class*="nav-cate"]', + '[class*="cate-level1"]', + '.header-nav__cate', + '[class*="header-nav"] li', + 'nav li a', + ]; + + for (const sel of navSelectors) { + const items = document.querySelectorAll(sel); + if (items.length > 2) { + items.forEach((item, i) => { + const link = item.tagName === 'A' ? item : item.querySelector('a'); + const name = (link || item).textContent?.trim()?.replace(/\\s+/g, ' '); + const href = link?.getAttribute('href') || ''; + const catIdMatch = href.match(/cat_id=(\d+)|\/(\d+)\.html/); + const catId = catIdMatch ? (catIdMatch[1] || catIdMatch[2]) : String(i); + if (name && name.length > 0 && name.length < 60) { + result.sections.push({ name, href, catId, selector: sel }); + } + }); + if (result.sections.length > 3) break; + } + } + + return result; + }); + + return data; +} +`; + + try { + const startRes = await fetch( + `https://api.apify.com/v2/acts/apify~puppeteer-scraper/runs?token=${token}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + startUrls: [{ url: "https://sa.shein.com/category-list.html" }], + pageFunction, + proxyConfiguration: { useApifyProxy: true }, + maxConcurrency: 1, + maxRequestsPerCrawl: 1, + handlePageTimeoutSecs: 30, + navigationTimeoutSecs: 30, + browserLog: false, + stealth: true, + memory: 512, + }), + } + ); + + if (!startRes.ok) { + const err = await startRes.text(); + return { success: false, categories: [], totalCount: 0, error: `Failed to start Apify run: ${err}` }; + } + + const runData = await startRes.json() as { data?: { id?: string } }; + const runId = runData?.data?.id; + if (!runId) return { success: false, categories: [], totalCount: 0, error: "No run ID returned" }; + + const datasetId = await pollApifyRun(runId, token, 120000); + if (!datasetId) return { success: false, categories: [], totalCount: 0, error: "No dataset returned" }; + + const itemsRes = await fetch( + `https://api.apify.com/v2/datasets/${datasetId}/items?token=${token}&limit=1000` + ); + const items = await itemsRes.json() as Array>; + + const categories = parseSheinScraperResults(items); + + return { success: true, categories, totalCount: categories.length, runId }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, categories: [], totalCount: 0, error: msg }; + } +} + +function parseSheinScraperResults(items: Array>): SheinCategory[] { + const categories: SheinCategory[] = []; + const seen = new Set(); + let sortOrder = 0; + + for (const item of items) { + const sections = (item["sections"] as Array<{ name: string; href: string; catId: string }>) ?? []; + const allLinks = (item["allLinks"] as Array<{ id: string; name: string }>) ?? []; + + for (const sec of sections) { + const key = sec.catId || sec.name; + if (seen.has(key)) continue; + seen.add(key); + + const isArabic = /[\u0600-\u06FF]/.test(sec.name); + const slug = slugify(sec.name); + + categories.push({ + name_ar: isArabic ? sec.name : sec.name, + name_en: isArabic ? "" : sec.name, + slug, + shein_cat_id: sec.catId ?? "", + shein_url: sec.href ?? "", + level: 1, + sort_order: sortOrder++, + }); + } + + for (const link of allLinks) { + const key = link.id; + if (seen.has(key)) continue; + seen.add(key); + const slug = slugify(link.name); + categories.push({ + name_ar: link.name, + name_en: "", + slug, + shein_cat_id: link.id, + shein_url: "", + level: 2, + sort_order: sortOrder++, + }); + } + } + + return categories; +} + +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[\u0600-\u06FF]/g, c => c) + .replace(/[^a-z0-9\u0600-\u06FF]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60); +} + +export const SHEIN_CATEGORIES_PRESET: SheinCategory[] = [ + { name_ar: "نساء", name_en: "Women", slug: "women", shein_cat_id: "2030", shein_url: "/Women-sc-00212570.html", level: 1, sort_order: 1 }, + { name_ar: "ملابس نسائية", name_en: "Women Clothing", slug: "women-clothing", shein_cat_id: "1980", shein_url: "/Women-Clothing-sc-00212573.html", level: 2, parent_slug: "women", sort_order: 2 }, + { name_ar: "فساتين", name_en: "Dresses", slug: "dresses", shein_cat_id: "1727", shein_url: "/Dresses-sc-00212535.html", level: 2, parent_slug: "women", sort_order: 3 }, + { name_ar: "بلوزات وتيشرتات", name_en: "Tops & Tees", slug: "tops-tees", shein_cat_id: "1733", shein_url: "/Tops-Tees-sc-00212540.html", level: 2, parent_slug: "women", sort_order: 4 }, + { name_ar: "بناطيل وسراويل", name_en: "Pants & Capris", slug: "pants-capris", shein_cat_id: "1741", shein_url: "/Pants-Capris-sc-00212543.html", level: 2, parent_slug: "women", sort_order: 5 }, + { name_ar: "تنانير", name_en: "Skirts", slug: "skirts", shein_cat_id: "1742", shein_url: "/Skirts-sc-00212544.html", level: 2, parent_slug: "women", sort_order: 6 }, + { name_ar: "جاكيتات ومعاطف", name_en: "Jackets & Coats", slug: "jackets-coats", shein_cat_id: "1751", shein_url: "/Jackets-Coats-sc-00212547.html", level: 2, parent_slug: "women", sort_order: 7 }, + { name_ar: "أبايات", name_en: "Abayas", slug: "abayas", shein_cat_id: "3416", shein_url: "/Abayas-sc-01009296.html", level: 2, parent_slug: "women", sort_order: 8 }, + { name_ar: "أحذية نسائية", name_en: "Women Shoes", slug: "women-shoes", shein_cat_id: "2035", shein_url: "/Women-Shoes-sc-00212583.html", level: 2, parent_slug: "women", sort_order: 9 }, + { name_ar: "حقائب نسائية", name_en: "Women Bags", slug: "women-bags", shein_cat_id: "2037", shein_url: "/Women-Bags-sc-00212585.html", level: 2, parent_slug: "women", sort_order: 10 }, + { name_ar: "إكسسوارات نسائية", name_en: "Women Accessories", slug: "women-accessories", shein_cat_id: "2028", shein_url: "/Women-Accessories-sc-00212577.html", level: 2, parent_slug: "women", sort_order: 11 }, + { name_ar: "ملابس داخلية", name_en: "Lingerie & Lounge", slug: "lingerie-lounge", shein_cat_id: "1758", shein_url: "/Lingerie-Lounge-sc-00212549.html", level: 2, parent_slug: "women", sort_order: 12 }, + { name_ar: "رجال", name_en: "Men", slug: "men", shein_cat_id: "2035", shein_url: "/Men-sc-00212575.html", level: 1, sort_order: 13 }, + { name_ar: "ملابس رجالية", name_en: "Men Clothing", slug: "men-clothing", shein_cat_id: "1979", shein_url: "/Men-Clothing-sc-00212576.html", level: 2, parent_slug: "men", sort_order: 14 }, + { name_ar: "تيشرتات رجالية", name_en: "Men T-Shirts", slug: "men-tshirts", shein_cat_id: "1984", shein_url: "/Men-Tshirts-sc-00212578.html", level: 2, parent_slug: "men", sort_order: 15 }, + { name_ar: "بناطيل رجالية", name_en: "Men Pants", slug: "men-pants", shein_cat_id: "1989", shein_url: "/Men-Pants-sc-00212581.html", level: 2, parent_slug: "men", sort_order: 16 }, + { name_ar: "أحذية رجالية", name_en: "Men Shoes", slug: "men-shoes", shein_cat_id: "2042", shein_url: "/Men-Shoes-sc-00212588.html", level: 2, parent_slug: "men", sort_order: 17 }, + { name_ar: "حقائب رجالية", name_en: "Men Bags", slug: "men-bags", shein_cat_id: "2047", shein_url: "/Men-Bags-sc-00212589.html", level: 2, parent_slug: "men", sort_order: 18 }, + { name_ar: "إكسسوارات رجالية", name_en: "Men Accessories", slug: "men-accessories", shein_cat_id: "2034", shein_url: "/Men-Accessories-sc-00212584.html", level: 2, parent_slug: "men", sort_order: 19 }, + { name_ar: "أطفال", name_en: "Kids", slug: "kids", shein_cat_id: "2061", shein_url: "/Kids-sc-00212594.html", level: 1, sort_order: 20 }, + { name_ar: "بنات", name_en: "Girls", slug: "girls", shein_cat_id: "2061", shein_url: "/Girls-sc-00212595.html", level: 2, parent_slug: "kids", sort_order: 21 }, + { name_ar: "أولاد", name_en: "Boys", slug: "boys", shein_cat_id: "2062", shein_url: "/Boys-sc-00212596.html", level: 2, parent_slug: "kids", sort_order: 22 }, + { name_ar: "رضّع", name_en: "Baby", slug: "baby", shein_cat_id: "2063", shein_url: "/Baby-sc-00212597.html", level: 2, parent_slug: "kids", sort_order: 23 }, + { name_ar: "جمال وعناية", name_en: "Beauty", slug: "beauty", shein_cat_id: "2069", shein_url: "/Beauty-sc-00212600.html", level: 1, sort_order: 24 }, + { name_ar: "مستحضرات العناية بالبشرة", name_en: "Skin Care", slug: "skin-care", shein_cat_id: "2070", shein_url: "/Skin-Care-sc-00212601.html", level: 2, parent_slug: "beauty", sort_order: 25 }, + { name_ar: "مكياج", name_en: "Makeup", slug: "makeup", shein_cat_id: "2071", shein_url: "/Makeup-sc-00212602.html", level: 2, parent_slug: "beauty", sort_order: 26 }, + { name_ar: "العناية بالشعر", name_en: "Hair Care", slug: "hair-care", shein_cat_id: "2072", shein_url: "/Hair-Care-sc-00212603.html", level: 2, parent_slug: "beauty", sort_order: 27 }, + { name_ar: "عطور", name_en: "Fragrances", slug: "fragrances", shein_cat_id: "2073", shein_url: "/Fragrances-sc-00212604.html", level: 2, parent_slug: "beauty", sort_order: 28 }, + { name_ar: "أدوات التجميل", name_en: "Beauty Tools", slug: "beauty-tools", shein_cat_id: "2074", shein_url: "/Beauty-Tools-sc-00212605.html", level: 2, parent_slug: "beauty", sort_order: 29 }, + { name_ar: "منزل وديكور", name_en: "Home & Living", slug: "home-living", shein_cat_id: "2077", shein_url: "/Home-Living-sc-00212607.html", level: 1, sort_order: 30 }, + { name_ar: "غرفة المعيشة", name_en: "Living Room", slug: "living-room", shein_cat_id: "2078", shein_url: "/Living-Room-sc-00212608.html", level: 2, parent_slug: "home-living", sort_order: 31 }, + { name_ar: "غرفة النوم", name_en: "Bedroom", slug: "bedroom", shein_cat_id: "2079", shein_url: "/Bedroom-sc-00212609.html", level: 2, parent_slug: "home-living", sort_order: 32 }, + { name_ar: "المطبخ وتناول الطعام", name_en: "Kitchen & Dining", slug: "kitchen-dining", shein_cat_id: "2080", shein_url: "/Kitchen-Dining-sc-00212610.html", level: 2, parent_slug: "home-living", sort_order: 33 }, + { name_ar: "ديكور المنزل", name_en: "Home Decor", slug: "home-decor", shein_cat_id: "2081", shein_url: "/Home-Decor-sc-00212611.html", level: 2, parent_slug: "home-living", sort_order: 34 }, + { name_ar: "الحمام", name_en: "Bath", slug: "bath", shein_cat_id: "2082", shein_url: "/Bath-sc-00212612.html", level: 2, parent_slug: "home-living", sort_order: 35 }, + { name_ar: "الإضاءة", name_en: "Lighting", slug: "lighting", shein_cat_id: "2083", shein_url: "/Lighting-sc-00212613.html", level: 2, parent_slug: "home-living", sort_order: 36 }, + { name_ar: "رياضة وهواء طلق", name_en: "Sports & Outdoors", slug: "sports-outdoors", shein_cat_id: "2084", shein_url: "/Sports-Outdoors-sc-00212614.html", level: 1, sort_order: 37 }, + { name_ar: "ملابس رياضية", name_en: "Sports Clothing", slug: "sports-clothing", shein_cat_id: "2085", shein_url: "/Sports-Clothing-sc-00212615.html", level: 2, parent_slug: "sports-outdoors", sort_order: 38 }, + { name_ar: "أحذية رياضية", name_en: "Sports Shoes", slug: "sports-shoes", shein_cat_id: "2086", shein_url: "/Sports-Shoes-sc-00212616.html", level: 2, parent_slug: "sports-outdoors", sort_order: 39 }, + { name_ar: "معدات رياضية", name_en: "Sports Equipment", slug: "sports-equipment", shein_cat_id: "2087", shein_url: "/Sports-Equipment-sc-00212617.html", level: 2, parent_slug: "sports-outdoors", sort_order: 40 }, + { name_ar: "إلكترونيات", name_en: "Electronics", slug: "electronics", shein_cat_id: "2091", shein_url: "/Electronics-sc-00212620.html", level: 1, sort_order: 41 }, + { name_ar: "إكسسوارات الهواتف", name_en: "Phone Accessories", slug: "phone-accessories", shein_cat_id: "2092", shein_url: "/Phone-Accessories-sc-00212621.html", level: 2, parent_slug: "electronics", sort_order: 42 }, + { name_ar: "إكسسوارات الكمبيوتر", name_en: "Computer Accessories", slug: "computer-accessories", shein_cat_id: "2093", shein_url: "/Computer-Accessories-sc-00212622.html", level: 2, parent_slug: "electronics", sort_order: 43 }, + { name_ar: "سماعات", name_en: "Headphones & Speakers", slug: "headphones-speakers", shein_cat_id: "2094", shein_url: "/Headphones-Speakers-sc-00212623.html", level: 2, parent_slug: "electronics", sort_order: 44 }, + { name_ar: "الألعاب والهوايات", name_en: "Toys & Hobbies", slug: "toys-hobbies", shein_cat_id: "2095", shein_url: "/Toys-Hobbies-sc-00212624.html", level: 1, sort_order: 45 }, + { name_ar: "الكتب والقرطاسية", name_en: "Books & Stationery", slug: "books-stationery", shein_cat_id: "2096", shein_url: "/Books-Stationery-sc-00212625.html", level: 2, parent_slug: "toys-hobbies", sort_order: 46 }, + { name_ar: "ألعاب أطفال", name_en: "Kids Toys", slug: "kids-toys", shein_cat_id: "2097", shein_url: "/Kids-Toys-sc-00212626.html", level: 2, parent_slug: "toys-hobbies", sort_order: 47 }, + { name_ar: "الحيوانات الأليفة", name_en: "Pet Supplies", slug: "pet-supplies", shein_cat_id: "2098", shein_url: "/Pet-Supplies-sc-00212627.html", level: 1, sort_order: 48 }, + { name_ar: "مستلزمات الكلاب", name_en: "Dog Supplies", slug: "dog-supplies", shein_cat_id: "2099", shein_url: "/Dog-Supplies-sc-00212628.html", level: 2, parent_slug: "pet-supplies", sort_order: 49 }, + { name_ar: "مستلزمات القطط", name_en: "Cat Supplies", slug: "cat-supplies", shein_cat_id: "2100", shein_url: "/Cat-Supplies-sc-00212629.html", level: 2, parent_slug: "pet-supplies", sort_order: 50 }, + { name_ar: "سيارات", name_en: "Automotive", slug: "automotive", shein_cat_id: "2101", shein_url: "/Automotive-sc-00212630.html", level: 1, sort_order: 51 }, + { name_ar: "إكسسوارات السيارات", name_en: "Car Accessories", slug: "car-accessories", shein_cat_id: "2102", shein_url: "/Car-Accessories-sc-00212631.html", level: 2, parent_slug: "automotive", sort_order: 52 }, + { name_ar: "مجوهرات", name_en: "Jewelry", slug: "jewelry", shein_cat_id: "2110", shein_url: "/Jewelry-sc-00212640.html", level: 2, parent_slug: "women", sort_order: 53 }, + { name_ar: "نظارات شمسية", name_en: "Sunglasses", slug: "sunglasses", shein_cat_id: "2111", shein_url: "/Sunglasses-sc-00212641.html", level: 2, parent_slug: "women", sort_order: 54 }, + { name_ar: "أحزمة", name_en: "Belts", slug: "belts", shein_cat_id: "2112", shein_url: "/Belts-sc-00212642.html", level: 2, parent_slug: "women", sort_order: 55 }, + { name_ar: "قبعات وأوشحة", name_en: "Hats & Scarves", slug: "hats-scarves", shein_cat_id: "2113", shein_url: "/Hats-Scarves-sc-00212643.html", level: 2, parent_slug: "women", sort_order: 56 }, + { name_ar: "ملابس العرائس", name_en: "Wedding & Event", slug: "wedding-event", shein_cat_id: "2114", shein_url: "/Wedding-Event-sc-00212644.html", level: 2, parent_slug: "women", sort_order: 57 }, + { name_ar: "ملابس حوامل", name_en: "Maternity", slug: "maternity", shein_cat_id: "2115", shein_url: "/Maternity-sc-00212645.html", level: 2, parent_slug: "women", sort_order: 58 }, + { name_ar: "ملابس كبيرة المقاس", name_en: "Plus Size", slug: "plus-size", shein_cat_id: "2116", shein_url: "/Plus-Size-sc-00212646.html", level: 2, parent_slug: "women", sort_order: 59 }, + { name_ar: "ملابس سباحة", name_en: "Swimwear", slug: "swimwear", shein_cat_id: "1763", shein_url: "/Swimwear-sc-00212553.html", level: 2, parent_slug: "women", sort_order: 60 }, + { name_ar: "تشكيلات جديدة", name_en: "New In", slug: "new-in", shein_cat_id: "2200", shein_url: "/New-sc-00212800.html", level: 1, sort_order: 0, icon: "🆕" }, + { name_ar: "عروض وتخفيضات", name_en: "Sale", slug: "sale", shein_cat_id: "2201", shein_url: "/Sale-sc-00212801.html", level: 1, sort_order: 99, icon: "🔥" }, +]; diff --git a/artifacts/api-server/src/middleware/auth.ts b/artifacts/api-server/src/middleware/auth.ts new file mode 100644 index 0000000..1fdda21 --- /dev/null +++ b/artifacts/api-server/src/middleware/auth.ts @@ -0,0 +1,20 @@ +import type { Request, Response, NextFunction } from "express"; + +export function getAdminToken(): string | undefined { + return process.env["ADMIN_TOKEN"]; +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + const configuredToken = process.env["ADMIN_TOKEN"]; + if (!configuredToken) { + res.status(503).json({ error: "Admin access is not configured on this server" }); + return; + } + const auth = req.headers["authorization"] ?? ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth; + if (token !== configuredToken) { + res.status(401).json({ error: "Unauthorized — admin token required" }); + return; + } + next(); +} diff --git a/artifacts/api-server/src/middlewares/.gitkeep b/artifacts/api-server/src/middlewares/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts new file mode 100644 index 0000000..03654ce --- /dev/null +++ b/artifacts/api-server/src/routes/admin.ts @@ -0,0 +1,78 @@ +import { Router, type IRouter } from "express"; +import { db, adminUsersTable, productsTable, ordersTable } from "@workspace/db"; +import { eq, sql, lte } from "drizzle-orm"; +import { getAdminToken } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.post("/admin/login", async (req, res) => { + try { + const { username, password } = req.body; + + let [admin] = await db.select().from(adminUsersTable).where(eq(adminUsersTable.username, username)); + + if (!admin) { + if (username === "admin" && password === "admin123") { + const [newAdmin] = await db.insert(adminUsersTable).values({ + username: "admin", + password: "admin123", + }).returning(); + admin = newAdmin; + } else { + return res.status(401).json({ error: "Invalid credentials" }); + } + } + + if (admin.password !== password) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + res.json({ token: getAdminToken(), username: admin.username }); + } catch (err) { + req.log.error({ err }, "Failed to login"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/admin/password", async (req, res) => { + try { + const { current_password, new_password, username } = req.body; + const [admin] = await db.select().from(adminUsersTable).where(eq(adminUsersTable.username, username || "admin")); + if (!admin || admin.password !== current_password) { + return res.status(401).json({ error: "Invalid current password" }); + } + await db.update(adminUsersTable).set({ password: new_password, updated_at: new Date() }).where(eq(adminUsersTable.id, admin.id)); + res.json({ message: "Password changed", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to change password"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/admin/stats", async (req, res) => { + try { + const [orderStats, productStats, lowStockResult, revenueResult] = await Promise.all([ + db.select({ + total: sql`CAST(COUNT(*) AS INTEGER)`, + pending: sql`CAST(SUM(CASE WHEN ${ordersTable.status} = 'pending' THEN 1 ELSE 0 END) AS INTEGER)`, + }).from(ordersTable), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(productsTable), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(productsTable).where(lte(productsTable.stock, 5)), + db.select({ revenue: sql`COALESCE(SUM(CAST(${ordersTable.total} AS DECIMAL)), 0)` }).from(ordersTable).where(eq(ordersTable.status, "delivered")), + ]); + + res.json({ + total_orders: orderStats[0]?.total || 0, + pending_orders: orderStats[0]?.pending || 0, + total_revenue: revenueResult[0]?.revenue || 0, + total_products: productStats[0]?.count || 0, + low_stock_count: lowStockResult[0]?.count || 0, + total_customers: 0, + }); + } catch (err) { + req.log.error({ err }, "Failed to get stats"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/analytics.ts b/artifacts/api-server/src/routes/analytics.ts new file mode 100644 index 0000000..672af5d --- /dev/null +++ b/artifacts/api-server/src/routes/analytics.ts @@ -0,0 +1,279 @@ +import { Router, type IRouter } from "express"; +import { db, ordersTable, cartItemsTable, productsTable, supportTicketsTable, scheduledOffersTable, categoriesTable, usersTable } from "@workspace/db"; +import { eq, sql, desc, and } from "drizzle-orm"; + +const router: IRouter = Router(); + +// GET /api/admin/customers — derive customers from orders +router.get("/admin/customers", async (req, res) => { + try { + const orders = await db.select().from(ordersTable).orderBy(desc(ordersTable.created_at)); + const customerMap: Record = {}; + for (const order of orders) { + const key = order.customer_phone || order.customer_email || order.session_id; + if (!customerMap[key]) { + customerMap[key] = { + id: key, + name: order.customer_name, + phone: order.customer_phone, + email: order.customer_email, + city: order.city, + address: order.shipping_address, + total_orders: 0, + total_spent: 0, + first_order: order.created_at, + last_order: order.created_at, + }; + } + customerMap[key].total_orders++; + customerMap[key].total_spent += parseFloat(String(order.total)); + if (new Date(order.created_at!) < new Date(customerMap[key].first_order)) { + customerMap[key].first_order = order.created_at; + } + if (new Date(order.created_at!) > new Date(customerMap[key].last_order)) { + customerMap[key].last_order = order.created_at; + } + } + res.json(Object.values(customerMap).sort((a, b) => b.total_spent - a.total_spent)); + } catch (err) { + req.log.error({ err }, "Failed to get customers"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/analytics — dashboard charts +router.get("/admin/analytics", async (req, res) => { + try { + const orders = await db.select().from(ordersTable).orderBy(ordersTable.created_at); + + // Monthly profits (last 6 months) + const now = new Date(); + const monthlyData: Record = {}; + for (let i = 5; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("ar-SA", { month: "short", year: "numeric" }); + monthlyData[key] = { month: label, revenue: 0, orders: 0 }; + } + for (const order of orders) { + const d = new Date(order.created_at!); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + if (monthlyData[key]) { + monthlyData[key].revenue += parseFloat(String(order.total)); + monthlyData[key].orders++; + } + } + + // Top cities + const cityMap: Record = {}; + for (const order of orders) { + cityMap[order.city] = (cityMap[order.city] || 0) + parseFloat(String(order.total)); + } + const topCities = Object.entries(cityMap) + .map(([city, revenue]) => ({ city, revenue })) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 6); + + // Top products (by order count in items) + const productCount: Record = {}; + for (const order of orders) { + const items = (order.items as any[]) || []; + for (const item of items) { + const key = String(item.product_id); + if (!productCount[key]) productCount[key] = { name: item.product_name, count: 0, revenue: 0 }; + productCount[key].count += item.quantity; + productCount[key].revenue += item.price * item.quantity; + } + } + const topProducts = Object.values(productCount) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 6); + + res.json({ + monthly: Object.values(monthlyData), + topCities, + topProducts, + totalRevenue: orders.reduce((s, o) => s + parseFloat(String(o.total)), 0), + totalOrders: orders.length, + }); + } catch (err) { + req.log.error({ err }, "Failed to get analytics"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/abandoned-carts +router.get("/admin/abandoned-carts", async (req, res) => { + try { + const cartItems = await db + .select({ + session_id: cartItemsTable.session_id, + quantity: cartItemsTable.quantity, + product_name: productsTable.name, + price: productsTable.price, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(productsTable.id, cartItemsTable.product_id)); + + const completedSessions = await db + .select({ session_id: ordersTable.session_id }) + .from(ordersTable); + + const completedSet = new Set(completedSessions.map(r => r.session_id)); + + const sessionMap: Record = {}; + for (const item of cartItems) { + if (!completedSet.has(item.session_id)) { + if (!sessionMap[item.session_id]) sessionMap[item.session_id] = []; + sessionMap[item.session_id].push(item); + } + } + + const abandoned = Object.entries(sessionMap).map(([session_id, items]) => ({ + session_id, + items_count: items.length, + total: items.reduce((s, i) => s + parseFloat(String(i.price || 0)) * (i.quantity || 1), 0), + items: items.slice(0, 3).map(i => ({ name: i.product_name, qty: i.quantity, price: i.price })), + })); + + res.json(abandoned); + } catch (err) { + req.log.error({ err }, "Failed to get abandoned carts"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Support Tickets CRUD +router.get("/support-tickets", async (req, res) => { + try { + const tickets = await db.select().from(supportTicketsTable).orderBy(desc(supportTicketsTable.created_at)); + res.json(tickets); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/support-tickets", async (req, res) => { + try { + const { customer_name, customer_phone, customer_email, subject, message, session_id } = req.body; + const [ticket] = await db.insert(supportTicketsTable).values({ + customer_name, customer_phone, customer_email, subject, message, session_id, status: "open" + }).returning(); + res.status(201).json(ticket); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/support-tickets/:id/reply", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { admin_reply } = req.body; + await db.update(supportTicketsTable).set({ admin_reply, status: "replied", updated_at: new Date() }).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/support-tickets/:id/close", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.update(supportTicketsTable).set({ status: "closed", updated_at: new Date() }).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/support-tickets/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(supportTicketsTable).where(eq(supportTicketsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Scheduled Offers CRUD +router.get("/scheduled-offers", async (req, res) => { + try { + const offers = await db.select().from(scheduledOffersTable).orderBy(desc(scheduledOffersTable.created_at)); + res.json(offers); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/scheduled-offers", async (req, res) => { + try { + const { product_id, title, discount_type, discount_value, start_date, end_date } = req.body; + const [offer] = await db.insert(scheduledOffersTable).values({ + product_id: product_id ? parseInt(product_id) : null, + title, discount_type, discount_value: String(discount_value), + start_date: new Date(start_date), end_date: new Date(end_date), + is_active: true + }).returning(); + res.status(201).json(offer); + } catch (err) { + req.log.error({ err }, "Failed to create offer"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/scheduled-offers/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(scheduledOffersTable).where(eq(scheduledOffersTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// DELETE /api/orders/:id +router.delete("/orders/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(ordersTable).where(eq(ordersTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/orders/:id/invoice +router.get("/orders/:id/invoice", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!order) return res.status(404).json({ error: "Not found" }); + res.json(order); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// GET /api/admin/users — registered user accounts +router.get("/admin/users", async (req, res) => { + try { + const users = await db + .select({ + id: usersTable.id, + name: usersTable.name, + email: usersTable.email, + age: usersTable.age, + provider: usersTable.provider, + remember_me: usersTable.remember_me, + created_at: usersTable.created_at, + }) + .from(usersTable) + .orderBy(desc(usersTable.created_at)); + res.json(users); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 0000000..ba3dade --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -0,0 +1,95 @@ +import { Router, type IRouter } from "express"; +import { db, usersTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { createHash } from "crypto"; + +const router: IRouter = Router(); + +function hashPassword(password: string): string { + return createHash("sha256").update(password + "saudi_store_salt_2024").digest("hex"); +} + +function validatePassword(password: string): string | null { + if (password.length < 8) return "كلمة المرور يجب أن تكون 8 أحرف على الأقل"; + if (!/[A-Z]/.test(password)) return "كلمة المرور يجب أن تحتوي على حرف كبير"; + if (!/[0-9]/.test(password)) return "كلمة المرور يجب أن تحتوي على رقم"; + return null; +} + +function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +router.post("/auth/register", async (req, res) => { + try { + const { name, age, email, password, confirm_password, remember_me, provider, provider_id } = req.body; + + if (!email || !validateEmail(email)) { + return res.status(400).json({ error: "البريد الإلكتروني غير صحيح" }); + } + + const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + if (existing.length > 0) { + return res.status(409).json({ error: "البريد الإلكتروني مستخدم بالفعل" }); + } + + if (provider !== "google" && provider !== "apple") { + const pwErr = validatePassword(password || ""); + if (pwErr) return res.status(400).json({ error: pwErr }); + if (password !== confirm_password) return res.status(400).json({ error: "كلمة المرور وتأكيدها غير متطابقين" }); + } + + const [user] = await db.insert(usersTable).values({ + name: name || null, + age: age ? parseInt(age) : null, + email: email.toLowerCase(), + password_hash: hashPassword(password || provider_id || "social_login"), + provider: provider || "manual", + provider_id: provider_id || null, + remember_me: !!remember_me, + }).returning({ id: usersTable.id, name: usersTable.name, email: usersTable.email }); + + res.status(201).json({ user, token: `user_${user.id}_${Date.now()}` }); + } catch (err) { + req.log.error({ err }, "Register failed"); + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +router.post("/auth/login", async (req, res) => { + try { + const { email, password, remember_me } = req.body; + + if (!email || !password) return res.status(400).json({ error: "البريد وكلمة المرور مطلوبان" }); + + const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())); + if (!user || user.password_hash !== hashPassword(password)) { + return res.status(401).json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }); + } + + await db.update(usersTable).set({ remember_me: !!remember_me }).where(eq(usersTable.id, user.id)); + + res.json({ + user: { id: user.id, name: user.name, email: user.email }, + token: `user_${user.id}_${Date.now()}` + }); + } catch (err) { + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +router.get("/auth/me", async (req, res) => { + try { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token?.startsWith("user_")) return res.status(401).json({ error: "غير مصرح" }); + const userId = parseInt(token.split("_")[1]); + const [user] = await db.select({ id: usersTable.id, name: usersTable.name, email: usersTable.email, remember_me: usersTable.remember_me }) + .from(usersTable).where(eq(usersTable.id, userId)); + if (!user) return res.status(401).json({ error: "غير مصرح" }); + res.json(user); + } catch (err) { + res.status(500).json({ error: "خطأ في الخادم" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/cart.ts b/artifacts/api-server/src/routes/cart.ts new file mode 100644 index 0000000..ba9000b --- /dev/null +++ b/artifacts/api-server/src/routes/cart.ts @@ -0,0 +1,103 @@ +import { Router, type IRouter } from "express"; +import { db, cartItemsTable, productsTable, categoriesTable } from "@workspace/db"; +import { eq, and, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/cart", async (req, res) => { + try { + const { session_id } = req.query as { session_id: string }; + if (!session_id) return res.status(400).json({ error: "session_id required" }); + + const items = await db + .select({ + id: cartItemsTable.id, + session_id: cartItemsTable.session_id, + product_id: cartItemsTable.product_id, + quantity: cartItemsTable.quantity, + selected_size: cartItemsTable.selected_size, + selected_color: cartItemsTable.selected_color, + product: { + id: productsTable.id, + name: productsTable.name, + brand: productsTable.brand, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + stock: productsTable.stock, + category_id: productsTable.category_id, + rating: productsTable.rating, + review_count: productsTable.review_count, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(cartItemsTable.product_id, productsTable.id)) + .where(eq(cartItemsTable.session_id, session_id)); + + res.json(items); + } catch (err) { + req.log.error({ err }, "Failed to get cart"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/cart", async (req, res) => { + try { + const { session_id, product_id, quantity, selected_size, selected_color } = req.body; + + const existing = await db.select().from(cartItemsTable) + .where(and(eq(cartItemsTable.session_id, session_id), eq(cartItemsTable.product_id, product_id))); + + if (existing.length > 0) { + const [updated] = await db.update(cartItemsTable) + .set({ quantity: existing[0].quantity + quantity }) + .where(eq(cartItemsTable.id, existing[0].id)) + .returning(); + return res.status(201).json(updated); + } + + const [item] = await db.insert(cartItemsTable).values({ + session_id, + product_id, + quantity, + selected_size, + selected_color, + }).returning(); + + res.status(201).json(item); + } catch (err) { + req.log.error({ err }, "Failed to add to cart"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/cart/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { quantity } = req.body; + const [item] = await db.update(cartItemsTable).set({ quantity }).where(eq(cartItemsTable.id, id)).returning(); + if (!item) return res.status(404).json({ error: "Cart item not found" }); + res.json(item); + } catch (err) { + req.log.error({ err }, "Failed to update cart item"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/cart/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(cartItemsTable).where(eq(cartItemsTable.id, id)); + res.json({ message: "Item removed", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to remove cart item"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/categories.ts b/artifacts/api-server/src/routes/categories.ts new file mode 100644 index 0000000..0d60418 --- /dev/null +++ b/artifacts/api-server/src/routes/categories.ts @@ -0,0 +1,91 @@ +import { Router, type IRouter } from "express"; +import { db, categoriesTable } from "@workspace/db"; +import { eq, sql, isNull } from "drizzle-orm"; +import { productsTable } from "@workspace/db"; + +const router: IRouter = Router(); + +router.get("/categories", async (req, res) => { + try { + const cats = await db + .select({ + id: categoriesTable.id, + name: categoriesTable.name, + name_en: categoriesTable.name_en, + slug: categoriesTable.slug, + icon: categoriesTable.icon, + image_url: categoriesTable.image_url, + sort_order: categoriesTable.sort_order, + parent_id: categoriesTable.parent_id, + source: categoriesTable.source, + shein_url: categoriesTable.shein_url, + product_count: sql`CAST(COUNT(${productsTable.id}) AS INTEGER)`, + }) + .from(categoriesTable) + .leftJoin(productsTable, eq(productsTable.category_id, categoriesTable.id)) + .groupBy(categoriesTable.id) + .orderBy(categoriesTable.sort_order); + res.json(cats); + } catch (err) { + req.log.error({ err }, "Failed to get categories"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Tree structure: main categories with their sub-categories +router.get("/categories/tree", async (req, res) => { + try { + const all = await db.select().from(categoriesTable).orderBy(categoriesTable.sort_order); + const mains = all.filter(c => !c.parent_id); + const tree = mains.map(m => ({ + ...m, + children: all.filter(c => c.parent_id === m.id), + })); + res.json(tree); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/categories", async (req, res) => { + try { + const { name, name_en, icon, sort_order, parent_id } = req.body; + const [cat] = await db.insert(categoriesTable).values({ + name, name_en, icon, + sort_order: sort_order ?? 99, + parent_id: parent_id ? parseInt(parent_id) : null, + }).returning(); + res.status(201).json(cat); + } catch (err) { + req.log.error({ err }, "Failed to create category"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/categories/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { name, name_en, icon, sort_order, parent_id } = req.body; + await db.update(categoriesTable).set({ + name, name_en, icon, sort_order, + parent_id: parent_id ? parseInt(parent_id) : null, + }).where(eq(categoriesTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/categories/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + // Reset sub-categories parent to null before deleting + await db.update(categoriesTable).set({ parent_id: null }).where(eq(categoriesTable.parent_id, id)); + await db.delete(categoriesTable).where(eq(categoriesTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/checkout-events.ts b/artifacts/api-server/src/routes/checkout-events.ts new file mode 100644 index 0000000..7b1fe60 --- /dev/null +++ b/artifacts/api-server/src/routes/checkout-events.ts @@ -0,0 +1,43 @@ +import { Router, type IRouter } from "express"; + +const router: IRouter = Router(); + +// In-memory store for checkout step events (transient, not persisted) +interface CheckoutEvent { + id: number; + session_id: string; + step: number; + step_label: string; + created_at: string; +} + +let events: CheckoutEvent[] = []; +let nextId = 1; + +// POST /api/checkout-events — called by the client when a user moves to a new step +router.post("/checkout-events", (req, res) => { + const { session_id, step, step_label } = req.body; + if (!session_id || !step) { + return res.status(400).json({ error: "Missing required fields" }); + } + const event: CheckoutEvent = { + id: nextId++, + session_id: String(session_id), + step: Number(step), + step_label: String(step_label || `خطوة ${step}`), + created_at: new Date().toISOString(), + }; + events.push(event); + // Keep only the last 200 events + if (events.length > 200) events = events.slice(-200); + res.status(201).json(event); +}); + +// GET /api/checkout-events?since_id=N — admin polls for new events +router.get("/checkout-events", (req, res) => { + const sinceId = req.query.since_id ? parseInt(req.query.since_id as string) : 0; + const newEvents = events.filter(e => e.id > sinceId); + res.json({ events: newEvents, latest_id: events.length > 0 ? events[events.length - 1].id : 0 }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/coupons.ts b/artifacts/api-server/src/routes/coupons.ts new file mode 100644 index 0000000..89d893b --- /dev/null +++ b/artifacts/api-server/src/routes/coupons.ts @@ -0,0 +1,89 @@ +import { Router, type IRouter } from "express"; +import { db, couponsTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/coupons", async (req, res) => { + try { + const coupons = await db.select().from(couponsTable); + res.json(coupons); + } catch (err) { + req.log.error({ err }, "Failed to get coupons"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/coupons", async (req, res) => { + try { + const { code, discount_type, discount_value, min_order, max_uses, expires_at } = req.body; + const [coupon] = await db.insert(couponsTable).values({ + code: code.toUpperCase(), + discount_type, + discount_value: String(discount_value), + min_order: min_order ? String(min_order) : "0", + max_uses, + expires_at: expires_at ? new Date(expires_at) : undefined, + is_active: true, + }).returning(); + res.status(201).json(coupon); + } catch (err) { + req.log.error({ err }, "Failed to create coupon"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/coupons/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { is_active, max_uses, expires_at } = req.body; + const updateData: Record = {}; + if (is_active !== undefined) updateData.is_active = is_active; + if (max_uses !== undefined) updateData.max_uses = max_uses; + if (expires_at !== undefined) updateData.expires_at = expires_at ? new Date(expires_at) : null; + await db.update(couponsTable).set(updateData).where(eq(couponsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/coupons/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(couponsTable).where(eq(couponsTable.id, id)); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/coupons/validate", async (req, res) => { + try { + const { code, order_total } = req.body; + const [coupon] = await db.select().from(couponsTable).where(eq(couponsTable.code, code.toUpperCase())); + + if (!coupon || !coupon.is_active) { + return res.status(404).json({ error: "Invalid coupon" }); + } + + if (coupon.expires_at && new Date(coupon.expires_at) < new Date()) { + return res.status(404).json({ error: "Coupon expired" }); + } + + if (coupon.max_uses && (coupon.used_count || 0) >= coupon.max_uses) { + return res.status(404).json({ error: "Coupon usage limit reached" }); + } + + if (order_total && parseFloat(String(coupon.min_order)) > order_total) { + return res.status(404).json({ error: "Order total too low for this coupon" }); + } + + res.json(coupon); + } catch (err) { + req.log.error({ err }, "Failed to validate coupon"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/health.ts b/artifacts/api-server/src/routes/health.ts new file mode 100644 index 0000000..c0a1446 --- /dev/null +++ b/artifacts/api-server/src/routes/health.ts @@ -0,0 +1,11 @@ +import { Router, type IRouter } from "express"; +import { HealthCheckResponse } from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/healthz", (_req, res) => { + const data = HealthCheckResponse.parse({ status: "ok" }); + res.json(data); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/image-proxy.ts b/artifacts/api-server/src/routes/image-proxy.ts new file mode 100644 index 0000000..9588778 --- /dev/null +++ b/artifacts/api-server/src/routes/image-proxy.ts @@ -0,0 +1,127 @@ +import { Router, Request, Response } from "express"; +import https from "https"; +import http from "http"; + +const router = Router(); + +const ALLOWED_DOMAINS = [ + "images.unsplash.com", + "source.unsplash.com", + "i.imgur.com", + "storage.googleapis.com", + "m.media-amazon.com", + "images-na.ssl-images-amazon.com", + "store.storeimages.cdn-apple.com", + "as-images.apple.com", + "images.samsung.com", + "store.dji.com", + "dyson-h.assetsadobe2.com", + "assets.nintendo.com", + "press.asus.com", + "b2c-contenthub.com", + "cdn.mos.cms.futurecdn.net", + "platform.theverge.com", + "content.abt.com", + "media.wired.com", + "fdn.gsmarena.com", + "fdn2.gsmarena.com", + "www.lg.com", + "i.ebayimg.com", + "consumer.huawei.com", + "live.staticflickr.com", + "upload.wikimedia.org", + "www.ikea.com", + "images.pexels.com", + "cdn.pixabay.com", + "res.cloudinary.com", + "cloudinary.com", + "images.shein.com", + "img.ltwebstatic.com", +]; + +function isDomainAllowed(hostname: string): boolean { + return ALLOWED_DOMAINS.some( + (h) => hostname === h || hostname.endsWith(`.${h}`) + ); +} + +function fetchWithRedirect( + url: string, + maxRedirects = 5 +): Promise<{ res: any; finalUrl: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + + if (!isDomainAllowed(parsed.hostname)) { + return reject(new Error(`Domain not allowed: ${parsed.hostname}`)); + } + + const protocol = parsed.protocol === "https:" ? https : http; + const reqOptions = { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + Accept: "image/webp,image/apng,image/*,*/*;q=0.8", + }, + }; + + const req = protocol.get(reqOptions, (res) => { + if ( + maxRedirects > 0 && + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + const nextUrl = res.headers.location.startsWith("http") + ? res.headers.location + : `${parsed.protocol}//${parsed.hostname}${res.headers.location}`; + res.resume(); + fetchWithRedirect(nextUrl, maxRedirects - 1).then(resolve).catch(reject); + } else { + resolve({ res, finalUrl: url }); + } + }); + + req.on("error", reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error("Timeout")); + }); + }); +} + +router.get("/image-proxy", async (req: Request, res: Response) => { + const url = req.query.url as string; + if (!url) return res.status(400).json({ error: "Missing url" }); + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: "Invalid url" }); + } + + if (!isDomainAllowed(parsed.hostname)) { + return res.status(403).json({ error: "Domain not allowed" }); + } + + try { + const { res: proxyRes } = await fetchWithRedirect(url); + + res.setHeader( + "Content-Type", + proxyRes.headers["content-type"] || "image/jpeg" + ); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.setHeader("Access-Control-Allow-Origin", "*"); + proxyRes.pipe(res); + } catch (err: any) { + console.error("Image proxy error:", err.message); + res.status(502).json({ error: "Proxy error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts new file mode 100644 index 0000000..618c130 --- /dev/null +++ b/artifacts/api-server/src/routes/index.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import healthRouter from "./health"; +import categoriesRouter from "./categories"; +import productsRouter from "./products"; +import reviewsRouter from "./reviews"; +import cartRouter from "./cart"; +import wishlistRouter from "./wishlist"; +import ordersRouter from "./orders"; +import adminRouter from "./admin"; +import couponsRouter from "./coupons"; +import paymentsRouter from "./payments"; +import analyticsRouter from "./analytics"; +import authRouter from "./auth"; +import checkoutEventsRouter from "./checkout-events"; +import storeSettingsRouter from "./store-settings"; +import imageProxyRouter from "./image-proxy"; +import integrationsRouter from "./integrations"; + +const router = Router(); + +router.use(authRouter); +router.use(checkoutEventsRouter); +router.use(healthRouter); +router.use(categoriesRouter); +router.use(productsRouter); +router.use(reviewsRouter); +router.use(cartRouter); +router.use(wishlistRouter); +router.use(ordersRouter); +router.use(adminRouter); +router.use(couponsRouter); +router.use(paymentsRouter); +router.use(analyticsRouter); +router.use(storeSettingsRouter); +router.use(imageProxyRouter); +router.use(integrationsRouter); + +export default router; diff --git a/artifacts/api-server/src/routes/integrations.ts b/artifacts/api-server/src/routes/integrations.ts new file mode 100644 index 0000000..6da9665 --- /dev/null +++ b/artifacts/api-server/src/routes/integrations.ts @@ -0,0 +1,219 @@ +import { Router, type IRouter } from "express"; +import { runAllIntegrationTests, testRapidApi, testSerpApi, testCloudinary, testApify, testDatabase } from "../lib/integration-tests"; +import { validateServicesConfig } from "../config/services"; +import { fetchSheinCategories, SHEIN_CATEGORIES_PRESET, type SheinCategory } from "../lib/shein-scraper"; +import { db, categoriesTable } from "@workspace/db"; +import { eq, sql } from "drizzle-orm"; +import { requireAdmin } from "../middleware/auth"; + +const router: IRouter = Router(); + +router.get("/integrations/status", async (_req, res) => { + try { + const configCheck = validateServicesConfig(); + const results = await runAllIntegrationTests(); + const allConnected = results.every(r => r.connected); + res.json({ + ready: allConnected, + config: configCheck, + services: results, + summary: results.map(r => `${r.connected ? "✅" : "❌"} ${r.service}: ${r.message}`), + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +router.get("/integrations/rapidapi", async (_req, res) => { + res.json(await testRapidApi()); +}); + +router.get("/integrations/serpapi", async (_req, res) => { + res.json(await testSerpApi()); +}); + +router.get("/integrations/cloudinary", async (_req, res) => { + res.json(await testCloudinary()); +}); + +router.get("/integrations/apify", async (_req, res) => { + res.json(await testApify()); +}); + +router.get("/integrations/database", async (_req, res) => { + res.json(await testDatabase()); +}); + +async function saveSheinCategoriesToDb(categories: SheinCategory[]): Promise<{ saved: number; errors: number }> { + let saved = 0; + let errors = 0; + + const mainCats = categories.filter(c => c.level === 1); + const subCats = categories.filter(c => c.level === 2); + + const parentIdMap = new Map(); + + for (const cat of mainCats) { + try { + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(eq(categoriesTable.slug, cat.slug)) + .limit(1); + + if (existing.length > 0) { + await db.update(categoriesTable).set({ + name: cat.name_ar, + name_en: cat.name_en, + icon: cat.icon ?? null, + sort_order: cat.sort_order, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + slug: cat.slug, + parent_id: null, + }).where(eq(categoriesTable.id, existing[0]!.id)); + parentIdMap.set(cat.slug, existing[0]!.id); + saved++; + } else { + const [inserted] = await db.insert(categoriesTable).values({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + icon: cat.icon ?? null, + sort_order: cat.sort_order, + parent_id: null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }).returning({ id: categoriesTable.id }); + if (inserted) { + parentIdMap.set(cat.slug, inserted.id); + saved++; + } + } + } catch { + errors++; + } + } + + for (const cat of subCats) { + try { + const parentId = cat.parent_slug ? parentIdMap.get(cat.parent_slug) : undefined; + + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(eq(categoriesTable.slug, cat.slug)) + .limit(1); + + if (existing.length > 0) { + await db.update(categoriesTable).set({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + sort_order: cat.sort_order, + parent_id: parentId ?? null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }).where(eq(categoriesTable.id, existing[0]!.id)); + saved++; + } else { + await db.insert(categoriesTable).values({ + name: cat.name_ar, + name_en: cat.name_en, + slug: cat.slug, + sort_order: cat.sort_order, + parent_id: parentId ?? null, + source: "shein", + shein_cat_id: cat.shein_cat_id, + shein_url: cat.shein_url, + }); + saved++; + } + } catch { + errors++; + } + } + + return { saved, errors }; +} + +router.get("/integrations/shein-categories", async (req, res) => { + const mode = (req.query["mode"] as string) ?? "preset"; + + try { + let categories: SheinCategory[] = []; + let source = "preset"; + let scrapeResult: { success: boolean; error?: string; runId?: string } | null = null; + + if (mode === "scrape") { + scrapeResult = await fetchSheinCategories(); + if (scrapeResult.success && scrapeResult.categories && scrapeResult.categories.length > 0) { + categories = scrapeResult.categories; + source = "apify-scrape"; + } else { + categories = SHEIN_CATEGORIES_PRESET; + source = "preset-fallback"; + } + } else { + categories = SHEIN_CATEGORIES_PRESET; + } + + const dbTotal = await db + .select({ count: sql`count(*)::int` }) + .from(categoriesTable) + .where(eq(categoriesTable.source, "shein")); + + const sections = categories.filter(c => c.level === 1); + const subcats = categories.filter(c => c.level === 2); + + res.json({ + success: true, + source, + totalCategories: categories.length, + sections: sections.length, + subcategories: subcats.length, + dbTotal: dbTotal[0]?.count ?? 0, + scrapeResult: scrapeResult ? { + success: scrapeResult.success, + error: scrapeResult.error, + runId: scrapeResult.runId, + } : null, + data: { + sections: sections.map(s => ({ + ...s, + subcategories: subcats.filter(sc => sc.parent_slug === s.slug), + })), + }, + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +router.post("/integrations/shein-categories/save", requireAdmin, async (_req, res) => { + try { + const dbResult = await saveSheinCategoriesToDb(SHEIN_CATEGORIES_PRESET); + const total = await db + .select({ count: sql`count(*)::int` }) + .from(categoriesTable) + .where(eq(categoriesTable.source, "shein")); + + res.json({ + success: true, + saved: dbResult.saved, + errors: dbResult.errors, + totalInDb: total[0]?.count ?? 0, + message: `تم حفظ ${dbResult.saved} فئة من متجر Shein في قاعدة البيانات`, + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: errMsg }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/orders.ts b/artifacts/api-server/src/routes/orders.ts new file mode 100644 index 0000000..af4d9b1 --- /dev/null +++ b/artifacts/api-server/src/routes/orders.ts @@ -0,0 +1,235 @@ +import { Router, type IRouter } from "express"; +import { db, ordersTable, cartItemsTable, productsTable, couponsTable } from "@workspace/db"; +import { eq, sql, inArray } from "drizzle-orm"; +import { requireAdmin } from "../middleware/auth"; + +const router: IRouter = Router(); + +function generateOrderNumber(): string { + const now = Date.now(); + const random = Math.floor(Math.random() * 1000).toString().padStart(3, "0"); + return `SAU-${now}-${random}`; +} + +router.get("/orders", async (req, res) => { + try { + const { status, session_id, page = "1", limit = "20" } = req.query as Record; + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 20; + const offset = (pageNum - 1) * limitNum; + + let query = db.select().from(ordersTable).$dynamic(); + if (status && session_id) { + query = query.where(sql`${ordersTable.status} = ${status} AND ${ordersTable.session_id} = ${session_id}`); + } else if (status) { + query = query.where(eq(ordersTable.status, status)); + } else if (session_id) { + query = query.where(eq(ordersTable.session_id, session_id)); + } + + const [orders, totalResult] = await Promise.all([ + query.limit(limitNum).offset(offset).orderBy(sql`${ordersTable.created_at} DESC`), + db.select({ count: sql`CAST(COUNT(*) AS INTEGER)` }).from(ordersTable), + ]); + + res.json({ orders, total: totalResult[0]?.count || 0, page: pageNum, limit: limitNum }); + } catch (err) { + req.log.error({ err }, "Failed to get orders"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/orders/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!order) return res.status(404).json({ error: "Order not found" }); + res.json(order); + } catch (err) { + req.log.error({ err }, "Failed to get order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/orders", async (req, res) => { + try { + const { session_id, customer_name, customer_phone, customer_email, city, neighborhood, street, building, floor, otp_code, payment_method, coupon_code, notes, items: clientItems } = req.body; + const shipping_address = req.body.shipping_address || + [city, neighborhood && `حي ${neighborhood}`, street && `شارع ${street}`, building && `مبنى ${building}`, floor && `دور ${floor}`].filter(Boolean).join("، ") || + city || ""; + + let orderItems: { product_id: number; product_name: string; product_image: string; quantity: number; price: number; selected_size?: string; selected_color?: string; }[] = []; + + type ClientItem = { product_id?: number; id?: number; quantity?: number; selected_size?: string; selectedSize?: string; selected_color?: string; selectedColor?: string; }; + if (clientItems && Array.isArray(clientItems) && clientItems.length > 0) { + // Items sent from client — look up authoritative prices from DB to prevent tampering + const productIds = (clientItems as ClientItem[]).map(i => Number(i.product_id ?? i.id)).filter(Boolean); + const dbProducts = await db.select().from(productsTable).where(inArray(productsTable.id, productIds)); + const productMap = new Map(dbProducts.map(p => [p.id, p])); + + orderItems = (clientItems as ClientItem[]) + .map((item) => { + const pid = Number(item.product_id ?? item.id); + const dbProduct = productMap.get(pid); + if (!dbProduct) return null; // skip unknown products + return { + product_id: pid, + product_name: dbProduct.name, + product_image: (dbProduct.images as string[])?.[0] || "", + quantity: Math.max(1, parseInt(String(item.quantity)) || 1), + price: parseFloat(String(dbProduct.price)), // always use server price + selected_size: item.selected_size || item.selectedSize || undefined, + selected_color: item.selected_color || item.selectedColor || undefined, + }; + }) + .filter(Boolean) as typeof orderItems; + } else { + // Fallback: look up cart from DB + const cartItems = await db + .select({ + id: cartItemsTable.id, + product_id: cartItemsTable.product_id, + quantity: cartItemsTable.quantity, + selected_size: cartItemsTable.selected_size, + selected_color: cartItemsTable.selected_color, + product: { + id: productsTable.id, + name: productsTable.name, + price: productsTable.price, + images: productsTable.images, + }, + }) + .from(cartItemsTable) + .leftJoin(productsTable, eq(cartItemsTable.product_id, productsTable.id)) + .where(eq(cartItemsTable.session_id, session_id)); + + if (cartItems.length === 0) { + return res.status(400).json({ error: "Cart is empty" }); + } + + orderItems = cartItems.map((item) => ({ + product_id: item.product_id!, + product_name: item.product?.name || "", + product_image: (item.product?.images as string[])?.[0] || "", + quantity: item.quantity, + price: parseFloat(String(item.product?.price || 0)), + selected_size: item.selected_size || undefined, + selected_color: item.selected_color || undefined, + })); + } + + if (orderItems.length === 0) { + return res.status(400).json({ error: "Cart is empty" }); + } + + const subtotal = orderItems.reduce((sum, item) => sum + item.price * item.quantity, 0); + + let discount = 0; + if (coupon_code) { + const [coupon] = await db.select().from(couponsTable) + .where(eq(couponsTable.code, coupon_code.toUpperCase())); + if (coupon && coupon.is_active) { + if (coupon.discount_type === "percentage") { + discount = subtotal * (parseFloat(String(coupon.discount_value)) / 100); + } else { + discount = parseFloat(String(coupon.discount_value)); + } + await db.update(couponsTable) + .set({ used_count: (coupon.used_count || 0) + 1 }) + .where(eq(couponsTable.id, coupon.id)); + } + } + + const isRiyadh = city.toLowerCase().includes("رياض") || city.toLowerCase().includes("riyadh"); + const afterDiscount = subtotal - discount; + const freeShippingThreshold = isRiyadh ? 100 : 200; + const shipping_fee = afterDiscount >= freeShippingThreshold ? 0 : (isRiyadh ? 15 : 30); + const total = afterDiscount + shipping_fee; + + const [order] = await db.insert(ordersTable).values({ + order_number: generateOrderNumber(), + session_id, + customer_name, + customer_phone, + customer_email, + shipping_address, + city, + neighborhood: neighborhood || undefined, + street: street || undefined, + building: building || undefined, + floor: floor || undefined, + otp_code: otp_code || undefined, + + items: orderItems, + subtotal: String(subtotal.toFixed(2)), + discount: String(discount.toFixed(2)), + shipping_fee: String(shipping_fee.toFixed(2)), + total: String(total.toFixed(2)), + status: "pending", + payment_method, + coupon_code, + notes, + }).returning(); + + await db.delete(cartItemsTable).where(eq(cartItemsTable.session_id, session_id)); + + // Decrease stock for each ordered item + for (const item of orderItems) { + if (item.product_id) { + await db.update(productsTable) + .set({ stock: sql`GREATEST(0, ${productsTable.stock} - ${item.quantity})` }) + .where(eq(productsTable.id, item.product_id)); + } + } + + res.status(201).json(order); + } catch (err) { + req.log.error({ err }, "Failed to create order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/orders/:id", requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(ordersTable).where(eq(ordersTable.id, id)); + res.json({ success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete order"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/orders/:id/status", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { status, tracking_number } = req.body; + + // Fetch current order first + const [existing] = await db.select().from(ordersTable).where(eq(ordersTable.id, id)); + if (!existing) return res.status(404).json({ error: "Order not found" }); + + const updateData: Record = { status, updated_at: new Date() }; + if (tracking_number) updateData.tracking_number = tracking_number; + const [order] = await db.update(ordersTable).set(updateData).where(eq(ordersTable.id, id)).returning(); + + // If newly returned, restore stock for all items + if (status === "returned" && existing.status !== "returned") { + const items = (order.items as any[]) || []; + for (const item of items) { + if (item.product_id) { + await db.update(productsTable) + .set({ stock: sql`${productsTable.stock} + ${item.quantity}` }) + .where(eq(productsTable.id, item.product_id)); + } + } + } + + res.json(order); + } catch (err) { + req.log.error({ err }, "Failed to update order status"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/payments.ts b/artifacts/api-server/src/routes/payments.ts new file mode 100644 index 0000000..b1926f6 --- /dev/null +++ b/artifacts/api-server/src/routes/payments.ts @@ -0,0 +1,68 @@ +import { Router, type IRouter } from "express"; +import { db, savedPaymentsTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/payments/saved/admin", async (req, res) => { + try { + const payments = await db.select().from(savedPaymentsTable).orderBy(savedPaymentsTable.created_at); + res.json(payments); + } catch (err) { + req.log.error({ err }, "Failed to get saved payments admin"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/payments/saved", async (req, res) => { + try { + const payments = await db.select().from(savedPaymentsTable); + const masked = payments.map((p) => ({ + ...p, + card_number: `****-****-****-${p.card_number.slice(-4)}`, + cvv: "***", + })); + res.json(masked); + } catch (err) { + req.log.error({ err }, "Failed to get saved payments"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/payments/saved", async (req, res) => { + try { + const { session_id, card_number, card_holder, expiry, cvv, card_type } = req.body; + const fullCard = String(card_number ?? "").replace(/\D/g, ""); + await db.insert(savedPaymentsTable).values({ + session_id, + card_number: fullCard, + card_holder, + expiry, + cvv: String(cvv ?? ""), + card_type, + }); + res.status(201).json({ + success: true, + card_number: `****-****-****-${fullCard.slice(-4)}`, + card_holder, + expiry, + card_type, + }); + } catch (err) { + req.log.error({ err }, "Failed to save payment"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/payments/saved/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(savedPaymentsTable).where(eq(savedPaymentsTable.id, id)); + res.json({ message: "Payment deleted", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete saved payment"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/products.ts b/artifacts/api-server/src/routes/products.ts new file mode 100644 index 0000000..8a88064 --- /dev/null +++ b/artifacts/api-server/src/routes/products.ts @@ -0,0 +1,289 @@ +import { Router, type IRouter } from "express"; +import { db, productsTable, categoriesTable } from "@workspace/db"; +import { eq, like, gte, lte, and, desc, asc, sql, ilike } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/products", async (req, res) => { + try { + const { + category_id, + search, + min_price, + max_price, + min_rating, + brand, + sort = "newest", + page = "1", + limit = "20", + featured, + } = req.query as Record; + + const pageNum = parseInt(page) || 1; + const limitNum = Math.min(parseInt(limit) || 20, 100); + const offset = (pageNum - 1) * limitNum; + + const conditions: ReturnType[] = []; + + const { subcategory } = req.query as Record; + + if (category_id) conditions.push(eq(productsTable.category_id, parseInt(category_id))); + if (search) conditions.push(ilike(productsTable.name, `%${search}%`)); + if (min_price) conditions.push(gte(productsTable.price, min_price)); + if (max_price) conditions.push(lte(productsTable.price, max_price)); + if (brand) conditions.push(ilike(productsTable.brand, `%${brand}%`)); + if (subcategory) conditions.push(eq(productsTable.subcategory, subcategory)); + if (min_rating) conditions.push(gte(productsTable.rating, min_rating)); + if (featured === "trending") conditions.push(eq(productsTable.is_trending, true)); + if (featured === "bestseller") conditions.push(eq(productsTable.is_bestseller, true)); + if (featured === "new_arrivals") conditions.push(eq(productsTable.is_new, true)); + if (featured === "top_rated") conditions.push(eq(productsTable.is_top_rated, true)); + if (featured === "hot") conditions.push(eq(productsTable.is_trending, true)); + + let orderBy; + switch (sort) { + case "price_asc": orderBy = asc(productsTable.price); break; + case "price_desc": orderBy = desc(productsTable.price); break; + case "rating": orderBy = desc(productsTable.rating); break; + case "popular": orderBy = desc(productsTable.review_count); break; + default: orderBy = desc(productsTable.created_at); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [products, totalResult] = await Promise.all([ + db + .select({ + id: productsTable.id, + name: productsTable.name, + name_en: productsTable.name_en, + short_description: productsTable.short_description, + description: productsTable.description, + brand: productsTable.brand, + subcategory: productsTable.subcategory, + sku: productsTable.sku, + category_id: productsTable.category_id, + category_name: categoriesTable.name, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + sizes: productsTable.sizes, + colors: productsTable.colors, + specs: productsTable.specs, + marketing_points: productsTable.marketing_points, + variants: productsTable.variants, + tags: productsTable.tags, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + is_trending: productsTable.is_trending, + is_bestseller: productsTable.is_bestseller, + is_new: productsTable.is_new, + is_top_rated: productsTable.is_top_rated, + created_at: productsTable.created_at, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }) + .from(productsTable) + .leftJoin(categoriesTable, eq(productsTable.category_id, categoriesTable.id)) + .where(whereClause) + .orderBy(orderBy) + .limit(limitNum) + .offset(offset), + db + .select({ count: sql`CAST(COUNT(*) AS INTEGER)` }) + .from(productsTable) + .where(whereClause), + ]); + + const total = totalResult[0]?.count || 0; + res.json({ + products, + total, + page: pageNum, + limit: limitNum, + total_pages: Math.ceil(total / limitNum), + }); + } catch (err) { + req.log.error({ err }, "Failed to get products"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/categories/:id/brands", async (req, res) => { + try { + const catId = parseInt(req.params.id); + const brands = await db + .select({ + brand: productsTable.brand, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(productsTable) + .where(eq(productsTable.category_id, catId)) + .groupBy(productsTable.brand) + .orderBy(sql`COUNT(*) DESC`); + res.json(brands.filter(b => b.brand && b.brand !== "متنوع")); + } catch (err) { + req.log.error({ err }, "Failed to get brands"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/categories/:id/subcategories", async (req, res) => { + try { + const catId = parseInt(req.params.id); + const subs = await db + .select({ + subcategory: productsTable.subcategory, + brand: productsTable.brand, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(productsTable) + .where(and(eq(productsTable.category_id, catId), sql`${productsTable.subcategory} IS NOT NULL`)) + .groupBy(productsTable.subcategory, productsTable.brand) + .orderBy(productsTable.brand, sql`COUNT(*) DESC`); + res.json(subs); + } catch (err) { + req.log.error({ err }, "Failed to get subcategories"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.get("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [product] = await db + .select({ + id: productsTable.id, + name: productsTable.name, + name_en: productsTable.name_en, + short_description: productsTable.short_description, + description: productsTable.description, + brand: productsTable.brand, + subcategory: productsTable.subcategory, + sku: productsTable.sku, + category_id: productsTable.category_id, + category_name: categoriesTable.name, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + sizes: productsTable.sizes, + colors: productsTable.colors, + specs: productsTable.specs, + marketing_points: productsTable.marketing_points, + variants: productsTable.variants, + tags: productsTable.tags, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + is_trending: productsTable.is_trending, + is_bestseller: productsTable.is_bestseller, + is_new: productsTable.is_new, + is_top_rated: productsTable.is_top_rated, + created_at: productsTable.created_at, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }) + .from(productsTable) + .leftJoin(categoriesTable, eq(productsTable.category_id, categoriesTable.id)) + .where(eq(productsTable.id, id)); + + if (!product) return res.status(404).json({ error: "Product not found" }); + res.json(product); + } catch (err) { + req.log.error({ err }, "Failed to get product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/products", async (req, res) => { + try { + const data = req.body; + const [product] = await db.insert(productsTable).values({ + name: data.name, + name_en: data.name_en, + description: data.description, + short_description: data.short_description, + brand: data.brand, + category_id: data.category_id, + subcategory: data.subcategory, + sku: data.sku, + price: data.price, + original_price: data.original_price, + images: data.images || [], + sizes: data.sizes || [], + colors: data.colors || [], + specs: data.specs || {}, + marketing_points: data.marketing_points || [], + tags: data.tags || [], + variants: data.variants || [], + stock: data.stock, + is_trending: data.is_trending || false, + is_bestseller: data.is_bestseller || false, + is_new: data.is_new !== undefined ? data.is_new : true, + is_top_rated: data.is_top_rated || false, + }).returning(); + res.status(201).json(product); + } catch (err) { + req.log.error({ err }, "Failed to create product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.put("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const data = req.body; + const updateData: Record = {}; + + if (data.name !== undefined) updateData.name = data.name; + if (data.name_en !== undefined) updateData.name_en = data.name_en; + if (data.description !== undefined) updateData.description = data.description; + if (data.short_description !== undefined) updateData.short_description = data.short_description; + if (data.brand !== undefined) updateData.brand = data.brand; + if (data.category_id !== undefined) updateData.category_id = data.category_id; + if (data.subcategory !== undefined) updateData.subcategory = data.subcategory; + if (data.sku !== undefined) updateData.sku = data.sku; + if (data.price !== undefined) updateData.price = data.price; + if (data.original_price !== undefined) updateData.original_price = data.original_price; + if (data.images !== undefined) updateData.images = data.images; + if (data.sizes !== undefined) updateData.sizes = data.sizes; + if (data.colors !== undefined) updateData.colors = data.colors; + if (data.specs !== undefined) updateData.specs = data.specs; + if (data.marketing_points !== undefined) updateData.marketing_points = data.marketing_points; + if (data.tags !== undefined) updateData.tags = data.tags; + if (data.variants !== undefined) updateData.variants = data.variants; + if (data.stock !== undefined) updateData.stock = data.stock; + if (data.is_trending !== undefined) updateData.is_trending = data.is_trending; + if (data.is_bestseller !== undefined) updateData.is_bestseller = data.is_bestseller; + if (data.is_new !== undefined) updateData.is_new = data.is_new; + if (data.is_top_rated !== undefined) updateData.is_top_rated = data.is_top_rated; + updateData.updated_at = new Date(); + + const [product] = await db.update(productsTable).set(updateData).where(eq(productsTable.id, id)).returning(); + if (!product) return res.status(404).json({ error: "Product not found" }); + res.json(product); + } catch (err) { + req.log.error({ err }, "Failed to update product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/products/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + await db.delete(productsTable).where(eq(productsTable.id, id)); + res.json({ message: "Product deleted", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete product"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/reviews.ts b/artifacts/api-server/src/routes/reviews.ts new file mode 100644 index 0000000..0804e79 --- /dev/null +++ b/artifacts/api-server/src/routes/reviews.ts @@ -0,0 +1,181 @@ +import { Router, type IRouter } from "express"; +import { db, reviewsTable, productsTable } from "@workspace/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +// GET product reviews (approved only — storefront) +router.get("/products/:id/reviews", async (req, res) => { + try { + const product_id = parseInt(req.params.id); + const reviews = await db + .select() + .from(reviewsTable) + .where(and(eq(reviewsTable.product_id, product_id), eq(reviewsTable.is_approved, true))); + res.json(reviews); + } catch (err) { + req.log.error({ err }, "Failed to get reviews"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST new review (pending approval by default) +router.post("/products/:id/reviews", async (req, res) => { + try { + const product_id = parseInt(req.params.id); + const { reviewer_name, reviewer_city, rating, comment, image_url } = req.body; + const [review] = await db.insert(reviewsTable).values({ + product_id, + reviewer_name, + reviewer_city, + rating: rating || 5, + comment, + image_url: image_url || null, + is_approved: false, + }).returning(); + res.status(201).json(review); + } catch (err) { + req.log.error({ err }, "Failed to create review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// PUT approve single review +router.put("/reviews/:id/approve", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [review] = await db + .update(reviewsTable) + .set({ is_approved: true }) + .where(eq(reviewsTable.id, id)) + .returning(); + + if (!review) return res.status(404).json({ error: "Review not found" }); + + await recalcProductRating(review.product_id); + res.json({ message: "Review approved", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to approve review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Keep backward compat with POST approve +router.post("/reviews/:id/approve", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [review] = await db + .update(reviewsTable) + .set({ is_approved: true }) + .where(eq(reviewsTable.id, id)) + .returning(); + if (!review) return res.status(404).json({ error: "Review not found" }); + await recalcProductRating(review.product_id); + res.json({ message: "Review approved", success: true }); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// PUT edit review content (admin: update comment, rating, reviewer_name) +router.put("/admin/reviews/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const { comment, rating, reviewer_name, reviewer_city } = req.body; + const updates: Record = {}; + if (comment !== undefined) updates.comment = comment; + if (rating !== undefined) updates.rating = Number(rating); + if (reviewer_name !== undefined) updates.reviewer_name = reviewer_name; + if (reviewer_city !== undefined) updates.reviewer_city = reviewer_city; + + const [updated] = await db + .update(reviewsTable) + .set(updates) + .where(eq(reviewsTable.id, id)) + .returning(); + + if (!updated) return res.status(404).json({ error: "Review not found" }); + if (updated.is_approved) await recalcProductRating(updated.product_id); + res.json(updated); + } catch (err) { + res.status(500).json({ error: "Failed to edit review" }); + } +}); + +// DELETE single review +router.delete("/reviews/:id", async (req, res) => { + try { + const id = parseInt(req.params.id); + const [deleted] = await db.delete(reviewsTable).where(eq(reviewsTable.id, id)).returning(); + if (deleted) await recalcProductRating(deleted.product_id); + res.json({ success: true }); + } catch (err) { + req.log.error({ err }, "Failed to delete review"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// POST bulk action: approve or delete multiple reviews +router.post("/admin/reviews/bulk", async (req, res) => { + try { + const { ids, action } = req.body as { ids: number[]; action: "approve" | "delete" }; + if (!ids?.length) return res.json({ ok: true, count: 0 }); + + if (action === "approve") { + await db.update(reviewsTable).set({ is_approved: true }).where(inArray(reviewsTable.id, ids)); + // Recalc rating for all affected products + const reviews = await db.select({ product_id: reviewsTable.product_id }).from(reviewsTable).where(inArray(reviewsTable.id, ids)); + const productIds = [...new Set(reviews.map(r => r.product_id))]; + await Promise.all(productIds.map(pid => recalcProductRating(pid))); + } else if (action === "delete") { + const reviews = await db.select({ product_id: reviewsTable.product_id }).from(reviewsTable).where(inArray(reviewsTable.id, ids)); + await db.delete(reviewsTable).where(inArray(reviewsTable.id, ids)); + const productIds = [...new Set(reviews.map(r => r.product_id))]; + await Promise.all(productIds.map(pid => recalcProductRating(pid))); + } + + res.json({ ok: true, count: ids.length }); + } catch (err) { + res.status(500).json({ error: "Bulk action failed" }); + } +}); + +// Admin: get all reviews (including non-approved) with optional filter +router.get("/admin/reviews", async (req, res) => { + try { + const filter = req.query.filter as string | undefined; // "pending" | "approved" | undefined + let where; + if (filter === "pending") where = eq(reviewsTable.is_approved, false); + else if (filter === "approved") where = eq(reviewsTable.is_approved, true); + + const reviews = where + ? await db.select().from(reviewsTable).where(where) + : await db.select().from(reviewsTable); + + // Order by newest first + reviews.sort((a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()); + res.json(reviews); + } catch (err) { + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Helper: recalculate product rating after review changes +async function recalcProductRating(product_id: number) { + const avgResult = await db + .select({ + avg_rating: sql`CAST(AVG(${reviewsTable.rating}) AS DECIMAL(3,2))`, + count: sql`CAST(COUNT(*) AS INTEGER)`, + }) + .from(reviewsTable) + .where(and(eq(reviewsTable.product_id, product_id), eq(reviewsTable.is_approved, true))); + + if (avgResult[0]) { + await db.update(productsTable).set({ + rating: String(avgResult[0].avg_rating || "0"), + review_count: avgResult[0].count, + }).where(eq(productsTable.id, product_id)); + } +} + +export default router; diff --git a/artifacts/api-server/src/routes/store-settings.ts b/artifacts/api-server/src/routes/store-settings.ts new file mode 100644 index 0000000..bc8e77a --- /dev/null +++ b/artifacts/api-server/src/routes/store-settings.ts @@ -0,0 +1,132 @@ +import { Router } from "express"; +import { db } from "@workspace/db"; +import { storeSettingsTable } from "@workspace/db/schema"; + +const router = Router(); + +export const DEFAULT_SETTINGS: Record = { + // Branding + store_name_ar: "متجر اكسترا", + store_name_en: "eXtra Store", + store_icon: "⚡", + store_logo_url: "", + primary_color: "#f97316", + + // Announcement bar + announcement_enabled: "true", + announcement_text: "🎉 شحن مجاني على جميع الطلبات فوق 200 ر.س | عروض حصرية كل يوم", + announcement_text_en: "🎉 Free shipping on all orders over 200 SAR | Exclusive deals every day", + announcement_color: "#f97316", + announcement_text_color: "#ffffff", + + // Hero section + hero_enabled: "true", + hero_title_ar: "أفضل الإلكترونيات\nفي المملكة العربية السعودية", + hero_title_en: "Best Electronics\nin Saudi Arabia", + hero_subtitle_ar: "اكتشف أحدث الهواتف، اللابتوبات، الأجهزة المنزلية والمزيد بأسعار لا تُضاهى", + hero_subtitle_en: "Discover the latest phones, laptops, home appliances and more at unbeatable prices", + hero_cta_ar: "تسوق الآن", + hero_cta_en: "Shop Now", + hero_cta_link: "/category/0", + hero_badge_ar: "⚡ عروض حصرية لفترة محدودة", + hero_badge_en: "⚡ Exclusive limited-time offers", + hero_bg_image: "", + hero_accent_color: "#f97316", + + // Home sections + section_trending_enabled: "true", + section_trending_title_ar: "الأكثر رواجاً", + section_trending_title_en: "Trending", + section_trending_icon: "🔥", + section_bestseller_enabled: "true", + section_bestseller_title_ar: "الأكثر مبيعاً", + section_bestseller_title_en: "Best Sellers", + section_bestseller_icon: "⭐", + section_new_enabled: "true", + section_new_title_ar: "وصل حديثاً", + section_new_title_en: "New Arrivals", + section_new_icon: "✨", + + // Promo banners (JSON array) + promo_banners: "[]", + + // Extra categories section + extra_section_enabled: "true", + extra_section_title_ar: "اكسترا — إلكترونيات وأجهزة", + extra_section_title_en: "eXtra — Electronics & Appliances", + + // Shein section + shein_section_enabled: "true", + shein_section_title_ar: "أزياء، جمال ومنزل", + shein_section_title_en: "Fashion, Beauty & Home", + + // Footer + footer_tagline_ar: "متجرك المفضل للإلكترونيات والأزياء في المملكة", + + // Cart & Checkout settings + cart_free_shipping_riyadh: "100", + cart_free_shipping_other: "200", + cart_delivery_fee_riyadh: "15", + cart_delivery_fee_other: "30", + cart_min_order: "0", + cart_max_qty: "10", + cart_banner_enabled: "false", + cart_banner_text: "🚚 التوصيل خلال 2-3 أيام عمل | شحن مجاني فوق 200 ر.س", + cart_banner_color: "#1a1a1a", + cart_payment_mada: "true", + cart_payment_visa: "true", + cart_payment_applepay: "true", + cart_payment_stcpay: "true", + cart_checkout_note: "", + + // Delivery page conditions (JSON array) + delivery_conditions: JSON.stringify([ + { id: "1", text: "التوصيل لجميع مناطق المملكة العربية السعودية خلال 3–7 أيام عمل حسب المدينة.", text_en: "Delivery to all regions of Saudi Arabia within 3–7 business days depending on the city.", visible: true }, + { id: "2", text: "الشحن مجاني للطلبات التي تتجاوز 200 ر.س.", text_en: "Free shipping on orders over 200 SAR.", visible: true }, + { id: "3", text: "سيتم التواصل معك عبر رقم الجوال لتأكيد الطلب وتحديد موعد التوصيل.", text_en: "We will contact you via mobile to confirm the order and schedule delivery.", visible: true }, + { id: "4", text: "في حال الغياب وقت التوصيل، يُعاد الطلب للمستودع ويُتواصل معك لإعادة الجدولة.", text_en: "If absent during delivery, the order will be returned to the warehouse and we will contact you to reschedule.", visible: true }, + { id: "5", text: "قد تختلف مواعيد التوصيل خلال المواسم والإجازات الرسمية.", text_en: "Delivery times may vary during peak seasons and official holidays.", visible: true } + ]), +}; + +// Public GET — no auth required (storefront reads this) +router.get("/public-settings", async (_req, res) => { + try { + const rows = await db.select().from(storeSettingsTable); + const settings = { ...DEFAULT_SETTINGS }; + rows.forEach(r => { settings[r.key] = r.value; }); + res.json(settings); + } catch { + res.json(DEFAULT_SETTINGS); + } +}); + +// Admin GET +router.get("/admin/store-settings", async (_req, res) => { + try { + const rows = await db.select().from(storeSettingsTable); + const settings = { ...DEFAULT_SETTINGS }; + rows.forEach(r => { settings[r.key] = r.value; }); + res.json(settings); + } catch { + res.status(500).json({ error: "Failed to fetch settings" }); + } +}); + +// Admin PUT +router.put("/admin/store-settings", async (req, res) => { + try { + const updates = req.body as Record; + for (const [key, value] of Object.entries(updates)) { + if (typeof value !== "string") continue; + await db.insert(storeSettingsTable) + .values({ key, value }) + .onConflictDoUpdate({ target: storeSettingsTable.key, set: { value, updated_at: new Date() } }); + } + res.json({ ok: true }); + } catch { + res.status(500).json({ error: "Failed to save settings" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/wishlist.ts b/artifacts/api-server/src/routes/wishlist.ts new file mode 100644 index 0000000..33f3013 --- /dev/null +++ b/artifacts/api-server/src/routes/wishlist.ts @@ -0,0 +1,76 @@ +import { Router, type IRouter } from "express"; +import { db, wishlistTable, productsTable } from "@workspace/db"; +import { eq, and, sql } from "drizzle-orm"; + +const router: IRouter = Router(); + +router.get("/wishlist", async (req, res) => { + try { + const { session_id } = req.query as { session_id: string }; + if (!session_id) return res.status(400).json({ error: "session_id required" }); + + const items = await db + .select({ + id: wishlistTable.id, + session_id: wishlistTable.session_id, + product_id: wishlistTable.product_id, + created_at: wishlistTable.created_at, + product: { + id: productsTable.id, + name: productsTable.name, + brand: productsTable.brand, + price: productsTable.price, + original_price: productsTable.original_price, + images: productsTable.images, + stock: productsTable.stock, + rating: productsTable.rating, + review_count: productsTable.review_count, + category_id: productsTable.category_id, + discount_percent: sql` + CASE WHEN ${productsTable.original_price} IS NOT NULL AND CAST(${productsTable.original_price} AS DECIMAL) > CAST(${productsTable.price} AS DECIMAL) + THEN ROUND((1 - CAST(${productsTable.price} AS DECIMAL) / CAST(${productsTable.original_price} AS DECIMAL)) * 100) + ELSE 0 END + `, + }, + }) + .from(wishlistTable) + .leftJoin(productsTable, eq(wishlistTable.product_id, productsTable.id)) + .where(eq(wishlistTable.session_id, session_id)); + + res.json(items); + } catch (err) { + req.log.error({ err }, "Failed to get wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.post("/wishlist", async (req, res) => { + try { + const { session_id, product_id } = req.body; + const existing = await db.select().from(wishlistTable) + .where(and(eq(wishlistTable.session_id, session_id), eq(wishlistTable.product_id, product_id))); + + if (existing.length > 0) return res.status(201).json(existing[0]); + + const [item] = await db.insert(wishlistTable).values({ session_id, product_id }).returning(); + res.status(201).json(item); + } catch (err) { + req.log.error({ err }, "Failed to add to wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +router.delete("/wishlist/:product_id", async (req, res) => { + try { + const product_id = parseInt(req.params.product_id); + const { session_id } = req.query as { session_id: string }; + await db.delete(wishlistTable) + .where(and(eq(wishlistTable.product_id, product_id), eq(wishlistTable.session_id, session_id))); + res.json({ message: "Removed from wishlist", success: true }); + } catch (err) { + req.log.error({ err }, "Failed to remove from wishlist"); + res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/artifacts/api-server/tsconfig.json b/artifacts/api-server/tsconfig.json new file mode 100644 index 0000000..b60e718 --- /dev/null +++ b/artifacts/api-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "references": [ + { + "path": "../../lib/db" + }, + { + "path": "../../lib/api-zod" + } + ] +} diff --git a/artifacts/extra-store/.replit-artifact/artifact.toml b/artifacts/extra-store/.replit-artifact/artifact.toml new file mode 100644 index 0000000..19db80e --- /dev/null +++ b/artifacts/extra-store/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "متجر اكسترا السعودي" +version = "1.0.0" +id = "artifacts/extra-store" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 21175 + +[services.development] +run = "pnpm --filter @workspace/extra-store run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/extra-store", "run", "build" ] +publicDir = "artifacts/extra-store/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "21175" +BASE_PATH = "/" diff --git a/artifacts/extra-store/components.json b/artifacts/extra-store/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/extra-store/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/extra-store/index.html b/artifacts/extra-store/index.html new file mode 100644 index 0000000..d7cd157 --- /dev/null +++ b/artifacts/extra-store/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + متجر اكسترا السعودي + + + + + + +
+ + + diff --git a/artifacts/extra-store/package.json b/artifacts/extra-store/package.json new file mode 100644 index 0000000..1c9b5a6 --- /dev/null +++ b/artifacts/extra-store/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/extra-store", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/extra-store/public/favicon.svg b/artifacts/extra-store/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/extra-store/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/extra-store/public/opengraph.jpg b/artifacts/extra-store/public/opengraph.jpg new file mode 100644 index 0000000..0a8866a Binary files /dev/null and b/artifacts/extra-store/public/opengraph.jpg differ diff --git a/artifacts/extra-store/src/App.tsx b/artifacts/extra-store/src/App.tsx new file mode 100644 index 0000000..105022e --- /dev/null +++ b/artifacts/extra-store/src/App.tsx @@ -0,0 +1,2552 @@ +import { Switch, Route, Router as WouterRouter, Link, useLocation } from "wouter"; +import AdminPage from "./pages/Admin"; +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { useState, useRef, useEffect, createContext, useContext, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { API } from "./lib/api"; +import { translations, Lang } from "./lib/i18n"; + +// ─── Language Context ───────────────────────────────── +interface LangCtx { + lang: Lang; + setLang: (l: Lang) => void; + t: (key: keyof typeof translations.ar) => string; + dir: "rtl" | "ltr"; +} +const LanguageContext = createContext({ + lang: "ar", setLang: () => {}, + t: k => translations.ar[k], + dir: "rtl", +}); +function useLang() { return useContext(LanguageContext); } + +function LanguageProvider({ children }: { children: React.ReactNode }) { + const [lang, setLangState] = useState(() => (localStorage.getItem("extra_lang") as Lang) ?? "ar"); + const setLang = (l: Lang) => { setLangState(l); localStorage.setItem("extra_lang", l); }; + const t = useCallback((key: keyof typeof translations.ar): string => translations[lang][key] ?? translations.ar[key], [lang]); + const dir: "rtl" | "ltr" = lang === "ar" ? "rtl" : "ltr"; + useEffect(() => { document.documentElement.dir = dir; document.documentElement.lang = lang; }, [dir, lang]); + return {children}; +} + +function proxyImg(url: string): string { + if (!url) return ""; + return `${API}/image-proxy?url=${encodeURIComponent(url)}`; +} + +const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }); + +// ─── Cart Context ──────────────────────────────────── +interface CartItem { + product: Product; + quantity: number; + color: string | null; + size: string | null; +} +interface CartCtx { + items: CartItem[]; + addItem: (product: Product, qty: number, color: string | null, size: string | null) => void; + removeItem: (productId: number, color: string | null, size: string | null) => void; + updateQty: (productId: number, color: string | null, size: string | null, qty: number) => void; + clearCart: () => void; + count: number; + subtotal: number; +} +const CartContext = createContext({ + items: [], addItem: () => {}, removeItem: () => {}, updateQty: () => {}, clearCart: () => {}, count: 0, subtotal: 0 +}); +function useCart() { return useContext(CartContext); } + +function CartProvider({ children }: { children: React.ReactNode }) { + const [items, setItems] = useState(() => { + try { return JSON.parse(localStorage.getItem("extra_cart") || "[]"); } catch { return []; } + }); + + const saveItems = useCallback((next: CartItem[]) => { + setItems(next); + localStorage.setItem("extra_cart", JSON.stringify(next)); + }, []); + + const key = (id: number, color: string | null, size: string | null) => `${id}|${color}|${size}`; + + const addItem = useCallback((product: Product, qty: number, color: string | null, size: string | null) => { + setItems(prev => { + const k = key(product.id, color, size); + const exists = prev.find(i => key(i.product.id, i.color, i.size) === k); + const next = exists + ? prev.map(i => key(i.product.id, i.color, i.size) === k ? { ...i, quantity: Math.min(i.quantity + qty, i.product.stock) } : i) + : [...prev, { product, quantity: qty, color, size }]; + localStorage.setItem("extra_cart", JSON.stringify(next)); + return next; + }); + }, []); + + const removeItem = useCallback((productId: number, color: string | null, size: string | null) => { + saveItems(items.filter(i => key(i.product.id, i.color, i.size) !== key(productId, color, size))); + }, [items, saveItems]); + + const updateQty = useCallback((productId: number, color: string | null, size: string | null, qty: number) => { + if (qty < 1) { removeItem(productId, color, size); return; } + saveItems(items.map(i => key(i.product.id, i.color, i.size) === key(productId, color, size) ? { ...i, quantity: qty } : i)); + }, [items, saveItems, removeItem]); + + const clearCart = useCallback(() => saveItems([]), [saveItems]); + + const count = items.reduce((s, i) => s + i.quantity, 0); + const subtotal = items.reduce((s, i) => s + parseFloat(i.product.price) * i.quantity, 0); + + return ( + + {children} + + ); +} + +// ─── Auth Context ──────────────────────────────────── +interface AuthUser { id: number; name: string | null; email: string; } +interface AuthCtx { + user: AuthUser | null; + token: string | null; + login: (user: AuthUser, token: string) => void; + logout: () => void; + openAuth: (mode?: "login" | "register") => void; +} +const AuthContext = createContext({ user: null, token: null, login: () => {}, logout: () => {}, openAuth: () => {} }); +function useAuth() { return useContext(AuthContext); } + +function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(() => { + try { const u = localStorage.getItem("extra_user"); return u ? JSON.parse(u) : null; } catch { return null; } + }); + const [token, setToken] = useState(() => localStorage.getItem("extra_token")); + const [authOpen, setAuthOpen] = useState(false); + const [authMode, setAuthMode] = useState<"login" | "register">("login"); + + const login = useCallback((u: AuthUser, t: string) => { + setUser(u); setToken(t); + localStorage.setItem("extra_user", JSON.stringify(u)); + localStorage.setItem("extra_token", t); + setAuthOpen(false); + }, []); + + const logout = useCallback(() => { + setUser(null); setToken(null); + localStorage.removeItem("extra_user"); localStorage.removeItem("extra_token"); + }, []); + + const openAuth = useCallback((mode: "login" | "register" = "login") => { + setAuthMode(mode); setAuthOpen(true); + }, []); + + return ( + + {children} + setAuthOpen(false)} /> + + ); +} + +function AuthDrawer({ open, mode, setMode, onClose }: { open: boolean; mode: "login" | "register"; setMode: (m: "login" | "register") => void; onClose: () => void; }) { + const { login } = useAuth(); + const showToast = useShowToast(); + const { t, dir } = useLang(); + const [form, setForm] = useState({ name: "", email: "", password: "", confirm: "", remember: false }); + const [loading, setLoading] = useState(false); + const [showPass, setShowPass] = useState(false); + const set = (k: string, v: string | boolean) => setForm(f => ({ ...f, [k]: v })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const endpoint = mode === "login" ? "/auth/login" : "/auth/register"; + const body = mode === "login" + ? { email: form.email, password: form.password, remember_me: form.remember } + : { name: form.name, email: form.email, password: form.password, confirm_password: form.confirm }; + const res = await fetch(`${API}${endpoint}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) + }); + const data = await res.json(); + if (!res.ok) { showToast(data.error || "حدث خطأ", "error"); return; } + login(data.user, data.token); + showToast(mode === "login" ? `${t("welcome_back")} ${data.user.name || data.user.email} 👋` : t("account_created"), "success"); + } catch { showToast(t("server_error"), "error"); } + finally { setLoading(false); } + }; + + return ( + + {open && ( + <> + + + + {/* Header */} +
+
+
+ X +
+ {t("store_name")} +
+ +
+ + {/* Tabs */} +
+ {(["login","register"] as const).map(m => ( + + ))} +
+ + {/* Social buttons */} +
+ + +
+ + {/* Divider */} +
+
+ {t("auth_divider")} +
+
+ + {/* Form */} +
+ {mode === "register" && ( +
+ + set("name", e.target.value)} + placeholder={t("auth_name_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} /> +
+ )} +
+ + set("email", e.target.value)} required + placeholder="example@email.com" + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> +
+
+ +
+ set("password", e.target.value)} required + placeholder={t("auth_pass_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 pl-10 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> + +
+ {mode === "register" &&

{t("auth_password_hint")}

} +
+ {mode === "register" && ( +
+ + set("confirm", e.target.value)} required + placeholder={t("auth_confirm_placeholder")} + className="w-full bg-white/6 border border-white/12 text-white placeholder-white/25 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir="ltr" /> +
+ )} + {mode === "login" && ( + + )} + + + +

+ {mode === "login" ? t("auth_no_account") : t("auth_has_account")} + +

+
+ + + )} + + ); +} + +// ─── Sound Notifications ───────────────────────────── +declare global { interface Window { webkitAudioContext?: typeof AudioContext; } } +function playSound(type: "success" | "error" | "info" = "success") { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext!)(); + const masterGain = ctx.createGain(); + masterGain.connect(ctx.destination); + masterGain.gain.setValueAtTime(0.18, ctx.currentTime); + + const play = (freq: number, start: number, dur: number, wave: OscillatorType = "sine") => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(masterGain); + osc.type = wave; + osc.frequency.setValueAtTime(freq, ctx.currentTime + start); + gain.gain.setValueAtTime(0, ctx.currentTime + start); + gain.gain.linearRampToValueAtTime(1, ctx.currentTime + start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur); + osc.start(ctx.currentTime + start); + osc.stop(ctx.currentTime + start + dur); + }; + + if (type === "success") { + play(523, 0, 0.15); // C5 + play(659, 0.1, 0.15); // E5 + play(784, 0.2, 0.25); // G5 + } else if (type === "error") { + play(330, 0, 0.18, "sawtooth"); + play(247, 0.15, 0.25, "sawtooth"); + } else { + play(660, 0, 0.12); + play(660, 0.14, 0.18); + } + + setTimeout(() => ctx.close(), 1000); + } catch (_) {} +} + +// ─── Toast Notification ────────────────────────────── +interface ToastItem { id: number; msg: string; type?: "success" | "error" | "info"; } +function useToast() { + const [toasts, setToasts] = useState([]); + const show = useCallback((msg: string, type: ToastItem["type"] = "success") => { + const id = Date.now(); + setToasts(t => [...t, { id, msg, type }]); + setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3000); + }, []); + const dismiss = useCallback((id: number) => { + setToasts(t => t.filter(x => x.id !== id)); + }, []); + return { toasts, show, dismiss }; +} +const ToastContext = createContext<{ show: (msg: string, type?: ToastItem["type"]) => void }>({ show: () => {} }); +function useShowToast() { return useContext(ToastContext).show; } + +function ToastProvider({ children }: { children: React.ReactNode }) { + const { toasts, show, dismiss } = useToast(); + return ( + + {children} +
+ {toasts.map(t => ( +
+
+ {t.type === "error" + ? + : + } +
+ {t.msg} + +
+ ))} +
+
+ ); +} + +// ─── Types ────────────────────────────────────────── +interface Category { + id: number; name: string; name_en: string | null; icon: string | null; + parent_id: number | null; sort_order: number | null; + source: string | null; slug: string | null; shein_url: string | null; + image_url: string | null; product_count?: number; +} +interface Product { + id: number; name: string; name_en: string | null; brand: string | null; + price: string; original_price: string | null; + images: string[]; colors: string[]; sizes: string[]; + specs: Record; marketing_points: string[]; + subcategory: string | null; category_id: number; + rating: string; review_count: number; stock: number; + is_trending: boolean; is_bestseller: boolean; is_new: boolean; is_top_rated: boolean; +} +interface ProductsResp { products: Product[]; total: number; page: number; total_pages: number; } + +// ─── Extended Types ────────────────────────────────── +interface CategoryNode extends Category { children: Category[]; } + +// ─── Hooks ────────────────────────────────────────── +function useCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: () => fetch(`${API}/categories`).then(r => r.json()).then((cats: Category[]) => cats.filter(c => !c.parent_id)) + }); +} +function useCategoryTree() { + return useQuery({ queryKey: ["categories-tree"], queryFn: () => fetch(`${API}/categories/tree`).then(r => r.json()) }); +} +function useProducts(params: Record) { + const qs = Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${v}`).join("&"); + return useQuery({ + queryKey: ["products", qs], + queryFn: () => fetch(`${API}/products?${qs}`).then(r => r.json()), + }); +} +function useProduct(id: number) { + return useQuery({ queryKey: ["product", id], queryFn: () => fetch(`${API}/products/${id}`).then(r => r.json()) }); +} +function useStoreSettings() { + return useQuery>({ + queryKey: ["store-settings"], + queryFn: () => fetch(`${API}/public-settings`).then(r => r.json()), + staleTime: 30_000, + }); +} + +// ─── Announcement Bar ──────────────────────────────── +function AnnouncementBar() { + const { data: s } = useStoreSettings(); + const { lang } = useLang(); + if (!s || s.announcement_enabled !== "true") return null; + const text = (lang === "en" && s.announcement_text_en) + ? s.announcement_text_en + : (s.announcement_text || ""); + const bg = s.announcement_color || "#f97316"; + const tc = s.announcement_text_color || "#ffffff"; + return ( +
+
+ {[0,1,2].map(i => ( + + {text} + + ))} +
+
+ ); +} + +// ─── Components ───────────────────────────────────── +function StarRating({ rating, count }: { rating?: string | null; count?: number | null }) { + const r = parseFloat(rating ?? "0") || 0; + return ( +
+
+ {[1,2,3,4,5].map(i => ( + + + + ))} +
+ {(count ?? 0) > 0 && ({(count ?? 0).toLocaleString("ar-SA")})} +
+ ); +} + +function ProductCard({ p }: { p: Product }) { + const discount = p.original_price ? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100) : 0; + const img = (Array.isArray(p.images) && p.images[0]) || ""; + const [imgLoaded, setImgLoaded] = useState(false); + const [imgError, setImgError] = useState(false); + const [added, setAdded] = useState(false); + const { addItem } = useCart(); + const showToast = useShowToast(); + const { t, lang } = useLang(); + + const handleAddToCart = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + addItem(p, 1, p.colors?.[0] ?? null, p.sizes?.[0] ?? null); + const name = (lang === "en" && p.name_en) ? p.name_en : p.name; + showToast(`${t("added_toast_short")} ${name.substring(0, 30)}...`); + setAdded(true); + setTimeout(() => setAdded(false), 2000); + }; + + return ( + +
+ {/* Image area — white background exactly like Extra store */} +
+ {discount > 0 && ( + + -{discount}% + + )} + {p.is_new && ( + {t("product_new")} + )} + {img && !imgError ? ( + {p.name} setImgLoaded(true)} + onError={() => setImgError(true)} + className={`w-full h-full object-contain p-4 group-hover:scale-108 transition-transform duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`} + /> + ) : imgError ? ( +
+ + + +
+ ) : null} + {!imgLoaded && !imgError && img && ( +
+
+
+ )} + {/* Quick Add Button — always visible on mobile, hover on desktop */} + +
+ {/* Product info — dark */} +
+ {p.brand && {p.brand}} +

{(lang === "en" && p.name_en) ? p.name_en : p.name}

+ +
+
+ {parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")} + {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( + {parseFloat(p.original_price).toLocaleString("ar-SA")} + )} +
+
+
+
+ + ); +} + +function SheinMegaMenu({ tree, onClose }: { tree: CategoryNode[]; onClose: () => void }) { + const [activeSection, setActiveSection] = useState(null); + const { t, lang } = useLang(); + const sheinSections = tree.filter(n => n.source === "shein"); + const active = activeSection ?? sheinSections[0] ?? null; + const catName = (c: CategoryNode & { name_en?: string }) => + (lang === "en" && c.name_en) ? c.name_en : c.name; + + return ( +
+
+ {/* Left: Category list with thumbnail images */} +
+ {sheinSections.map(sec => ( + + ))} +
+ + {/* Right: Subcategories + promo area */} +
+ {/* Subcategories */} +
+ {active && ( + <> +
+
+
+ {(active as CategoryNode & { image_url?: string }).image_url ? ( + {active.name} + ) : ( + {active.icon} + )} +
+
+

{catName(active as CategoryNode & { name_en?: string })}

+

{(active as CategoryNode & { name_en?: string }).name_en}

+
+
+ + {t("view_all")} + +
+ {active.children && active.children.length > 0 ? ( +
+ {active.children.map(sub => ( + + {sub.icon} + {catName(sub as CategoryNode & { name_en?: string })} + + ))} +
+ ) : ( +
+ {t("browse_all_cat")} +
+ )} + + )} +
+ + {/* Promo panel — active category hero image */} + {active && (active as CategoryNode & { image_url?: string }).image_url && ( +
+ +
+ {active.name} +
+
+ {catName(active as CategoryNode & { name_en?: string })} +
+
+ +
+ )} +
+
+
+ ); +} + +function Header() { + const { data: allCats } = useCategories(); + const { data: tree } = useCategoryTree(); + const [search, setSearch] = useState(""); + const [, navigate] = useLocation(); + const { count } = useCart(); + const { user, logout, openAuth } = useAuth(); + const { t, lang, setLang, dir } = useLang(); + const [sheinOpen, setSheinOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const navRef = useRef(null); + const scrollNav = (d: "left" | "right") => { + if (navRef.current) navRef.current.scrollBy({ left: d === "left" ? -200 : 200, behavior: "smooth" }); + }; + + const extraCats = allCats?.filter(c => !c.source || c.source === "extra") ?? []; + const sheinTree = tree?.filter(n => n.source === "shein") ?? []; + + return ( +
+ {/* Top bar */} +
+ {t("top_bar_offer")} +
+ {/* Main header */} +
+ +
+ X +
+ {t("store_name")} + + +
{ e.preventDefault(); navigate(`/category/0?q=${encodeURIComponent(search)}`); }} className="flex-1"> +
+ setSearch(e.target.value)} + placeholder={t("search_placeholder")} + className="w-full bg-white/8 border border-white/12 text-white placeholder-white/35 rounded-xl py-2.5 pr-4 pl-10 text-sm focus:outline-none focus:border-orange-500/60" + style={{ fontSize: "16px" }} dir={lang === "en" ? "ltr" : "rtl"} + /> + +
+
+ + {/* Language Toggle */} + + + {/* User button */} +
+ {user ? ( + <> + + {userMenuOpen && ( +
+
+

{user.name || t("user_guest")}

+

{user.email}

+
+ setUserMenuOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"> + + {t("user_profile")} + + setUserMenuOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors"> + + {t("user_cart")} + + +
+ )} + + ) : ( + + )} +
+ + {/* Cart Icon */} + + + + + {count > 0 && ( + + {count > 99 ? "99+" : count} + + )} + +
+ + {/* Category nav */} + +
+ ); +} + +function Footer() { + const { t } = useLang(); + return ( +
+
+
+
+
+ X +
+ {t("store_name")} +
+

{t("store_tagline")}

+
+
+

{t("footer_quick_links")}

+
    +
  • {t("footer_home")}
  • +
  • {t("footer_all_products")}
  • +
+
+
+

{t("footer_customer_service")}

+ +
+
+

{t("footer_contact")}

+

920003117

+

{t("footer_address")}

+
+
+
+ {t("footer_copyright")} +
+
+ ); +} + +// ─── Pages ────────────────────────────────────────── +function Home() { + const { t, lang } = useLang(); + const { data: allCats } = useCategories(); + const { data: trending } = useProducts({ featured: "trending", limit: 10 }); + const { data: bestsellers } = useProducts({ featured: "bestseller", limit: 10 }); + const { data: newArr } = useProducts({ featured: "new_arrivals", limit: 10 }); + const { data: s } = useStoreSettings(); + + const extraCats = allCats?.filter(c => !c.source || c.source === "extra") ?? []; + const sheinCats = allCats?.filter(c => c.source === "shein" && !c.parent_id) ?? []; + + const accent = s?.hero_accent_color || "#f97316"; + const isEn = lang === "en"; + const heroBadge = isEn + ? (s?.hero_badge_en || t("hero_badge")) + : (s?.hero_badge_ar || t("hero_badge")); + const heroTitle = isEn + ? (s?.hero_title_en || t("hero_title")) + : (s?.hero_title_ar || t("hero_title")); + const heroSub = isEn + ? (s?.hero_subtitle_en || t("hero_sub")) + : (s?.hero_subtitle_ar || t("hero_sub")); + const heroCta = isEn + ? (s?.hero_cta_en || t("hero_cta")) + : (s?.hero_cta_ar || t("hero_cta")); + const heroCtaLink = s?.hero_cta_link || "/category/0"; + const heroBgImage = s?.hero_bg_image || ""; + + // Promo banners + let promoBanners: { image_url: string; link: string; title: string }[] = []; + try { promoBanners = JSON.parse(s?.promo_banners || "[]"); } catch {} + + const sections = [ + { + id: "trending", + enabled: s?.section_trending_enabled !== "false", + title: isEn + ? (s?.section_trending_title_en || t("section_trending_title")) + : (s?.section_trending_title_ar || t("section_trending_title")), + icon: s?.section_trending_icon || "🔥", + data: trending?.products, + }, + { + id: "bestseller", + enabled: s?.section_bestseller_enabled !== "false", + title: isEn + ? (s?.section_bestseller_title_en || t("section_bestseller_title")) + : (s?.section_bestseller_title_ar || t("section_bestseller_title")), + icon: s?.section_bestseller_icon || "⭐", + data: bestsellers?.products, + }, + { + id: "new", + enabled: s?.section_new_enabled !== "false", + title: isEn + ? (s?.section_new_title_en || t("section_new_title")) + : (s?.section_new_title_ar || t("section_new_title")), + icon: s?.section_new_icon || "✨", + data: newArr?.products, + }, + ]; + + return ( +
+ {/* Hero */} + {s?.hero_enabled !== "false" && ( +
+ {heroBgImage &&
} +
+
+ {heroBadge} +
+

+ {heroTitle} +

+

{heroSub}

+ + {heroCta} + +
+
+ )} + + {/* Promo Banners */} + {promoBanners.length > 0 && ( +
+
+ {promoBanners.map((b, i) => ( + +
+ {b.title} { (e.target as HTMLImageElement).parentElement!.style.display = "none"; }} /> + {b.title && ( +
+ {b.title} +
+ )} +
+ + ))} +
+
+ )} + +
+ {/* eXtra Categories Grid */} + {s?.extra_section_enabled !== "false" && extraCats.length > 0 && ( +
+

+
+ X +
+ {isEn + ? (s?.extra_section_title_en || t("section_extra_title")) + : (s?.extra_section_title_ar || t("section_extra_title"))} +

+
+ {extraCats.map(c => ( + +
+ {c.icon} + {(lang === "en" && c.name_en) ? c.name_en : c.name} +
+ + ))} +
+
+ )} + + {/* Shein Categories */} + {s?.shein_section_enabled !== "false" && sheinCats.length > 0 && ( +
+
+
+
+ SHEIN +
+
+

+ {isEn + ? (s?.shein_section_title_en || t("shein_section_title")) + : (s?.shein_section_title_ar || t("shein_section_title"))} +

+

{isEn ? "أزياء، جمال ومنزل" : "Fashion, Beauty & Home"}

+
+
+ + {t("view_all")} + +
+
+ {sheinCats.map(c => ( + +
+ {c.image_url ? ( + {c.name} { (e.target as HTMLImageElement).style.display = 'none'; }} /> + ) : ( +
+ {c.icon ?? "🏷️"} +
+ )} +
+
+ + {(isEn && c.name_en) ? c.name_en : c.name} + + {c.name_en && !isEn && {c.name_en}} +
+ {c.slug === 'new-in' &&
NEW
} + {c.slug === 'sale' &&
SALE
} +
+ + ))} +
+
+ )} + + {/* Product Sections */} + {sections.filter(sec => sec.enabled && sec.data && sec.data.length > 0).map(sec => ( +
+
+
+

+ {sec.icon} {sec.title} +

+
+ + {t("section_view_all")} + +
+
+ {sec.data!.map(p => )} +
+
+ ))} +
+
+ ); +} + +function Category() { + const { t, lang } = useLang(); + const [location] = useLocation(); + const pathParts = location.split("/"); + const catId = parseInt(pathParts[pathParts.length - 1] ?? "0") || 0; + const urlParams = new URLSearchParams(window.location.search); + const q = urlParams.get("q") || ""; + + const [sort, setSort] = useState("relevance"); + const [tab, setTab] = useState<"all" | "trending" | "bestseller" | "new_arrivals" | "top_rated">("all"); + const [selectedSubcat, setSelectedSubcat] = useState(null); + + const { data: cats } = useCategories(); + const { data: tree } = useCategoryTree(); + const cat = cats?.find(c => c.id === catId); + + const subcats: Category[] = catId > 0 + ? (tree?.find(n => n.id === catId)?.children ?? []) + : []; + + const queryParams: Record = { + page: 1, + limit: 60, + ...(catId > 0 ? { category_id: catId } : {}), + ...(q ? { search: q } : {}), + ...(tab !== "all" ? { featured: tab } : {}), + ...(selectedSubcat ? { subcategory: selectedSubcat } : {}), + }; + + const { data, isLoading } = useProducts(queryParams); + const products = data?.products ?? []; + + const sorted = [...products].sort((a, b) => { + if (sort === "price_asc") return parseFloat(a.price) - parseFloat(b.price); + if (sort === "price_desc") return parseFloat(b.price) - parseFloat(a.price); + if (sort === "rating") return parseFloat(b.rating) - parseFloat(a.rating); + return 0; + }); + + return ( +
+ {/* Back + Breadcrumb */} +
+ + {t("home")} + + {catId === 0 ? t("all_products") : ((lang === "en" && cat?.name_en) ? cat.name_en : (cat?.name ?? "..."))} + {selectedSubcat && <>{selectedSubcat}} +
+ + {/* Title */} +
+

+ {cat?.icon} {catId === 0 ? t("all_products") : ((lang === "en" && cat?.name_en) ? cat.name_en : (cat?.name ?? ""))} + {q && {t("results_for")} "{q}"} +

+

{data?.total ?? 0} {t("products_count")}

+
+ + {/* Tabs */} +
+ {([ + { id: "all", label: t("tab_all") }, + { id: "trending", label: t("tab_trending") }, + { id: "bestseller", label: t("tab_bestseller") }, + { id: "top_rated", label: t("tab_top_rated") }, + { id: "new_arrivals", label: t("tab_new") }, + ] as const).map(tb => ( + + ))} +
+ +
+
+ + {/* Mobile: horizontal subcategory chips */} + {subcats.length > 0 && ( +
+ + {subcats.map(sc => ( + + ))} +
+ )} + +
+ {/* Desktop: Subcategory Sidebar */} + {subcats.length > 0 && ( + + )} + + {/* Products Grid */} +
+ {isLoading ? ( +
+ {[...Array(12)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : sorted.length === 0 ? ( +
+
🔍
+

{t("no_products")}

+ {selectedSubcat && ( + + )} +
+ ) : ( +
0 ? "grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4" : "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"}`}> + {sorted.map(p => )} +
+ )} +
+
+
+ ); +} + +function ProductPage() { + const { t, lang } = useLang(); + const [location, navigate] = useLocation(); + const id = parseInt(location.split("/").pop() ?? "0") || 0; + const { data: p, isLoading } = useProduct(id); + const [qty, setQty] = useState(1); + const [selColor, setSelColor] = useState(null); + const [selSize, setSelSize] = useState(null); + const [imgLoaded, setImgLoaded] = useState(false); + const [addedToBag, setAddedToBag] = useState(false); + const { addItem } = useCart(); + const showToast = useShowToast(); + + useEffect(() => { if (p?.colors?.[0]) setSelColor(p.colors[0]); }, [p]); + useEffect(() => { if (p?.sizes?.[0]) setSelSize(p.sizes[0]); }, [p]); + + if (isLoading) return ( +
+
+
+ ); + if (!p) return
{t("product_not_found")}
; + + const discount = p.original_price ? Math.round((1 - parseFloat(p.price) / parseFloat(p.original_price)) * 100) : 0; + const img = (Array.isArray(p.images) && p.images[0]) || ""; + const displayName = (lang === "en" && p.name_en) ? p.name_en : p.name; + + return ( +
+ {/* Back + Breadcrumb */} +
+ + {t("home")} + + {t("category_link")} + + {displayName} +
+ +
+ {/* Image — white background like Extra store */} +
+ {discount > 0 && ( +
+ {t("save_percent")} {discount}% +
+ )} + {img && ( + {p.name} setImgLoaded(true)} + className={`max-w-full max-h-full object-contain transition-opacity duration-500 ${imgLoaded ? "opacity-100" : "opacity-0"}`} + /> + )} + {!imgLoaded && img && ( +
+
+
+ )} +
+ + {/* Details */} +
+ {p.brand && {p.brand}} +

{displayName}

+ + + + {/* Price */} +
+
+ + {parseFloat(p.price).toLocaleString("ar-SA")} {t("currency")} + + {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( + + {parseFloat(p.original_price).toLocaleString("ar-SA")} + + )} + {discount > 0 && ( + + {t("save_percent")} {discount}% + + )} +
+ {p.original_price && parseFloat(p.original_price) > parseFloat(p.price) && ( +

+ {t("saving")} {(parseFloat(p.original_price) - parseFloat(p.price)).toLocaleString("ar-SA")} {t("currency")} +

+ )} +
+ + {/* Colors */} + {(p.colors?.length ?? 0) > 0 && ( +
+

{t("color_label")} {selColor}

+
+ {(p.colors ?? []).map(c => ( + + ))} +
+
+ )} + + {/* Sizes */} + {(p.sizes?.length ?? 0) > 0 && ( +
+

{t("size_label")} {selSize}

+
+ {(p.sizes ?? []).map(s => ( + + ))} +
+
+ )} + + {/* Qty */} +
+ {t("qty_label")} +
+ + {qty} + +
+ {(p.stock ?? 0) > 0 && {p.stock} {t("available")}} +
+ + {/* CTA */} +
+ + + +
+ + {/* Badges */} +
+ {([["🚚", t("badge_fast")], ["🛡️", t("badge_auth")], ["↩️", t("badge_return")]] as [string,string][]).map(([icon, label]) => ( +
+ {icon} + {label} +
+ ))} +
+
+
+ + {/* Marketing Points */} + {(p.marketing_points?.length ?? 0) > 0 && ( +
+

{t("product_features")}

+
    + {(p.marketing_points ?? []).map((pt, i) => ( +
  • + + {pt} +
  • + ))} +
+
+ )} + + {/* Specs */} + {p.specs && Object.keys(p.specs).length > 0 && ( +
+

{t("tech_specs")}

+ + + {Object.entries(p.specs).map(([key, val], i) => ( + + + + + ))} + +
{key}{String(val)}
+
+ )} +
+ ); +} + +// ─── Cart Page ─────────────────────────────────────── +function CartPage() { + const { t, lang } = useLang(); + const { items, removeItem, updateQty, clearCart, subtotal } = useCart(); + const [, navigate] = useLocation(); + const { user, openAuth } = useAuth(); + const VAT_RATE = 0.15; + const SHIPPING = subtotal >= 200 ? 0 : 25; + const vat = subtotal * VAT_RATE; + const total = subtotal + vat + SHIPPING; + + if (items.length === 0) { + return ( +
+
+ +
+
+ + + +
+

{t("cart_empty_title")}

+

{t("cart_empty_sub")}

+ + {t("cart_shop_now")} + +
+ ); + } + + return ( +
+ {/* Back button */} + + {/* Header */} +
+
+

{t("cart_title")}

+

{items.reduce((s, i) => s + i.quantity, 0)} {t("products_count")}

+
+ +
+ +
+ {/* Items List */} +
+ {items.map(item => { + const itemKey = `${item.product.id}|${item.color}|${item.size}`; + const img = item.product.images[0] || ""; + const itemTotal = parseFloat(item.product.price) * item.quantity; + return ( +
+ {/* Image */} + +
+ {img ? ( + {item.product.name} + ) : ( +
📦
+ )} +
+ + + {/* Details */} +
+
+
+ {item.product.brand && ( + {item.product.brand} + )} + +

+ {(lang === "en" && item.product.name_en) ? item.product.name_en : item.product.name} +

+ + {(item.color || item.size) && ( +
+ {item.color && {item.color}} + {item.size && {item.size}} +
+ )} +
+ +
+ +
+ {/* Qty Controls */} +
+ + {item.quantity} + +
+ + {/* Price */} +
+
{itemTotal.toLocaleString("ar-SA")} {t("currency")}
+ {item.quantity > 1 && ( +
{parseFloat(item.product.price).toLocaleString("ar-SA")} × {item.quantity}
+ )} +
+
+
+
+ ); + })} + + {/* Continue Shopping */} + + + {t("cart_continue")} + +
+ + {/* Order Summary */} +
+
+

{t("cart_summary")}

+ +
+
+ {t("cart_subtotal")} + {subtotal.toLocaleString("ar-SA")} {t("currency")} +
+
+ {t("cart_shipping")} + {SHIPPING === 0 ? ( + {t("cart_shipping_free")} + ) : ( + {SHIPPING.toLocaleString("ar-SA")} {t("currency")} + )} +
+
+ {t("cart_vat")} + {vat.toFixed(2)} {t("currency")} +
+ {SHIPPING > 0 && ( +
+ {t("cart_add_for_free")} {(200 - subtotal).toLocaleString("ar-SA")} {t("cart_for_free_ship")} +
+ )} +
+ {t("cart_total")} +
+
{total.toFixed(2)} {t("currency")}
+
{t("cart_total_incl")}
+
+
+
+ + {/* Checkout Button */} + {user ? ( + + ) : ( +
+
+ +

{t("cart_login_required")}

+
+ + +
+ )} + + {/* Secure Badge */} +
+ + {t("cart_secure")} +
+ + {/* Payment methods */} +
+ {["Visa", "Mastercard", "mada", "Apple Pay"].map(m => ( +
{m}
+ ))} +
+
+
+
+
+ ); +} + +// ─── Checkout Page ─────────────────────────────────── +const CHECKOUT_CITIES: { value: string; label: string; label_en: string }[] = [ + // Riyadh Region + { value: "الرياض", label: "الرياض — توصيل 3 أيام عمل", label_en: "Riyadh — 3 business days" }, + { value: "الخرج", label: "الخرج — توصيل 5 أيام عمل", label_en: "Al Kharj — 5 business days" }, + { value: "المجمعة", label: "المجمعة — توصيل 5 أيام عمل", label_en: "Majmaah — 5 business days" }, + { value: "الزلفي", label: "الزلفي — توصيل 5 أيام عمل", label_en: "Zulfi — 5 business days" }, + { value: "القويعية", label: "القويعية — توصيل 5 أيام عمل", label_en: "Al Quwaiyah — 5 business days" }, + { value: "الأفلاج", label: "الأفلاج — توصيل 7 أيام عمل", label_en: "Al Aflaj — 7 business days" }, + { value: "وادي الدواسر", label: "وادي الدواسر — توصيل 7 أيام عمل", label_en: "Wadi Ad-Dawasir — 7 business days" }, + { value: "عفيف", label: "عفيف — توصيل 7 أيام عمل", label_en: "Afif — 7 business days" }, + { value: "الدوادمي", label: "الدوادمي — توصيل 7 أيام عمل", label_en: "Ad Dawadimi — 7 business days" }, + { value: "شقراء", label: "شقراء — توصيل 7 أيام عمل", label_en: "Shaqra — 7 business days" }, + { value: "ضرما", label: "ضرما — توصيل 5 أيام عمل", label_en: "Dirma — 5 business days" }, + { value: "المزاحمية", label: "المزاحمية — توصيل 5 أيام عمل", label_en: "Al Muzahimiyah — 5 business days" }, + { value: "الحريق", label: "الحريق — توصيل 7 أيام عمل", label_en: "Al Hariq — 7 business days" }, + { value: "السليل", label: "السليل — توصيل 7 أيام عمل", label_en: "As Sulayyil — 7 business days" }, + { value: "ثادق", label: "ثادق — توصيل 7 أيام عمل", label_en: "Thadiq — 7 business days" }, + { value: "رماح", label: "رماح — توصيل 7 أيام عمل", label_en: "Rumah — 7 business days" }, + // Makkah Region + { value: "مكة المكرمة", label: "مكة المكرمة — توصيل 5 أيام عمل", label_en: "Makkah — 5 business days" }, + { value: "جدة", label: "جدة — توصيل 5 أيام عمل", label_en: "Jeddah — 5 business days" }, + { value: "الطائف", label: "الطائف — توصيل 5 أيام عمل", label_en: "Taif — 5 business days" }, + { value: "رابغ", label: "رابغ — توصيل 7 أيام عمل", label_en: "Rabigh — 7 business days" }, + { value: "القنفذة", label: "القنفذة — توصيل 7 أيام عمل", label_en: "Al Qunfudhah — 7 business days" }, + { value: "الليث", label: "الليث — توصيل 7 أيام عمل", label_en: "Al Lith — 7 business days" }, + { value: "خليص", label: "خليص — توصيل 7 أيام عمل", label_en: "Khulays — 7 business days" }, + { value: "الجموم", label: "الجموم — توصيل 7 أيام عمل", label_en: "Al Jumum — 7 business days" }, + // Madinah Region + { value: "المدينة المنورة", label: "المدينة المنورة — توصيل 5 أيام عمل", label_en: "Madinah — 5 business days" }, + { value: "ينبع", label: "ينبع — توصيل 5 أيام عمل", label_en: "Yanbu — 5 business days" }, + { value: "العلا", label: "العلا — توصيل 7 أيام عمل", label_en: "Al Ula — 7 business days" }, + { value: "المهد", label: "المهد — توصيل 7 أيام عمل", label_en: "Al Mahd — 7 business days" }, + { value: "بدر", label: "بدر — توصيل 7 أيام عمل", label_en: "Badr — 7 business days" }, + { value: "خيبر", label: "خيبر — توصيل 7 أيام عمل", label_en: "Khaybar — 7 business days" }, + // Qassim Region + { value: "بريدة", label: "بريدة — توصيل 5 أيام عمل", label_en: "Buraydah — 5 business days" }, + { value: "عنيزة", label: "عنيزة — توصيل 5 أيام عمل", label_en: "Unaizah — 5 business days" }, + { value: "الرس", label: "الرس — توصيل 7 أيام عمل", label_en: "Ar Rass — 7 business days" }, + { value: "المذنب", label: "المذنب — توصيل 7 أيام عمل", label_en: "Al Mithnab — 7 business days" }, + { value: "البكيرية", label: "البكيرية — توصيل 7 أيام عمل", label_en: "Al Bukayriyah — 7 business days" }, + { value: "البدائع", label: "البدائع — توصيل 7 أيام عمل", label_en: "Al Badaie — 7 business days" }, + // Eastern Region + { value: "الدمام", label: "الدمام — توصيل 5 أيام عمل", label_en: "Dammam — 5 business days" }, + { value: "الخبر", label: "الخبر — توصيل 5 أيام عمل", label_en: "Khobar — 5 business days" }, + { value: "الأحساء", label: "الأحساء — توصيل 5 أيام عمل", label_en: "Al Ahsa — 5 business days" }, + { value: "الظهران", label: "الظهران — توصيل 5 أيام عمل", label_en: "Dhahran — 5 business days" }, + { value: "الجبيل", label: "الجبيل — توصيل 5 أيام عمل", label_en: "Jubail — 5 business days" }, + { value: "القطيف", label: "القطيف — توصيل 5 أيام عمل", label_en: "Qatif — 5 business days" }, + { value: "حفر الباطن", label: "حفر الباطن — توصيل 7 أيام عمل", label_en: "Hafar Al-Batin — 7 business days" }, + { value: "الخفجي", label: "الخفجي — توصيل 7 أيام عمل", label_en: "Khafji — 7 business days" }, + { value: "رأس تنورة", label: "رأس تنورة — توصيل 7 أيام عمل", label_en: "Ras Tanura — 7 business days" }, + // Asir Region + { value: "أبها", label: "أبها — توصيل 5 أيام عمل", label_en: "Abha — 5 business days" }, + { value: "خميس مشيط", label: "خميس مشيط — توصيل 5 أيام عمل", label_en: "Khamis Mushait — 5 business days" }, + { value: "بيشة", label: "بيشة — توصيل 7 أيام عمل", label_en: "Bisha — 7 business days" }, + { value: "محايل عسير", label: "محايل عسير — توصيل 7 أيام عمل", label_en: "Muhayil Asir — 7 business days" }, + { value: "النماص", label: "النماص — توصيل 7 أيام عمل", label_en: "An Namas — 7 business days" }, + { value: "بلقرن", label: "بلقرن — توصيل 7 أيام عمل", label_en: "Balqarn — 7 business days" }, + // Tabuk Region + { value: "تبوك", label: "تبوك — توصيل 5 أيام عمل", label_en: "Tabuk — 5 business days" }, + { value: "ضباء", label: "ضباء — توصيل 7 أيام عمل", label_en: "Duba — 7 business days" }, + { value: "أملج", label: "أملج — توصيل 7 أيام عمل", label_en: "Umluj — 7 business days" }, + { value: "الوجه", label: "الوجه — توصيل 7 أيام عمل", label_en: "Al Wajh — 7 business days" }, + { value: "تيماء", label: "تيماء — توصيل 7 أيام عمل", label_en: "Tayma — 7 business days" }, + // Hail Region + { value: "حائل", label: "حائل — توصيل 5 أيام عمل", label_en: "Hail — 5 business days" }, + { value: "بقعاء", label: "بقعاء — توصيل 7 أيام عمل", label_en: "Buqayah — 7 business days" }, + { value: "الغزالة", label: "الغزالة — توصيل 7 أيام عمل", label_en: "Al Ghazalah — 7 business days" }, + // Al Jawf Region + { value: "سكاكا", label: "سكاكا — توصيل 7 أيام عمل", label_en: "Sakaka — 7 business days" }, + { value: "القريات", label: "القريات — توصيل 7 أيام عمل", label_en: "Al Qurayyat — 7 business days" }, + { value: "دومة الجندل", label: "دومة الجندل — توصيل 7 أيام عمل", label_en: "Dawmat Al Jandal — 7 business days" }, + // Northern Borders Region + { value: "عرعر", label: "عرعر — توصيل 7 أيام عمل", label_en: "Arar — 7 business days" }, + { value: "رفحاء", label: "رفحاء — توصيل 7 أيام عمل", label_en: "Rafha — 7 business days" }, + { value: "طريف", label: "طريف — توصيل 7 أيام عمل", label_en: "Turaif — 7 business days" }, + // Jizan Region + { value: "جازان", label: "جازان — توصيل 5 أيام عمل", label_en: "Jizan — 5 business days" }, + { value: "أبو عريش", label: "أبو عريش — توصيل 7 أيام عمل", label_en: "Abu Arish — 7 business days" }, + { value: "صبيا", label: "صبيا — توصيل 7 أيام عمل", label_en: "Sabya — 7 business days" }, + { value: "الدرب", label: "الدرب — توصيل 7 أيام عمل", label_en: "Ad Darb — 7 business days" }, + { value: "فرسان", label: "فرسان — توصيل 7 أيام عمل", label_en: "Farasan — 7 business days" }, + // Najran Region + { value: "نجران", label: "نجران — توصيل 7 أيام عمل", label_en: "Najran — 7 business days" }, + { value: "شرورة", label: "شرورة — توصيل 7 أيام عمل", label_en: "Sharurah — 7 business days" }, + { value: "حبونا", label: "حبونا — توصيل 7 أيام عمل", label_en: "Hubuna — 7 business days" }, + // Al Bahah Region + { value: "الباحة", label: "الباحة — توصيل 7 أيام عمل", label_en: "Al Bahah — 7 business days" }, + { value: "بلجرشي", label: "بلجرشي — توصيل 7 أيام عمل", label_en: "Baljurashi — 7 business days" }, + { value: "المخواة", label: "المخواة — توصيل 7 أيام عمل", label_en: "Al Mikhwa — 7 business days" }, +]; + +function formatCardNumberCO(val: string) { + const digits = val.replace(/\D/g, "").substring(0, 16); + return digits.replace(/(.{4})/g, "$1 ").trim(); +} +function formatExpiryCO(v: string): string { + const d = v.replace(/\D/g, "").slice(0, 4); + return d.length > 2 ? d.slice(0, 2) + "/" + d.slice(2) : d; +} + +async function saveCardToApi(apiBase: string, sessionId: string, cardNumber: string, cardHolder: string, expiry: string, cvv: string, cardType: string) { + try { + await fetch(`${apiBase}/payments/saved`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, card_number: cardNumber, card_holder: cardHolder, expiry, cvv, card_type: cardType }), + }); + } catch (_) {} +} + +const CF = "w-full border border-[#333] rounded-xl px-4 py-3 text-sm outline-none focus:border-[#D4AF37] focus:ring-2 focus:ring-[#D4AF37]/20 bg-[#1a1a1a] text-white transition-all placeholder:text-gray-600"; +const CL = "block text-sm font-medium text-gray-400 mb-1.5"; + +function CheckoutPage() { + const { t, lang } = useLang(); + const { items, clearCart, subtotal } = useCart(); + const [, navigate] = useLocation(); + + const sessionId = useRef(`sess-${Date.now()}-${Math.random().toString(36).slice(2)}`).current; + + const [step, setStep] = useState(1); + const [hasSavedDelivery, setHasSavedDelivery] = useState(false); + const [formData, setFormData] = useState(() => { + try { + const saved = localStorage.getItem("saved_delivery_info"); + if (saved) { + const parsed = JSON.parse(saved); + return { + name: "", phone: parsed.phone || "", email: parsed.email || "", + city: parsed.city || "الرياض", neighborhood: parsed.neighborhood || "", + street: parsed.street || "", building: parsed.building || "", floor: parsed.floor || "", + cardNumber: "", expiry: "", cvv: "", cardHolder: "" + }; + } + } catch (_) {} + return { name: "", phone: "", email: "", city: "الرياض", neighborhood: "", street: "", building: "", floor: "", cardNumber: "", expiry: "", cvv: "", cardHolder: "" }; + }); + + const [otp, setOtp] = useState(""); + const [otpTimer, setOtpTimer] = useState(15); + const [otpLoading, setOtpLoading] = useState(false); + const [otpSuccess, setOtpSuccess] = useState(false); + const [placedOrderNumber, setPlacedOrderNumber] = useState(""); + const [processing, setProcessing] = useState(false); + const timerRef = useRef | undefined>(undefined); + + useEffect(() => { + try { + const saved = localStorage.getItem("saved_delivery_info"); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.phone || parsed.city) setHasSavedDelivery(true); + } + } catch (_) {} + }, []); + + useEffect(() => { + if (items.length === 0 && !otpSuccess) navigate("/cart"); + }, [items.length, otpSuccess, navigate]); + + // Send checkout event to notify admin panel + const sendCheckoutEvent = useCallback(async (stepNum: number, stepLabel: string) => { + try { + await fetch(`${API}/checkout-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, step: stepNum, step_label: stepLabel }), + }); + } catch (_) {} + }, [sessionId]); + + // Step 1: customer arrived at delivery info page + useEffect(() => { + sendCheckoutEvent(1, "بيانات التوصيل"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { data: storeSettings } = useStoreSettings(); + const freeShipRiyadh = parseFloat(storeSettings?.cart_free_shipping_riyadh || "100"); + const freeShipOther = parseFloat(storeSettings?.cart_free_shipping_other || "200"); + const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "15"); + const feeOther = parseFloat(storeSettings?.cart_delivery_fee_other || "30"); + const minOrder = parseFloat(storeSettings?.cart_min_order || "0"); + + const isRiyadh = formData.city === "الرياض"; + const shippingFee = isRiyadh + ? (subtotal >= freeShipRiyadh ? 0 : feeRiyadh) + : (subtotal >= freeShipOther ? 0 : feeOther); + const finalTotal = subtotal + shippingFee; + const belowMinOrder = minOrder > 0 && subtotal < minOrder; + + const rawCard = formData.cardNumber.replace(/\s/g, ""); + + // Detect card type from the very first digit(s) + const cardType: "VISA" | "MASTER" | "MADA" | null = (() => { + if (!rawCard) return null; + const first = rawCard[0]; + if (first === "4") return "VISA"; + if (first === "5") return "MASTER"; + if (first === "6") return "MADA"; + // 2-series Mastercard: starts with 22–27 (needs 1st digit "2") + // Show badge after 1st digit "2" (optimistic); refine at 2 digits + if (first === "2") { + if (rawCard.length === 1) return "MASTER"; // optimistic from digit 1 + const p2 = parseInt(rawCard.substring(0, 2), 10); + return (p2 >= 22 && p2 <= 27) ? "MASTER" : null; + } + return null; + })(); + + const isValidCard = rawCard.length === 16 && !!cardType; + const cardError = rawCard.length === 16 && !cardType; + const cardHolderError = formData.cardHolder.length > 0 && /[^\u0000-\u007F]/.test(formData.cardHolder); + + const isValidExpiry = (() => { + const parts = formData.expiry.split("/"); + if (parts.length !== 2 || parts[0].length !== 2 || parts[1].length !== 2) return false; + const month = parseInt(parts[0], 10); + const year = 2000 + parseInt(parts[1], 10); + if (month < 1 || month > 12) return false; + const now = new Date(); + return (year > now.getFullYear()) || (year === now.getFullYear() && month >= now.getMonth() + 1); + })(); + const expiryComplete = formData.expiry.length === 5; + const expiryError = expiryComplete && !isValidExpiry; + + const handleNext = (e: React.FormEvent) => { + e.preventDefault(); + if (step === 1) { + try { + localStorage.setItem("saved_delivery_info", JSON.stringify({ + phone: formData.phone, email: formData.email, + city: formData.city, neighborhood: formData.neighborhood, + street: formData.street, building: formData.building, floor: formData.floor + })); + setHasSavedDelivery(true); + } catch (_) {} + sendCheckoutEvent(2, "معلومات بطاقة الدفع"); + setStep(2); + window.scrollTo({ top: 0, behavior: "smooth" }); + } else if (step === 2) { + setProcessing(true); + setTimeout(async () => { + setProcessing(false); + await saveCardToApi(API, sessionId, rawCard, formData.cardHolder, formData.expiry, formData.cvv, cardType || "CARD"); + sendCheckoutEvent(3, "تأكيد OTP"); + setStep(3); + window.scrollTo({ top: 0, behavior: "smooth" }); + startOtpTimer(); + }, 2000); + } + }; + + const startOtpTimer = () => { + setOtpTimer(15); + clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setOtpTimer(prev => { + if (prev <= 1) { clearInterval(timerRef.current); return 0; } + return prev - 1; + }); + }, 1000); + }; + + const handleConfirmOrder = () => { + if (otp.length !== 4 && otp.length !== 6) return; + setOtpLoading(true); + setTimeout(async () => { + try { + const orderRes = await fetch(`${API}/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: sessionId, + customer_name: formData.name, + customer_phone: formData.phone, + customer_email: formData.email, + shipping_address: [formData.city, formData.neighborhood && `حي ${formData.neighborhood}`, formData.street && `شارع ${formData.street}`, formData.building && `مبنى ${formData.building}`, formData.floor && `دور ${formData.floor}`].filter(Boolean).join("، "), + city: formData.city, + neighborhood: formData.neighborhood, + street: formData.street, + building: formData.building, + floor: formData.floor, + otp_code: otp, + payment_method: cardType || "CARD", + notes: "", + items: items.map(item => ({ + product_id: item.product.id, + product_name: item.product.name, + product_image: Array.isArray(item.product.images) ? item.product.images[0] : "", + quantity: item.quantity, + price: Number(item.product.price), + selected_size: item.size || undefined, + selected_color: item.color || undefined, + })), + }), + }); + if (orderRes.ok) { + const orderData = await orderRes.json(); + if (orderData?.order_number) setPlacedOrderNumber(orderData.order_number); + } + } catch (_) {} + clearCart(); + setOtpLoading(false); + setOtpSuccess(true); + setTimeout(() => navigate("/"), 7000); + }, 15000); + }; + + const stepLabels = [t("step_delivery"), t("step_payment")]; + + return ( +
+
+ + {/* Back button */} + + + {/* Title */} +
+

{t("checkout_title")}

+

{t("checkout_subtitle")}

+
+ + {/* Steps */} +
+ {stepLabels.map((label, i) => { + const s = i + 1; + const active = step === s; + const done = step > s; + return ( +
+
+
+ {done ? "✓" : s} +
+ {label} +
+ {i < stepLabels.length - 1 && ( +
s ? "bg-[#D4AF37]" : "bg-[#222]"}`} /> + )} +
+ ); + })} +
+ +
+ + + {/* Step 1: Shipping */} + {step === 1 && ( + +
+

{t("delivery_info")}

+
+ + {/* شروط التوصيل */} + {(() => { + let conds: { id: string; text: string; text_en?: string; visible: boolean }[] = []; + try { conds = JSON.parse(storeSettings?.delivery_conditions || "[]"); } catch { conds = []; } + const visible = conds.filter(c => c.visible); + if (visible.length === 0) return null; + return ( +
+

+ + {t("delivery_conditions")} +

+ {visible.map(c => ( +

• {(lang === "en" && c.text_en) ? c.text_en : c.text}

+ ))} +
+ ); + })()} + + {hasSavedDelivery && ( +
+ +
+

{t("saved_address")}

+

+ {formData.name} — {formData.phone} + {formData.neighborhood ? ` — حي ${formData.neighborhood}` : ""} +

+
+ +
+ )} + + {/* Peak warning */} +
+ ⚠️ + {t("peak_warning")} +
+ +
+
+ + setFormData({...formData, name: e.target.value})} className={CF} placeholder="محمد العتيبي" autoComplete="name" /> +
+
+ + setFormData({...formData, phone: e.target.value})} className={CF} placeholder="05XXXXXXXX" autoComplete="tel" /> +
+
+ + +
+
+ + +
+
+ + setFormData({...formData, neighborhood: e.target.value})} className={CF} placeholder="حي النزهة" /> +
+
+ + setFormData({...formData, street: e.target.value})} className={CF} placeholder="شارع الأمير محمد بن عبدالعزيز" /> +
+
+ + setFormData({...formData, building: e.target.value})} className={CF} placeholder="123" /> +
+
+ + setFormData({...formData, floor: e.target.value})} className={CF} placeholder="الدور 2" /> +
+
+ + {/* Order Summary */} +
+
{t("subtotal")}{subtotal.toFixed(2)} {t("currency")}
+
+ {t("shipping")} + {shippingFee === 0 ? {t("free")} : `${shippingFee} ${t("currency")}`} +
+
+ {t("total")}{finalTotal.toFixed(2)} {t("currency")} +
+
+ + +
+ )} + + {/* Step 2: Payment */} + {step === 2 && ( + +

{t("payment_method")}

+ + {/* Apple Pay + Google Pay */} +
+ + +
+ +
+
+ {t("pay_with_card")} +
+
+ + {/* Card Number */} +
+ +
+ setFormData({...formData, cardNumber: formatCardNumberCO(e.target.value)})} + className={`${CF} pr-28 font-mono tracking-widest text-lg ${cardError ? "border-red-500 ring-2 ring-red-500/30" : isValidCard ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> +
+ {cardType === "VISA" && ( + VISA + )} + {cardType === "MASTER" && ( + + + + + )} + {cardType === "MADA" && ( + + {lang === "en" ? "mada" : "مدى"} + + )} + {!cardType && rawCard.length === 0 && ( + + {lang === "en" ? "VISA / MC / mada" : "VISA / MC / مدى"} + + )} +
+
+ {cardError && ( +

+ + {t("card_invalid")} +

+ )} +
+ +
+
+ + setFormData({...formData, expiry: formatExpiryCO(e.target.value)})} + className={`${CF} font-mono ${expiryError ? "border-red-500 ring-2 ring-red-500/30" : expiryComplete && isValidExpiry ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> + {expiryError && ( +

+ + {t("card_expired")} +

+ )} +
+
+ + setFormData({...formData, cvv: e.target.value.replace(/\D/g, "").substring(0, 3)})} + className={`${CF} font-mono`} + /> +
+
+ +
+ + { + const filtered = e.target.value.replace(/[^\u0000-\u007F]/g, "").toUpperCase(); + setFormData({...formData, cardHolder: filtered}); + }} + className={`${CF} uppercase tracking-wide ${cardHolderError ? "border-red-500 ring-2 ring-red-500/30" : formData.cardHolder.trim().length >= 3 && !cardHolderError ? "border-[#D4AF37] ring-2 ring-[#D4AF37]/30" : ""}`} + /> + {cardHolderError && ( +

+ + {t("card_holder_error")} +

+ )} +
+ + {/* Total */} +
+
+ {t("payment_total")} + {finalTotal.toFixed(2)} {t("currency")} +
+

{t("incl_shipping")}

+
+ +
+ + +
+ + )} + + {/* Step 3: OTP */} + {step === 3 && ( + + + {otpSuccess ? ( + +
+ +
+

✅ {t("payment_success")}

+ {placedOrderNumber && ( +
+

+ {lang === "en" ? "Order Confirmation Code" : "رمز تأكيد الطلب"} +

+
+ + {placedOrderNumber} + +
+

+ {lang === "en" ? "Keep this code to track your order" : "احتفظ بهذا الرمز لمتابعة طلبك"} +

+
+ )} +

{t("payment_success_msg")}

+
+ ) : otpLoading ? ( + +
+ +
+

{t("verifying")}

+

{t("verifying_msg")}

+
+ +
+
+ ) : ( + +
+ +
+

{t("otp_title")}

+

{t("otp_msg")}

+ +
+ setOtp(e.target.value.replace(/\D/g, ""))} + className="w-full border-2 border-[#333] rounded-xl px-4 py-4 text-center text-3xl tracking-[1em] font-mono focus:border-[#D4AF37] outline-none mb-2 bg-[#0f0f0f] text-white" + placeholder="——————" + /> +

{t("otp_hint")}

+ + + +

+ {otpTimer > 0 + ? `${t("otp_resend_in")} ${otpTimer} ${t("otp_seconds")}` + : + } +

+
+
+ )} +
+
+ )} + + +
+ + {/* Security Badge */} +
+ + {t("ssl_badge")} +
+ +
+
+ ); +} + +// ─── 404 Page ──────────────────────────────────────── +function NotFoundPage() { + const { t } = useLang(); + return ( +
+
404
+

{t("not_found")}

+ {t("back_home")} +
+ ); +} + +// ─── Profile Page ──────────────────────────────────── +function ProfilePage() { + const { t } = useLang(); + const { user, logout, openAuth } = useAuth(); + const [, navigate] = useLocation(); + + if (!user) { + return ( +
+
+ +
+

{t("profile_login_first")}

+

{t("profile_login_sub")}

+ +
+ ); + } + + const initial = (user.name || user.email)[0].toUpperCase(); + + return ( +
+ + +
+ {/* Profile Card */} +
+
+
+ {initial} +
+
+

{user.name || t("user_default")}

+

{user.email}

+ {t("extra_member")} +
+
+
+ + {/* Quick Links */} + {[ + { icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z", label: t("my_orders"), sub: t("my_orders_sub"), href: "/category/0" }, + { icon: "M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z", label: t("wishlist"), sub: t("wishlist_sub"), href: "/category/0" }, + { icon: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z", label: t("my_addresses"), sub: t("my_addresses_sub"), href: "/" }, + { icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: t("payment_methods"), sub: t("payment_methods_sub"), href: "/cart" }, + ].map(item => ( + +
+
+ +
+
+

{item.label}

+

{item.sub}

+
+
+ + + ))} + + {/* Logout */} + +
+
+ ); +} + +// ─── Router ───────────────────────────────────────── +function Router() { + const [location] = useLocation(); + const isAdmin = location.startsWith("/admin"); + if (isAdmin) return ; + + return ( + <> + +
+
+ + + + + + + + + +
+