From e0d6d4fcaf489e65023a2e8c938024d4db8578fd Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 28 Mar 2026 04:47:13 +0000 Subject: [PATCH] Autosave: 20260328-044712 --- .env.example | 34 + .github/workflows/ci.yml | 42 + .github/workflows/deploy-flatlogic.yml | 83 + artifacts/api-server/src/app.ts | 10 +- artifacts/api-server/src/lib/ingest.ts | 423 +++++ .../api-server/src/middleware/api-key.ts | 67 + artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/ingest.ts | 193 +++ artifacts/api-server/src/types/express.d.ts | 9 + artifacts/extra-store/src/App.tsx | 1437 ++++++++++------- .../extra-store/src/lib/admin-preview-api.ts | 671 ++++++-- artifacts/extra-store/src/lib/api.ts | 4 +- artifacts/extra-store/src/lib/i18n.ts | 34 +- .../extra-store/src/lib/store-fallback.ts | 196 ++- artifacts/extra-store/src/pages/Admin.tsx | 432 +++-- artifacts/extra-store/vite.config.ts | 7 + docs/flatlogic-cicd-backend.md | 184 +++ lib/db/src/index.ts | 47 +- lib/db/src/schema/categories.ts | 38 +- lib/db/src/schema/index.ts | 1 + lib/db/src/schema/integration-events.ts | 29 + lib/db/src/schema/products.ts | 80 +- package.json | 6 +- scripts/flatlogic-deploy.sh | 58 + scripts/post-merge.sh | 12 +- 25 files changed, 3192 insertions(+), 907 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy-flatlogic.yml create mode 100644 artifacts/api-server/src/lib/ingest.ts create mode 100644 artifacts/api-server/src/middleware/api-key.ts create mode 100644 artifacts/api-server/src/routes/ingest.ts create mode 100644 artifacts/api-server/src/types/express.d.ts create mode 100644 docs/flatlogic-cicd-backend.md create mode 100644 lib/db/src/schema/integration-events.ts create mode 100755 scripts/flatlogic-deploy.sh mode change 100644 => 100755 scripts/post-merge.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e04e43 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# --------------------------------------------------------------------------- +# External database / Supabase (recommended: Supabase transaction pooler URL) +# --------------------------------------------------------------------------- +DATABASE_URL=postgresql://postgres:@:6543/postgres?sslmode=require +DB_SSL=require +DB_SSL_REJECT_UNAUTHORIZED=false +DB_POOL_MAX=20 +DB_CONNECTION_TIMEOUT_MS=10000 +DB_IDLE_TIMEOUT_MS=30000 +DB_QUERY_TIMEOUT_MS=15000 +DB_STATEMENT_TIMEOUT_MS=15000 +DB_APP_NAME=flatlogic-backend + +# --------------------------------------------------------------------------- +# API server / security +# --------------------------------------------------------------------------- +PORT=8080 +ADMIN_TOKEN=change-me-admin-token +API_INGEST_KEY=change-me-ingest-key +WEBHOOK_SECRET=change-me-global-webhook-secret +SHEIN_WEBHOOK_SECRET=change-me-shein-webhook-secret + +# --------------------------------------------------------------------------- +# Optional Supabase SDK keys (if later needed by background jobs) +# --------------------------------------------------------------------------- +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# --------------------------------------------------------------------------- +# Frontend +# --------------------------------------------------------------------------- +VITE_API_BASE_URL=/api +API_SERVER_URL=http://127.0.0.1:8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1870b70 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + - develop + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-check: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Enable Corepack + run: | + corepack enable + corepack prepare pnpm@10.16.1 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck workspace + run: pnpm typecheck + + - name: Build workspace + run: pnpm build diff --git a/.github/workflows/deploy-flatlogic.yml b/.github/workflows/deploy-flatlogic.yml new file mode 100644 index 0000000..d5d4711 --- /dev/null +++ b/.github/workflows/deploy-flatlogic.yml @@ -0,0 +1,83 @@ +name: Deploy to Flatlogic VM + +on: + push: + branches: + - main + - master + workflow_dispatch: + repository_dispatch: + types: + - bolt_sync + - replit_sync + - flatlogic_deploy + +concurrency: + group: flatlogic-deploy-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: production + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Enable Corepack + run: | + corepack enable + corepack prepare pnpm@10.16.1 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck workspace + run: pnpm typecheck + + - name: Build workspace + run: pnpm build + + - name: Configure SSH + env: + FLATLOGIC_SSH_KEY: ${{ secrets.FLATLOGIC_SSH_KEY }} + FLATLOGIC_HOST: ${{ secrets.FLATLOGIC_HOST }} + run: | + test -n "$FLATLOGIC_SSH_KEY" + test -n "$FLATLOGIC_HOST" + install -m 700 -d ~/.ssh + printf '%s' "$FLATLOGIC_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$FLATLOGIC_HOST" >> ~/.ssh/known_hosts + + - name: Deploy on Flatlogic VM + env: + FLATLOGIC_HOST: ${{ secrets.FLATLOGIC_HOST }} + FLATLOGIC_USER: ${{ secrets.FLATLOGIC_USER }} + PROJECT_DIR: ${{ secrets.FLATLOGIC_PROJECT_DIR }} + DEPLOY_BRANCH: ${{ secrets.FLATLOGIC_DEPLOY_BRANCH }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DB_SSL: ${{ secrets.DB_SSL }} + DB_POOL_MAX: ${{ secrets.DB_POOL_MAX }} + DB_QUERY_TIMEOUT_MS: ${{ secrets.DB_QUERY_TIMEOUT_MS }} + DB_STATEMENT_TIMEOUT_MS: ${{ secrets.DB_STATEMENT_TIMEOUT_MS }} + ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }} + API_INGEST_KEY: ${{ secrets.API_INGEST_KEY }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} + SHEIN_WEBHOOK_SECRET: ${{ secrets.SHEIN_WEBHOOK_SECRET }} + API_PORT: ${{ secrets.API_PORT }} + STORE_PORT: ${{ secrets.STORE_PORT }} + run: | + test -n "$FLATLOGIC_HOST" + test -n "$FLATLOGIC_USER" + test -n "$PROJECT_DIR" + ssh "$FLATLOGIC_USER@$FLATLOGIC_HOST" \ + "export PROJECT_DIR='$PROJECT_DIR' DEPLOY_BRANCH='${DEPLOY_BRANCH:-${GITHUB_REF_NAME}}' DATABASE_URL='$DATABASE_URL' DB_SSL='${DB_SSL:-require}' DB_POOL_MAX='${DB_POOL_MAX:-20}' DB_QUERY_TIMEOUT_MS='${DB_QUERY_TIMEOUT_MS:-15000}' DB_STATEMENT_TIMEOUT_MS='${DB_STATEMENT_TIMEOUT_MS:-15000}' ADMIN_TOKEN='$ADMIN_TOKEN' API_INGEST_KEY='$API_INGEST_KEY' WEBHOOK_SECRET='$WEBHOOK_SECRET' SHEIN_WEBHOOK_SECRET='$SHEIN_WEBHOOK_SECRET' API_PORT='${API_PORT:-8080}' STORE_PORT='${STORE_PORT:-3001}' && bash '$PROJECT_DIR/scripts/flatlogic-deploy.sh'" diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index f32f71e..8e62d77 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -6,6 +6,12 @@ import { logger } from "./lib/logger"; const app: Express = express(); +function captureRawBody(req: express.Request, _res: express.Response, buf: Buffer): void { + if (buf.length > 0) { + req.rawBody = buf.toString("utf8"); + } +} + app.use( pinoHttp({ logger, @@ -26,8 +32,8 @@ app.use( }), ); app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ verify: captureRawBody })); +app.use(express.urlencoded({ extended: true, verify: captureRawBody })); app.use("/api", router); diff --git a/artifacts/api-server/src/lib/ingest.ts b/artifacts/api-server/src/lib/ingest.ts new file mode 100644 index 0000000..c1d5ba7 --- /dev/null +++ b/artifacts/api-server/src/lib/ingest.ts @@ -0,0 +1,423 @@ +import { db, categoriesTable, integrationEventsTable, productsTable, type ProductVariant } from "@workspace/db"; +import { and, desc, eq, sql } from "drizzle-orm"; + +export type SourceName = "extra" | "shein"; + +type CategoryInput = { + id?: number; + name?: string; + name_en?: string; + slug?: string; + parent_slug?: string; +}; + +export type ProductIngestInput = { + source: SourceName; + external_id?: string; + sku?: string; + source_url?: string; + currency?: string; + availability?: string; + name?: string; + name_en?: string; + short_description?: string; + description?: string; + brand?: string; + subcategory?: string; + category_id?: number; + category?: CategoryInput; + price?: number; + original_price?: number; + images?: string[]; + sizes?: string[]; + colors?: string[]; + specs?: Record; + marketing_points?: string[]; + variants?: ProductVariant[]; + tags?: string[]; + metadata?: Record; + stock?: number; + rating?: number; + review_count?: number; + is_trending?: boolean; + is_bestseller?: boolean; + is_new?: boolean; + is_top_rated?: boolean; +}; + +export type WebhookProductPatch = Partial & { + source?: SourceName; + external_id?: string; + sku?: string; +}; + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function asInteger(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed === undefined ? undefined : Math.max(0, Math.trunc(parsed)); +} + +function asBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (["true", "1", "yes", "on"].includes(value.toLowerCase())) return true; + if (["false", "0", "no", "off"].includes(value.toLowerCase())) return false; + } + return fallback; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + +function asStringRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const entries = Object.entries(value as Record) + .map(([key, entryValue]) => { + const normalizedKey = asString(key); + const normalizedValue = asString(entryValue); + return normalizedKey && normalizedValue ? [normalizedKey, normalizedValue] : null; + }) + .filter((entry): entry is [string, string] => Boolean(entry)); + return Object.fromEntries(entries); +} + +function asJsonRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; +} + +function asVariants(value: unknown): ProductVariant[] { + if (!Array.isArray(value)) return []; + + const variants: ProductVariant[] = []; + + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const raw = entry as Record; + const label = asString(raw["label"]); + const price = asNumber(raw["price"]); + if (!label || price === undefined) continue; + const original = asNumber(raw["original_price"]); + variants.push({ + label, + price: toMoney(price), + original_price: original === undefined ? undefined : toMoney(original), + sku: asString(raw["sku"]), + }); + } + + return variants; +} + +function toMoney(value: number): string { + return value.toFixed(2); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function resolveSource(value: unknown, fallback: SourceName): SourceName { + return value === "shein" ? "shein" : fallback; +} + +function normalizeCategory(value: unknown): CategoryInput | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + return { + id: asInteger(raw["id"]), + name: asString(raw["name"]), + name_en: asString(raw["name_en"]), + slug: asString(raw["slug"]), + parent_slug: asString(raw["parent_slug"]), + }; +} + +export function normalizeProductInput(raw: unknown, fallbackSource: SourceName = "extra"): ProductIngestInput { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Each product payload must be an object"); + } + + const input = raw as Record; + const source = resolveSource(input["source"], fallbackSource); + const product: ProductIngestInput = { + source, + external_id: asString(input["external_id"]), + sku: asString(input["sku"]), + source_url: asString(input["source_url"]), + currency: asString(input["currency"]) ?? "SAR", + availability: asString(input["availability"]) ?? "unknown", + name: asString(input["name"]), + name_en: asString(input["name_en"]), + short_description: asString(input["short_description"]), + description: asString(input["description"]), + brand: asString(input["brand"]), + subcategory: asString(input["subcategory"]), + category_id: asInteger(input["category_id"]), + category: normalizeCategory(input["category"]), + price: asNumber(input["price"]), + original_price: asNumber(input["original_price"]), + images: asStringArray(input["images"]), + sizes: asStringArray(input["sizes"]), + colors: asStringArray(input["colors"]), + specs: asStringRecord(input["specs"]), + marketing_points: asStringArray(input["marketing_points"]), + variants: asVariants(input["variants"]), + tags: asStringArray(input["tags"]), + metadata: asJsonRecord(input["metadata"]), + stock: asInteger(input["stock"]), + rating: asNumber(input["rating"]), + review_count: asInteger(input["review_count"]), + is_trending: asBoolean(input["is_trending"]), + is_bestseller: asBoolean(input["is_bestseller"]), + is_new: asBoolean(input["is_new"], true), + is_top_rated: asBoolean(input["is_top_rated"]), + }; + + if (!product.external_id && !product.sku) { + throw new Error("Product payload must include external_id or sku"); + } + + return product; +} + +export function normalizeWebhookPatch(raw: unknown, fallbackSource: SourceName = "shein"): WebhookProductPatch { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Each webhook item must be an object"); + } + + const patch = normalizeProductInput(raw, fallbackSource); + return patch; +} + +async function ensureCategory(source: SourceName, category?: CategoryInput, categoryId?: number): Promise { + if (categoryId) { + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(eq(categoriesTable.id, categoryId)) + .limit(1); + + if (existing[0]) return existing[0].id; + } + + const fallbackSlug = `uncategorized-${source}`; + const slug = category?.slug ?? (category?.name ? slugify(category.name) : fallbackSlug); + const name = category?.name ?? (source === "shein" ? "شي إن" : "إكسترا"); + + const existing = await db + .select({ id: categoriesTable.id }) + .from(categoriesTable) + .where(and(eq(categoriesTable.source, source), eq(categoriesTable.slug, slug))) + .limit(1); + + if (existing[0]) return existing[0].id; + + const [inserted] = await db + .insert(categoriesTable) + .values({ + name, + name_en: category?.name_en ?? name, + slug, + source, + sort_order: 0, + }) + .returning({ id: categoriesTable.id }); + + if (!inserted) { + throw new Error("Failed to create category for ingested product"); + } + + return inserted.id; +} + +async function findExistingProduct(source: SourceName, externalId?: string, sku?: string) { + if (externalId) { + const rows = await db + .select() + .from(productsTable) + .where(and(eq(productsTable.source, source), eq(productsTable.external_id, externalId))) + .limit(1); + + if (rows[0]) return rows[0]; + } + + if (sku) { + const rows = await db + .select() + .from(productsTable) + .where(and(eq(productsTable.source, source), eq(productsTable.sku, sku))) + .limit(1); + + if (rows[0]) return rows[0]; + } + + return null; +} + +export async function upsertExternalProduct(input: ProductIngestInput) { + const existing = await findExistingProduct(input.source, input.external_id, input.sku); + const categoryId = await ensureCategory(input.source, input.category, input.category_id ?? existing?.category_id); + const productCode = input.external_id ?? input.sku ?? String(Date.now()); + const resolvedName = input.name ?? existing?.name ?? `${input.source.toUpperCase()} ${productCode}`; + const resolvedPrice = input.price ?? (existing?.price ? Number(existing.price) : 0); + const resolvedRating = input.rating ?? (existing?.rating ? Number(existing.rating) : 0); + const resolvedReviewCount = input.review_count ?? existing?.review_count ?? 0; + const resolvedStock = input.stock ?? existing?.stock ?? 0; + + const values = { + source: input.source, + external_id: input.external_id ?? existing?.external_id ?? null, + source_url: input.source_url ?? existing?.source_url ?? null, + currency: input.currency ?? existing?.currency ?? "SAR", + availability: input.availability ?? existing?.availability ?? "unknown", + name: resolvedName, + name_en: input.name_en ?? existing?.name_en ?? null, + short_description: input.short_description ?? existing?.short_description ?? null, + description: input.description ?? existing?.description ?? null, + brand: input.brand ?? existing?.brand ?? null, + subcategory: input.subcategory ?? existing?.subcategory ?? null, + sku: input.sku ?? existing?.sku ?? null, + category_id: categoryId, + price: toMoney(resolvedPrice), + original_price: + input.original_price !== undefined + ? toMoney(input.original_price) + : existing?.original_price ?? null, + images: input.images?.length ? input.images : existing?.images ?? [], + sizes: input.sizes?.length ? input.sizes : existing?.sizes ?? [], + colors: input.colors?.length ? input.colors : existing?.colors ?? [], + specs: Object.keys(input.specs ?? {}).length ? input.specs ?? {} : existing?.specs ?? {}, + marketing_points: + input.marketing_points?.length ? input.marketing_points : existing?.marketing_points ?? [], + variants: input.variants?.length ? input.variants : existing?.variants ?? [], + tags: input.tags?.length ? input.tags : existing?.tags ?? [], + metadata: Object.keys(input.metadata ?? {}).length ? input.metadata ?? {} : existing?.metadata ?? {}, + stock: resolvedStock, + rating: toMoney(resolvedRating), + review_count: resolvedReviewCount, + is_trending: input.is_trending ?? existing?.is_trending ?? false, + is_bestseller: input.is_bestseller ?? existing?.is_bestseller ?? false, + is_new: input.is_new ?? existing?.is_new ?? true, + is_top_rated: input.is_top_rated ?? existing?.is_top_rated ?? false, + last_synced_at: new Date(), + updated_at: new Date(), + }; + + if (existing) { + const [updated] = await db + .update(productsTable) + .set(values) + .where(eq(productsTable.id, existing.id)) + .returning(); + return { mode: "updated" as const, product: updated ?? existing }; + } + + const [created] = await db.insert(productsTable).values(values).returning(); + return { mode: "created" as const, product: created }; +} + +export async function applyWebhookPatch(input: WebhookProductPatch) { + const source = input.source ?? "shein"; + return upsertExternalProduct({ + ...input, + source, + }); +} + +export async function logIntegrationEvent(params: { + source: string; + eventType: string; + status: string; + payload: Record; + externalId?: string; + dedupeKey?: string; + itemsTotal?: number; + itemsSucceeded?: number; + itemsFailed?: number; + error?: string; +}) { + const [created] = await db + .insert(integrationEventsTable) + .values({ + source: params.source, + event_type: params.eventType, + status: params.status, + external_id: params.externalId ?? null, + dedupe_key: params.dedupeKey ?? null, + items_total: params.itemsTotal ?? 0, + items_succeeded: params.itemsSucceeded ?? 0, + items_failed: params.itemsFailed ?? 0, + error: params.error ?? null, + payload: params.payload, + processed_at: new Date(), + }) + .returning(); + + return created; +} + +export async function getPipelineStatus() { + const [productCounts, recentEvents] = await Promise.all([ + db + .select({ + total: sql`CAST(COUNT(*) AS INTEGER)`, + shein: sql`CAST(SUM(CASE WHEN ${productsTable.source} = 'shein' THEN 1 ELSE 0 END) AS INTEGER)`, + extra: sql`CAST(SUM(CASE WHEN ${productsTable.source} = 'extra' THEN 1 ELSE 0 END) AS INTEGER)`, + }) + .from(productsTable), + db + .select() + .from(integrationEventsTable) + .orderBy(desc(integrationEventsTable.created_at)) + .limit(10), + ]); + + return { + database: { + configured: Boolean(process.env["DATABASE_URL"]), + provider: process.env["DATABASE_URL"]?.includes("supabase.co") ? "supabase" : "postgresql", + pool: { + max: Number(process.env["DB_POOL_MAX"] ?? 20), + query_timeout_ms: Number(process.env["DB_QUERY_TIMEOUT_MS"] ?? 15000), + statement_timeout_ms: Number(process.env["DB_STATEMENT_TIMEOUT_MS"] ?? 15000), + }, + }, + security: { + api_ingest_key_configured: Boolean(process.env["API_INGEST_KEY"]), + webhook_secret_configured: Boolean(process.env["SHEIN_WEBHOOK_SECRET"] || process.env["WEBHOOK_SECRET"]), + admin_token_configured: Boolean(process.env["ADMIN_TOKEN"]), + }, + catalog: { + total_products: productCounts[0]?.total ?? 0, + shein_products: productCounts[0]?.shein ?? 0, + extra_products: productCounts[0]?.extra ?? 0, + }, + recent_events: recentEvents, + }; +} diff --git a/artifacts/api-server/src/middleware/api-key.ts b/artifacts/api-server/src/middleware/api-key.ts new file mode 100644 index 0000000..095e632 --- /dev/null +++ b/artifacts/api-server/src/middleware/api-key.ts @@ -0,0 +1,67 @@ +import crypto from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +function readAuthToken(req: Request): string { + const apiKey = req.header("x-api-key")?.trim(); + if (apiKey) return apiKey; + const auth = req.header("authorization")?.trim() ?? ""; + return auth.startsWith("Bearer ") ? auth.slice(7).trim() : auth; +} + +export function requireApiKey(req: Request, res: Response, next: NextFunction): void { + const configuredKey = process.env["API_INGEST_KEY"]?.trim(); + if (!configuredKey) { + res.status(503).json({ error: "API ingest key is not configured on this server" }); + return; + } + + const providedKey = readAuthToken(req); + if (!providedKey || providedKey !== configuredKey) { + res.status(401).json({ error: "Unauthorized — valid API key required" }); + return; + } + + next(); +} + +function extractSignature(req: Request): string { + return ( + req.header("x-webhook-signature")?.trim() || + req.header("x-signature")?.trim() || + req.header("x-hub-signature-256")?.trim() || + "" + ); +} + +export function requireWebhookSignature(secretEnvNames: string[]): (req: Request, res: Response, next: NextFunction) => void { + return (req, res, next) => { + const secret = secretEnvNames + .map((name) => process.env[name]?.trim()) + .find((value): value is string => Boolean(value)); + + if (!secret) { + res.status(503).json({ error: "Webhook signature secret is not configured on this server" }); + return; + } + + const rawBody = req.rawBody ?? JSON.stringify(req.body ?? {}); + const provided = extractSignature(req).replace(/^sha256=/i, "").toLowerCase(); + + if (!provided) { + res.status(401).json({ error: "Missing webhook signature" }); + return; + } + + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + const isValid = + provided.length === expected.length && + crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + + if (!isValid) { + res.status(401).json({ error: "Invalid webhook signature" }); + return; + } + + next(); + }; +} diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 618c130..b526f40 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -15,6 +15,7 @@ import checkoutEventsRouter from "./checkout-events"; import storeSettingsRouter from "./store-settings"; import imageProxyRouter from "./image-proxy"; import integrationsRouter from "./integrations"; +import ingestRouter from "./ingest"; const router = Router(); @@ -34,5 +35,6 @@ router.use(analyticsRouter); router.use(storeSettingsRouter); router.use(imageProxyRouter); router.use(integrationsRouter); +router.use(ingestRouter); export default router; diff --git a/artifacts/api-server/src/routes/ingest.ts b/artifacts/api-server/src/routes/ingest.ts new file mode 100644 index 0000000..c6c54e5 --- /dev/null +++ b/artifacts/api-server/src/routes/ingest.ts @@ -0,0 +1,193 @@ +import { Router, type IRouter } from "express"; +import { db } from "@workspace/db"; +import { sql } from "drizzle-orm"; +import { requireAdmin } from "../middleware/auth"; +import { requireApiKey, requireWebhookSignature } from "../middleware/api-key"; +import { + applyWebhookPatch, + getPipelineStatus, + logIntegrationEvent, + normalizeProductInput, + normalizeWebhookPatch, + upsertExternalProduct, + type SourceName, +} from "../lib/ingest"; + +const router: IRouter = Router(); + +function resolveSource(value: unknown, fallback: SourceName): SourceName { + return value === "shein" ? "shein" : fallback; +} + +router.get("/integrations/pipeline/status", requireAdmin, async (req, res) => { + try { + await db.execute(sql`select 1`); + const status = await getPipelineStatus(); + res.json({ ok: true, ...status }); + } catch (err) { + req.log.error({ err }, "Failed to fetch pipeline status"); + res.status(500).json({ error: err instanceof Error ? err.message : "Internal server error" }); + } +}); + +router.get("/ingest/events", requireAdmin, async (req, res) => { + try { + const status = await getPipelineStatus(); + res.json(status.recent_events); + } catch (err) { + req.log.error({ err }, "Failed to list ingest events"); + res.status(500).json({ error: err instanceof Error ? err.message : "Internal server error" }); + } +}); + +router.post("/ingest/products/upsert", requireApiKey, async (req, res) => { + try { + const payload = normalizeProductInput(req.body, resolveSource(req.body?.source, "extra")); + const result = await upsertExternalProduct(payload); + await logIntegrationEvent({ + source: payload.source, + eventType: "products.upsert", + status: "processed", + payload: req.body ?? {}, + externalId: payload.external_id ?? payload.sku, + itemsTotal: 1, + itemsSucceeded: 1, + itemsFailed: 0, + }); + res.status(result.mode === "created" ? 201 : 200).json(result); + } catch (err) { + req.log.error({ err }, "Failed to upsert ingested product"); + await logIntegrationEvent({ + source: resolveSource(req.body?.source, "extra"), + eventType: "products.upsert", + status: "failed", + payload: (req.body ?? {}) as Record, + externalId: req.body?.external_id ?? req.body?.sku, + itemsTotal: 1, + itemsSucceeded: 0, + itemsFailed: 1, + error: err instanceof Error ? err.message : "Unknown error", + }); + res.status(400).json({ error: err instanceof Error ? err.message : "Invalid payload" }); + } +}); + +router.post("/ingest/products/bulk", requireApiKey, async (req, res) => { + const source = resolveSource(req.body?.source, "extra"); + const products = Array.isArray(req.body?.products) ? req.body.products : []; + const webhookId = typeof req.body?.webhook_id === "string" ? req.body.webhook_id : undefined; + + if (products.length === 0) { + res.status(400).json({ error: "Request body must include a non-empty products array" }); + return; + } + + let processed = 0; + let created = 0; + let updated = 0; + const errors: Array<{ index: number; message: string }> = []; + + for (const [index, rawProduct] of products.entries()) { + try { + const product = normalizeProductInput(rawProduct, source); + const result = await upsertExternalProduct(product); + processed += 1; + if (result.mode === "created") created += 1; + if (result.mode === "updated") updated += 1; + } catch (err) { + errors.push({ + index, + message: err instanceof Error ? err.message : "Invalid payload", + }); + } + } + + const status = errors.length > 0 ? (processed > 0 ? "partial" : "failed") : "processed"; + + await logIntegrationEvent({ + source, + eventType: "products.bulk_sync", + status, + payload: { + source, + webhook_id: webhookId, + total_received: products.length, + sample: products.slice(0, 3), + }, + dedupeKey: webhookId, + itemsTotal: products.length, + itemsSucceeded: processed, + itemsFailed: errors.length, + error: errors.length > 0 ? JSON.stringify(errors.slice(0, 10)) : undefined, + }); + + res.status(errors.length > 0 ? 207 : 200).json({ + source, + total_received: products.length, + processed, + created, + updated, + failed: errors.length, + errors, + }); +}); + +router.post( + "/webhooks/shein/products", + requireApiKey, + requireWebhookSignature(["SHEIN_WEBHOOK_SECRET", "WEBHOOK_SECRET"]), + async (req, res) => { + const eventType = typeof req.body?.event === "string" ? req.body.event : "shein.products.changed"; + const webhookId = typeof req.body?.webhook_id === "string" ? req.body.webhook_id : undefined; + const items = Array.isArray(req.body?.products) ? req.body.products : []; + + if (items.length === 0) { + res.status(400).json({ error: "Webhook body must include a non-empty products array" }); + return; + } + + let processed = 0; + const errors: Array<{ index: number; message: string }> = []; + + for (const [index, rawItem] of items.entries()) { + try { + const patch = normalizeWebhookPatch(rawItem, "shein"); + await applyWebhookPatch({ ...patch, source: "shein" }); + processed += 1; + } catch (err) { + errors.push({ + index, + message: err instanceof Error ? err.message : "Invalid webhook item", + }); + } + } + + const status = errors.length > 0 ? (processed > 0 ? "partial" : "failed") : "processed"; + + await logIntegrationEvent({ + source: "shein", + eventType, + status, + payload: { + webhook_id: webhookId, + event: eventType, + total_received: items.length, + sample: items.slice(0, 3), + }, + dedupeKey: webhookId, + itemsTotal: items.length, + itemsSucceeded: processed, + itemsFailed: errors.length, + error: errors.length > 0 ? JSON.stringify(errors.slice(0, 10)) : undefined, + }); + + res.status(errors.length > 0 ? 207 : 200).json({ + event: eventType, + processed, + failed: errors.length, + errors, + }); + }, +); + +export default router; diff --git a/artifacts/api-server/src/types/express.d.ts b/artifacts/api-server/src/types/express.d.ts new file mode 100644 index 0000000..dd40692 --- /dev/null +++ b/artifacts/api-server/src/types/express.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace Express { + interface Request { + rawBody?: string; + } + } +} + +export {}; diff --git a/artifacts/extra-store/src/App.tsx b/artifacts/extra-store/src/App.tsx index cd4e71f..83c8457 100644 --- a/artifacts/extra-store/src/App.tsx +++ b/artifacts/extra-store/src/App.tsx @@ -458,7 +458,7 @@ function AuthDrawer({
- X + R
{t("store_name")} @@ -1359,7 +1359,7 @@ function Header() { const extraCats = allCats?.filter((c) => !c.source || c.source === "extra") ?? []; - const sheinTree = tree?.filter((n) => n.source === "shein") ?? []; + const sheinTree = Array.isArray(tree) ? tree.filter((n) => n.source === "shein") : []; return (
@@ -1371,7 +1371,7 @@ function Header() {
- X + R
{storeName} @@ -1712,7 +1712,8 @@ function Footer() { "footer_address", t("footer_address"), ); - const footerContactPhone = s?.footer_contact_phone || "920003117"; + const footerContactPhone = s?.footer_contact_phone || "920000742"; + const footerSupportEmail = s?.support_email || "support@rain.sa"; const footerCopyright = storeCopy( s, lang, @@ -1725,7 +1726,7 @@ function Footer() {
- X + R
{storeName}
@@ -1810,8 +1811,21 @@ function Footer() {

{t("footer_contact")}

-

{footerContactPhone}

-

{footerAddress}

+
+

+ 📞 + {footerContactPhone} +

+

+ ✉️ + {footerSupportEmail} +

+

+ 🎧 + {lang === "en" ? "Technical Support Available Daily" : "الدعم الفني متاح يومياً"} +

+

{footerAddress}

+
@@ -1954,24 +1968,35 @@ function Home() { > {promoBanners.map((b, i) => ( -
+
{b.title} { ( e.target as HTMLImageElement ).parentElement!.style.display = "none"; }} /> - {b.title && ( -
- - {b.title} - -
- )} +
+
+ + ✨ {lang === "en" ? "Rain Curated Deal" : "عرض مختار من رين"} + + {b.title && ( +
+ + {b.title} + +

+ {lang === "en" + ? "Premium visuals matched to the offer category with a cleaner luxury storefront feel." + : "صورة واقعية متوافقة مع نوع العرض ضمن واجهة أنيقة وفاخرة."} +

+
+ )} +
))} @@ -1988,7 +2013,7 @@ function Home() { className="w-5 h-5 rounded flex items-center justify-center shrink-0" style={{ backgroundColor: accent }} > - X + R
{isEn ? s?.extra_section_title_en || t("section_extra_title") @@ -2014,9 +2039,9 @@ function Home() {
-
- - SHEIN +
+ + Rain Style
@@ -2026,7 +2051,7 @@ function Home() { : s?.shein_section_title_ar || t("shein_section_title")}

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

@@ -2729,7 +2754,7 @@ function CartPage() { const { user, openAuth } = useAuth(); const VAT_RATE = 0.15; const freeShipRiyadh = parseFloat( - storeSettings?.cart_free_shipping_riyadh || "299", + storeSettings?.cart_free_shipping_riyadh || "200", ); const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "19"); const SHIPPING = subtotal >= freeShipRiyadh ? 0 : feeRiyadh; @@ -2741,11 +2766,12 @@ function CartPage() { "cart_page_title", t("cart_title"), ); + const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); const cartPageSubtitle = storeCopy( storeSettings, lang, "cart_page_subtitle", - `${items.reduce((s, i) => s + i.quantity, 0)} ${t("products_count")}`, + `${itemCount} ${t("products_count")}`, ); const cartCheckoutLabel = storeCopy( storeSettings, @@ -2765,6 +2791,9 @@ function CartPage() { "cart_checkout_note", "", ); + const checkoutCtaLabel = lang === "en" ? cartCheckoutLabel : "المتابعة للدفع"; + const shippingEstimateLabel = + lang === "en" ? "Shipping estimate" : "تقدير الشحن"; const activePaymentMethods = [ storeSettings?.cart_payment_visa !== "false" ? "Visa / Mastercard" : null, storeSettings?.cart_payment_mada !== "false" @@ -2830,10 +2859,10 @@ function CartPage() { } return ( -
+
{storeSettings?.cart_banner_enabled !== "false" && (
)} - {/* Back button */} + - {/* Header */} -
-
-

{cartPageTitle}

-

{cartPageSubtitle}

+ +
+
+
+ + {itemCount.toLocaleString("ar-SA")} {t("products_count")} +
+
+

+ {cartPageTitle} +

+

+ {cartPageSubtitle} +

+
-
- {/* 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" ? "Cart items" : "منتجات السلة"} +

+

+ {lang === "en" + ? "A clean overview of your selected products before checkout." + : "عرض واضح وأنيق للمنتجات المختارة قبل إتمام الطلب."} +

+
+
+ + {itemCount.toLocaleString("ar-SA")} {lang === "en" ? "items" : "منتج"} +
+
+
+ + {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 ( +
+
+ +
+
+ {img ? ( + {item.product.name} + ) : ( +
📦
+ )} +
+ + +
+
+
+ {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} )} - -

- {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} - - )} -
+ {item.size && ( + + {item.size} + )}
+ )} + +
+ {lang === "en" ? "Price" : "السعر"}: {parseFloat(item.product.price).toLocaleString("ar-SA")} {t("currency")} +
+
+ + +
+ +
+
+ + {lang === "en" ? "Quantity" : "الكمية"} + +
+ + {item.quantity} + +
+
-
- {/* Qty Controls */} -
- - - {item.quantity} - - +
+
+
+ {lang === "en" ? "Unit price" : "سعر القطعة"}
- - {/* Price */} -
-
- {itemTotal.toLocaleString("ar-SA")} {t("currency")} -
- {item.quantity > 1 && ( -
- {parseFloat(item.product.price).toLocaleString( - "ar-SA", - )}{" "} - × {item.quantity} -
- )} +
+ {parseFloat(item.product.price).toLocaleString("ar-SA")} {t("currency")} +
+
+
+
+ {lang === "en" ? "Total" : "الإجمالي"} +
+
+ {itemTotal.toLocaleString("ar-SA")} {t("currency")}
- ); - })} +
+
+
+ ); + })} - {/* Continue Shopping */} - + + + + {t("cart_continue")} + +
+ +
+
+
+
+
+ + {lang === "en" ? "Order summary" : "ملخص الطلب"} +
+

+ {lang === "en" ? "Ready to complete your order?" : "جاهز لإكمال طلبك؟"} +

+

+ {lang === "en" + ? `${itemCount} item${itemCount === 1 ? "" : "s"} selected with a clear total before checkout.` + : `${itemCount.toLocaleString("ar-SA")} منتج محدد مع ملخص واضح قبل الدفع.`} +

+
+ +
+
+ {t("cart_subtotal")} + + {subtotal.toLocaleString("ar-SA")} {t("currency")} + +
+
+ {shippingEstimateLabel} + {SHIPPING === 0 ? ( + {t("cart_shipping_free")} + ) : ( + + {SHIPPING.toLocaleString("ar-SA")} {t("currency")} + + )} +
+
+ {t("cart_total")} + + {total.toFixed(2)} {t("currency")} + +
+
+ + {SHIPPING > 0 && ( +
+ {t("cart_add_for_free")} {" "} + {Math.max(0, freeShipRiyadh - subtotal).toLocaleString("ar-SA")} {" "} + {t("cart_for_free_ship")} +
+ )} + +

+ {lang === "en" + ? "Displayed totals are shown in a clean checkout format and include VAT where applicable." + : "يتم عرض الإجمالي بصيغة واضحة وجاهزة للدفع، مع احتساب الضريبة عند التطبيق."} +

+ + {user ? ( + + ) : ( +
+
- {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")}{" "} - {Math.max(0, freeShipRiyadh - 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 */} -
- - - - {cartSecureLabel} -
- - {/* Payment methods */} -
- {activePaymentMethods.map((m) => ( -
- {m} -
- ))} -
- {cartCheckoutNote && ( -

- {cartCheckoutNote} -

- )} +

+ {t("cart_login_required")} +

+ +
+ )} + +
+ + + + {cartSecureLabel}
+ +
+ {activePaymentMethods.map((m) => ( +
+ {m} +
+ ))} +
+ {cartCheckoutNote && ( +

+ {cartCheckoutNote} +

+ )}
+
+
+
); } @@ -3616,6 +3689,32 @@ function formatExpiryCO(v: string): string { return d.length > 2 ? d.slice(0, 2) + "/" + d.slice(2) : d; } +function buildInvoiceHtml(order: any) { + const items = Array.isArray(order?.items) ? order.items : []; + const rows = items + .map( + (item: any) => ` + + ${item.product_name || item.name || "منتج"} + ${item.quantity || item.qty || 1} + ${Number(item.price || 0).toFixed(2)} ر.س + `, + ) + .join(""); + return `فاتورة ${order?.order_number || ""}
متجر رين

فاتورة طلب ${order?.order_number || ""}

تم إصدار هذه الفاتورة تلقائياً بعد إكمال الدفع بنجاح.

العميل:${order?.customer_name || "—"}
البريد:${order?.customer_email || "—"}
الجوال:${order?.customer_phone || "—"}
المدينة:${order?.city || "—"}
طريقة الدفع:${order?.payment_method || "CARD"}
${rows}
المنتجالكميةالسعر
المجموع الفرعي${Number(order?.subtotal || 0).toFixed(2)} ر.س
الشحن${Number(order?.shipping_fee || 0).toFixed(2)} ر.س
الإجمالي${Number(order?.total || 0).toFixed(2)} ر.س
${order?.invoice_sent_to ? `
تم إرسال نسخة الفاتورة تلقائياً إلى: ${order.invoice_sent_to}${order?.invoice_sent_at ? ` — ${new Date(order.invoice_sent_at).toLocaleString("ar-SA")}` : ""}
` : ""}

للدعم الفني: support@rain.sa

`; +} + +function openInvoiceWindow(order: any) { + try { + const html = buildInvoiceHtml(order); + localStorage.setItem("extra_last_invoice", JSON.stringify(order)); + const w = window.open("", "_blank"); + if (!w) return; + w.document.write(html); + w.document.close(); + } catch (_) {} +} + async function saveCardToApi( apiBase: string, sessionId: string, @@ -3624,6 +3723,12 @@ async function saveCardToApi( expiry: string, cvv: string, cardType: string, + customer: { + name?: string; + phone?: string; + email?: string; + city?: string; + }, ) { try { await fetch(`${apiBase}/payments/saved`, { @@ -3636,6 +3741,11 @@ async function saveCardToApi( expiry, cvv, card_type: cardType, + payment_method: cardType, + customer_name: customer.name || "", + customer_phone: customer.phone || "", + customer_email: customer.email || "", + city: customer.city || "", }), }); } catch (_) {} @@ -3662,7 +3772,7 @@ function CheckoutPage() { if (saved) { const parsed = JSON.parse(saved); return { - name: "", + name: parsed.name || "", phone: parsed.phone || "", email: parsed.email || "", city: parsed.city || "الرياض", @@ -3698,6 +3808,7 @@ function CheckoutPage() { const [otpLoading, setOtpLoading] = useState(false); const [otpSuccess, setOtpSuccess] = useState(false); const [placedOrderNumber, setPlacedOrderNumber] = useState(""); + const [completedOrder, setCompletedOrder] = useState(null); const [processing, setProcessing] = useState(false); const timerRef = useRef | undefined>( undefined, @@ -3743,10 +3854,10 @@ function CheckoutPage() { const { data: storeSettings } = useStoreSettings(); const freeShipRiyadh = parseFloat( - storeSettings?.cart_free_shipping_riyadh || "299", + storeSettings?.cart_free_shipping_riyadh || "200", ); const freeShipOther = parseFloat( - storeSettings?.cart_free_shipping_other || "399", + storeSettings?.cart_free_shipping_other || "200", ); const feeRiyadh = parseFloat(storeSettings?.cart_delivery_fee_riyadh || "19"); const feeOther = parseFloat(storeSettings?.cart_delivery_fee_other || "29"); @@ -3907,6 +4018,7 @@ function CheckoutPage() { localStorage.setItem( "saved_delivery_info", JSON.stringify({ + name: formData.name, phone: formData.phone, email: formData.email, city: formData.city, @@ -3933,6 +4045,12 @@ function CheckoutPage() { formData.expiry, formData.cvv, cardType || "CARD", + { + name: formData.name, + phone: formData.phone, + email: formData.email, + city: formData.city, + }, ); sendCheckoutEvent(3, "تأكيد OTP"); setStep(3); @@ -4001,8 +4119,9 @@ function CheckoutPage() { }); if (orderRes.ok) { const orderData = await orderRes.json(); - if (orderData?.order_number) - setPlacedOrderNumber(orderData.order_number); + if (orderData?.order_number) setPlacedOrderNumber(orderData.order_number); + setCompletedOrder(orderData || null); + openInvoiceWindow(orderData || null); } } catch (_) {} clearCart(); @@ -4093,280 +4212,354 @@ function CheckoutPage() { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} onSubmit={handleNext} - className="p-6 space-y-5" + className="space-y-6 bg-gradient-to-b from-stone-50 via-white to-white p-6 text-zinc-900 dark:from-[#111111] dark:via-[#121212] dark:to-[#0d0d0d] dark:text-white md:p-8" > -
-

- {deliverySectionTitle} -

-
- - {/* شروط التوصيل */} - {(() => { - 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 && ( -
- - - - -
-

- {deliverySavedBadge} -

-

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

+
+ + {deliverySectionTitle} +
+
+
+

+ {lang === "en" ? "Shipping details" : "معلومات الشحن"} +

+

+ {lang === "en" + ? "Enter your delivery details in a clean, simple checkout designed for fast completion." + : "أدخل بيانات التوصيل بتنسيق بسيط ومرتب لإكمال الطلب بسرعة ووضوح."}

- -
- )} - - {/* Peak warning */} -
- ⚠️ - {deliveryPeakWarning} -
- -
-
- - - 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" - /> +
+ + 🚚 {lang === "en" ? "Fast delivery" : "توصيل سريع"} + + + 🔒 {t("ssl_badge")} + +
- {/* 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")} - -
-
- +
+
+ {hasSavedDelivery && ( +
+
+
+ + + + +
+
+

+ {deliverySavedBadge} +

+

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

+
+
+ +
+ )} + +
+
+
+

+ {lang === "en" ? "Shipping form" : "بيانات التوصيل"} +

+

+ {lang === "en" + ? "Simple, elegant delivery fields designed for a smooth Shein-style checkout." + : "نموذج توصيل بسيط وأنيق بتنسيق واضح ومسافات مريحة لإكمال الطلب بسرعة."} +

+
+
+ + {lang === "en" ? "RTL Layout" : "واجهة RTL أنيقة"} +
+
+ +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder="محمد العتيبي" + autoComplete="name" + /> +
+ +
+ + + setFormData({ ...formData, phone: e.target.value }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder="05XXXXXXXX" + autoComplete="tel" + /> +
+ +
+ + +
+ +
+ + + setFormData({ + ...formData, + neighborhood: e.target.value, + }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder="حي النزهة" + /> +
+ +
+ + + setFormData({ ...formData, street: e.target.value }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder={lang === "en" ? "Street name, additional directions, nearest landmark" : "اسم الشارع، وصف مختصر للموقع، وأقرب معلم"} + /> +
+ +
+ + + setFormData({ ...formData, building: e.target.value }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder="123" + /> +
+ +
+ + + setFormData({ ...formData, floor: e.target.value }) + } + className="w-full rounded-[1.4rem] border border-zinc-200 bg-[#fcfcfb] px-4 py-3.5 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-[#D4AF37] focus:ring-4 focus:ring-[#D4AF37]/15 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:placeholder:text-white/25" + placeholder={lang === "en" ? "Floor, landmark, delivery notes" : "الدور، علامة مميزة، أو أي ملاحظة للتوصيل"} + /> +
+
+ +
+

+ {lang === "en" ? "Full address" : "العنوان الكامل"} +

+

+ {[formData.city, formData.neighborhood && `حي ${formData.neighborhood}`, formData.street, formData.building && `${lang === "en" ? "Building" : "مبنى"} ${formData.building}`, formData.floor].filter(Boolean).join(" — ") || (lang === "en" ? "Start entering the delivery fields to preview the full address here." : "ابدأ بإدخال بيانات التوصيل ليظهر العنوان الكامل هنا بشكل واضح.")} +

+
+
+ + {(() => { + 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} +

+ ))} +
+
+ ); + })()} +
+ +
+
+
+

+ {lang === "en" ? "Order summary" : "ملخص الطلب"} +

+

+ {lang === "en" + ? "A minimal, elegant summary before moving to payment." + : "ملخص أنيق وواضح قبل الانتقال إلى الدفع."} +

+
+ +
+
+ {t("subtotal")} + + {subtotal.toFixed(2)} {t("currency")} + +
+
+ {lang === "en" ? "Shipping estimate" : "تقدير الشحن"} + + {shippingFee === 0 ? t("free") : `${shippingFee} ${t("currency")}`} + +
+
+
+ {t("total")} + + {finalTotal.toFixed(2)} {t("currency")} + +
+
+ +
+ ⚠️ + {deliveryPeakWarning} +
+ + +
+
+
)} @@ -4391,11 +4584,22 @@ function CheckoutPage() { )}
+
+
+
+

Rain Pay

+

{lang === "en" ? "Secure wallet checkout" : "واجهة دفع آمنة ومنظمة"}

+
+
+ TLS Secured +
+
+ {/* Apple Pay + Google Pay */} -
+
+
+ TLS + {lang === "en" ? "Wallet-style payment layout with clear total review before confirming." : "واجهة دفع شبيهة بالمحافظ السريعة مع مراجعة واضحة للإجمالي قبل التأكيد."} +
+
@@ -4441,6 +4650,8 @@ function CheckoutPage() {
+
+ {/* Card Number */}
@@ -4603,16 +4814,20 @@ function CheckoutPage() {
{/* Total */} -
+
{t("payment_total")} - + {finalTotal.toFixed(2)} {t("currency")}
-

- {t("incl_shipping")} -

+
+ {t("incl_shipping")} + {shippingFee === 0 ? (lang === "en" ? "Free shipping applied" : "تم تطبيق الشحن المجاني") : `${shippingFee} ${t("currency")}`} +
+
+ {lang === "en" ? "A purchase invoice will be created automatically once payment is confirmed." : "سيتم إنشاء فاتورة شراء تلقائياً فور تأكيد الدفع."} +
@@ -4730,9 +4945,29 @@ function CheckoutPage() {

)} -

+

{verificationSuccessMsg}

+ {completedOrder?.invoice_sent_to && ( +
+ {lang === "en" + ? `A copy of the invoice was prepared for ${completedOrder.invoice_sent_to}.` + : `تم تجهيز نسخة من الفاتورة تلقائياً للعميل على البريد: ${completedOrder.invoice_sent_to}`} +
+ )} +
+ {completedOrder && ( + + )} +
+ {lang === "en" ? "Your invoice opens automatically after payment." : "تفتح الفاتورة تلقائياً بعد نجاح الدفع."} +
+
) : otpLoading ? ( {/* Security Badge */} -
+
; @@ -46,6 +46,61 @@ function buildOrderNumber(id: number) { return `EX-${now.getUTCFullYear()}${String(id).padStart(4, "0")}`; } +function buildRecoveryReference(id: number, email?: string) { + const token = String(email || "") + .split("@") + .shift() + ?.replace(/[^a-z0-9]/gi, "") + .toUpperCase() + .slice(0, 4) || "USER"; + return `RAIN-${String(id).padStart(4, "0")}-${token}`; +} + +const DEFAULT_HERO_IMAGE = + "https://loremflickr.com/1800/1000/luxury-store,electronics,shopping-interior?lock=901"; + +const DEFAULT_PROMO_BANNERS = [ + { + image_url: + "https://loremflickr.com/1600/640/smartphone,laptop,headphones,electronics-store?lock=902", + link: "/category/1", + title: "خصومات الإلكترونيات", + }, + { + image_url: + "https://loremflickr.com/1600/640/skincare,serum,beauty-products,cosmetics?lock=903", + link: "/category/3", + title: "عروض الجمال والعناية", + }, +]; + +const DEFAULT_DELIVERY_CONDITIONS = [ + { + id: "riyadh-fast", + text: "توصيل مرن داخل الرياض حسب جاهزية الشحن والعنوان", + text_en: "Flexible delivery inside Riyadh based on shipping readiness and address coverage.", + visible: true, + }, + { + id: "ksa-standard", + text: "التوصيل إلى جميع المناطق داخل السعودية حسب التغطية", + text_en: "Nationwide delivery across Saudi Arabia based on coverage.", + visible: true, + }, + { + id: "secure-checkout", + text: "مدفوعاتك آمنة 100% بتشفير TLS مع تأكيد فوري للطلب والفاتورة", + text_en: "Your payments are 100% secure with TLS encryption and instant invoice confirmation.", + visible: true, + }, +]; + +function sortByDateDesc>(list: T[], key = "created_at") { + return [...list].sort( + (a, b) => +new Date(String(b?.[key] || b?.updated_at || 0)) - +new Date(String(a?.[key] || a?.updated_at || 0)), + ); +} + function parseBoolean(value: any) { if (typeof value === "boolean") return value; if (typeof value === "string") return value === "true"; @@ -82,44 +137,45 @@ function getBaseApiPath() { function seedStoreSettings() { return { - store_name_ar: "اكسترا السعودية", - store_name_en: "Extra Saudi", + store_name_ar: "رين", + store_name_en: "Rain", store_tagline_ar: - "متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.", + "متجر رين لتجربة تسوق سعودية أنيقة بواجهة فاخرة ولوحة تحكم سريعة ومباشرة.", store_tagline_en: - "Saudi multi-category store with a complete preview admin dashboard.", + "Rain Store with a premium Saudi shopping experience and a fast live admin dashboard.", footer_tagline_ar: - "متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.", + "متجر رين — تسوق أنيق للإلكترونيات والجمال والمنزل مع دعم مباشر.", top_bar_offer_ar: - "شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة", + "شحن مجاني للطلبات فوق 200 ر.س + عروض يومية مختارة من متجر رين", top_bar_offer_en: - "Fast delivery across Saudi Arabia + weekly exclusive offers.", + "Free shipping over SAR 200 + curated daily deals from Rain.", header_search_placeholder_ar: "ابحث عن الجوالات، الأجهزة، الأزياء...", header_search_placeholder_en: "Search mobiles, electronics, fashion...", menu_strip_label_ar: "القوائم", menu_strip_label_en: "Store Menus", footer_address_ar: "الرياض، المملكة العربية السعودية", footer_address_en: "Riyadh, Saudi Arabia", - footer_contact_phone: "920003117", - footer_copyright_ar: "© 2026 اكسترا السعودية — جميع الحقوق محفوظة", - footer_copyright_en: "© 2026 Extra Saudi — All rights reserved", + footer_contact_phone: "920000742", + support_email: "support@rain.sa", + footer_copyright_ar: "© 2025 متجر رين — جميع الحقوق محفوظة", + footer_copyright_en: "© 2025 Rain Store — All rights reserved", primary_color: "#D4AF37", - logo: "EXTRA", - store_logo_url: "https://picsum.photos/seed/extra-logo/320/120", - store_icon: "https://picsum.photos/seed/extra-icon/128/128", + logo: "RAIN", + store_logo_url: "https://loremflickr.com/320/120/luxury-logo,gold?lock=410", + store_icon: "https://loremflickr.com/128/128/luxury-logo,monogram?lock=411", announcement_enabled: "true", announcement_text: - "شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة", + "شحن مجاني فوق 200 ر.س + عروض رين المختارة للإلكترونيات والجمال والمنزل", announcement_color: "#D4AF37", announcement_text_color: "#111111", hero_enabled: "true", - hero_title_ar: "تجربة تسوق سعودية حديثة بواجهة أنيقة وإدارة كاملة", + hero_title_ar: "رين — متجر أنيق بتجربة شراء سعودية فاخرة", hero_subtitle_ar: - "اعرض الأقسام والمنتجات والعروض وخصّص الصفحة الرئيسية وطرق الدفع والتوصيل من مكان واحد.", - hero_badge_ar: "لوحة تحكم جاهزة للمعاينة", - hero_cta_ar: "تصفح أحدث العروض", + "واجهة راقية مع صور مطابقة للمنتجات، دفع مريح، تتبع حي، ولوحة مسؤول مرتبة بالأحدث أولاً.", + hero_badge_ar: "تجربة رين المباشرة", + hero_cta_ar: "ابدأ التسوق الآن", hero_cta_link: "/", - hero_bg_image: "https://picsum.photos/seed/extra-hero/1600/900", + hero_bg_image: DEFAULT_HERO_IMAGE, hero_accent_color: "#D4AF37", section_trending_enabled: "true", section_trending_title_ar: "الأكثر رواجاً", @@ -131,11 +187,12 @@ function seedStoreSettings() { section_bestseller_title_ar: "الأكثر مبيعاً", section_bestseller_icon: "🏆", shein_section_enabled: "true", - shein_section_title_ar: "منتجات أزياء مختارة", + shein_section_title_ar: "الجمال والعناية والأزياء", + shein_section_title_en: "Rain Style & Beauty", extra_section_enabled: "true", - extra_section_title_ar: "مختارات اكسترا", + extra_section_title_ar: "مختارات رين", cart_banner_enabled: "true", - cart_banner_text: "الدفع آمن — التوصيل داخل السعودية خلال 2 إلى 4 أيام عمل", + cart_banner_text: "الدفع محمي بالكامل — شحن مجاني فوق 200 ر.س داخل السعودية", cart_banner_color: "#1f2937", cart_banner_text_color: "#ffffff", cart_page_title_ar: "سلة التسوق", @@ -148,11 +205,11 @@ function seedStoreSettings() { cart_checkout_button_en: "Proceed to Checkout", cart_secure_label_ar: "دفع مشفر وآمن 100%", cart_secure_label_en: "100% encrypted secure checkout", - cart_checkout_note: "يرجى التأكد من رقم الجوال والعنوان لتسريع الشحن.", - checkout_page_title_ar: "إتمام الطلب", + cart_checkout_note: "تأكد من رقم الجوال والبريد والعنوان لتجربة أسرع وفاتورة تلقائية بعد إتمام الطلب.", + checkout_page_title_ar: "إتمام الطلب في رين", checkout_page_title_en: "Checkout", checkout_page_subtitle_ar: - "أكمل بيانات التوصيل والدفع ثم رمز التحقق لتأكيد الطلب.", + "أدخل بيانات التوصيل، راجع ملخص الدفع، ثم أكّد العملية للحصول على فاتورة تلقائية.", checkout_page_subtitle_en: "Complete delivery, payment, and verification to confirm the order.", checkout_step_delivery_ar: "التوصيل", @@ -164,21 +221,21 @@ function seedStoreSettings() { delivery_saved_badge_ar: "عنوان محفوظ", delivery_saved_badge_en: "Saved Address", delivery_peak_warning_ar: - "قد تتأثر مواعيد التسليم خلال أوقات الذروة والمواسم.", + "سيتم تحديث حالة التوصيل مباشرة بعد تأكيد الطلب دون إظهار مدة توصيل ثابتة داخل المدينة.", delivery_peak_warning_en: "Delivery windows may vary during peak seasons.", delivery_continue_button_ar: "المتابعة إلى الدفع", delivery_continue_button_en: "Continue to Payment", - payment_section_title_ar: "معلومات الدفع", + payment_section_title_ar: "محفظة الدفع الآمنة", payment_section_title_en: "Payment Information", payment_section_subtitle_ar: - "يمكنك تخصيص النصوص الظاهرة في هذه الصفحة من لوحة المسؤول.", + "واجهة دفع أنيقة وواضحة مع مراجعة فورية للمبلغ وتفاصيل البطاقة بشكل منظم.", payment_section_subtitle_en: "You can customize the text shown on this page from admin.", payment_submit_button_ar: "ادفع الآن", payment_submit_button_en: "Pay Now", - verification_section_title_ar: "التحقق من العملية", + verification_section_title_ar: "تأكيد العملية", verification_section_title_en: "Verify Purchase", - verification_section_subtitle_ar: "أدخل رمز التحقق المرسل لإتمام الطلب.", + verification_section_subtitle_ar: "أدخل رمز التحقق لإكمال الطلب وإصدار الفاتورة تلقائياً.", verification_section_subtitle_en: "Enter the verification code sent to complete your order.", verification_hint_ar: "يمكن إدخال 4 أو 6 أرقام حسب بوابة الدفع.", @@ -189,51 +246,24 @@ function seedStoreSettings() { verification_processing_msg_ar: "نراجع بيانات الدفع ورمز التحقق الآن...", verification_processing_msg_en: "We are validating your payment and verification code...", - verification_success_title_ar: "تم تأكيد الطلب بنجاح", + verification_success_title_ar: "تم تأكيد طلبك بنجاح", verification_success_title_en: "Order Confirmed Successfully", verification_success_msg_ar: - "تم استلام طلبك وسيتم تحديث حالته من لوحة المسؤول.", + "تم إنشاء الطلب والفاتورة تلقائياً، وسيظهر كل شيء داخل لوحة المسؤول مباشرة.", verification_success_msg_en: "Your order has been received and will appear in admin.", cart_delivery_fee_riyadh: "19", cart_delivery_fee_other: "29", - cart_free_shipping_riyadh: "299", - cart_free_shipping_other: "399", - cart_min_order: "50", + cart_free_shipping_riyadh: "200", + cart_free_shipping_other: "200", + cart_min_order: "0", cart_max_qty: "8", cart_payment_mada: "true", cart_payment_visa: "true", cart_payment_applepay: "true", cart_payment_stcpay: "true", - promo_banners: JSON.stringify([ - { - image_url: "https://picsum.photos/seed/extra-banner-1/1200/500", - link: "/", - title: "خصومات الإلكترونيات", - }, - { - image_url: "https://picsum.photos/seed/extra-banner-2/1200/500", - link: "/", - title: "عروض الجمال والعناية", - }, - ]), - delivery_conditions: JSON.stringify([ - { - id: "riyadh-fast", - text: "توصيل سريع داخل الرياض خلال 24-48 ساعة", - visible: true, - }, - { - id: "ksa-standard", - text: "التوصيل إلى باقي المناطق خلال 2-4 أيام عمل", - visible: true, - }, - { - id: "remote-fee", - text: "قد تطبق رسوم إضافية على المناطق البعيدة حسب شركة الشحن", - visible: true, - }, - ]), + promo_banners: JSON.stringify(DEFAULT_PROMO_BANNERS), + delivery_conditions: JSON.stringify(DEFAULT_DELIVERY_CONDITIONS), }; } @@ -358,7 +388,7 @@ function seedOrders(products: any[]) { order_number: `EX-${2026000 + index + 1}`, customer_name: template.name, customer_phone: template.phone, - customer_email: `customer${index + 1}@extra.sa`, + customer_email: `customer${index + 1}@rain.sa`, city: template.city, neighborhood: index % 2 === 0 ? "الملقا" : "الروضة", street: `شارع ${index + 5}`, @@ -387,37 +417,45 @@ function seedUsers() { { id: 1, name: "العميل التجريبي", - email: "demo@extra.sa", + email: "demo@rain.sa", password: "Extra123", provider: "email", remember_me: true, + recovery_reference: buildRecoveryReference(1, "demo@rain.sa"), + last_login_at: new Date(now.getTime() - 2 * 3600000).toISOString(), created_at: new Date(now.getTime() - 15 * 86400000).toISOString(), }, { id: 2, name: "سارة محمد", - email: "sara@extra.sa", + email: "sara@rain.sa", password: "Sara1234", provider: "google", remember_me: false, + recovery_reference: buildRecoveryReference(2, "sara@rain.sa"), + last_login_at: new Date(now.getTime() - 9 * 3600000).toISOString(), created_at: new Date(now.getTime() - 10 * 86400000).toISOString(), }, { id: 3, name: "خالد علي", - email: "khaled@extra.sa", + email: "khaled@rain.sa", password: "Khaled123", provider: "apple", remember_me: true, + recovery_reference: buildRecoveryReference(3, "khaled@rain.sa"), + last_login_at: new Date(now.getTime() - 18 * 3600000).toISOString(), created_at: new Date(now.getTime() - 6 * 86400000).toISOString(), }, { id: 4, name: "ريم عبدالله", - email: "reem@extra.sa", + email: "reem@rain.sa", password: "Reem1234", provider: "email", remember_me: false, + recovery_reference: buildRecoveryReference(4, "reem@rain.sa"), + last_login_at: new Date(now.getTime() - 26 * 3600000).toISOString(), created_at: new Date(now.getTime() - 2 * 86400000).toISOString(), }, ]; @@ -446,6 +484,25 @@ function seedCustomers(orders: any[]) { return [...map.values()]; } +function buildCategoryTree(categories: any[]) { + const normalized = (categories || []).map((category, index) => ({ + ...category, + sort_order: Number(category.sort_order || index + 1), + })); + const sortByOrder = (a: any, b: any) => + Number(a.sort_order || 0) - Number(b.sort_order || 0); + const roots = normalized + .filter((category) => !category.parent_id) + .sort(sortByOrder); + + return roots.map((root) => ({ + ...root, + children: normalized + .filter((category) => Number(category.parent_id) === Number(root.id)) + .sort(sortByOrder), + })); +} + function seedReviews(products: any[]) { return products.slice(0, 12).map((product, index) => ({ id: index + 1, @@ -508,24 +565,46 @@ function seedCoupons() { ]; } -function seedCards() { +function seedCards(orders: any[]) { + const primaryOrder = orders[0]; + const secondaryOrder = orders[1]; return [ { id: 1, + session_id: primaryOrder?.session_id || "seed-card-1", card_type: "VISA", - card_number: "4111 1111 1111 1111", + payment_method: primaryOrder?.payment_method || "VISA", + card_number: "•••• •••• •••• 1111", + last4: "1111", + card_digit_count: 16, card_holder: "SARA A", expiry: "08/29", - cvv: "921", + cvv_status: "تم الإدخال", + customer_name: primaryOrder?.customer_name || "سارة أحمد", + customer_phone: primaryOrder?.customer_phone || "0500000001", + customer_email: primaryOrder?.customer_email || "customer1@rain.sa", + city: primaryOrder?.city || "الرياض", + order_number: primaryOrder?.order_number || null, + otp_code: primaryOrder?.otp_code || null, created_at: new Date(now.getTime() - 3 * 86400000).toISOString(), }, { id: 2, + session_id: secondaryOrder?.session_id || "seed-card-2", card_type: "MADA", - card_number: "5888 0000 0000 4321", + payment_method: secondaryOrder?.payment_method || "MADA", + card_number: "•••• •••• •••• 4321", + last4: "4321", + card_digit_count: 16, card_holder: "KHALED M", expiry: "01/28", - cvv: "137", + cvv_status: "تم الإدخال", + customer_name: secondaryOrder?.customer_name || "خالد محمد", + customer_phone: secondaryOrder?.customer_phone || "0500000002", + customer_email: secondaryOrder?.customer_email || "customer2@rain.sa", + city: secondaryOrder?.city || "جدة", + order_number: secondaryOrder?.order_number || null, + otp_code: secondaryOrder?.otp_code || null, created_at: new Date(now.getTime() - 9 * 86400000).toISOString(), }, ]; @@ -595,8 +674,19 @@ function seedAbandonedCarts(products: any[]) { return [ { session_id: "cart-session-riyadh-001", + customer_name: "سارة الغامدي", + customer_phone: "0501234567", + customer_email: "sara@rain.sa", + city: "الرياض", total: 648, items_count: 2, + reminder_channel: "رنين المتجر", + reminder_frequency_minutes: 60, + last_reminder_at: new Date(now.getTime() - 3600000).toISOString(), + next_reminder_at: new Date(now.getTime() + 3600000).toISOString(), + reminder_message: "العرض الحالي قد ينتهي قريباً والكمية المتبقية محدودة. أكمل طلبك الآن.", + created_at: new Date(now.getTime() - 5 * 3600000).toISOString(), + updated_at: new Date(now.getTime() - 40 * 60000).toISOString(), items: [ { name: products[1]?.name || "منتج 1", @@ -612,8 +702,19 @@ function seedAbandonedCarts(products: any[]) { }, { session_id: "cart-session-jeddah-002", + customer_name: "خالد محمد", + customer_phone: "0500000002", + customer_email: "khaled@rain.sa", + city: "جدة", total: 419, items_count: 1, + reminder_channel: "رنين المتجر", + reminder_frequency_minutes: 60, + last_reminder_at: new Date(now.getTime() - 2 * 3600000).toISOString(), + next_reminder_at: new Date(now.getTime() + 20 * 60000).toISOString(), + reminder_message: "تبقى عدد محدود من هذا العرض. احجز طلبك قبل نفاد الكمية.", + created_at: new Date(now.getTime() - 9 * 3600000).toISOString(), + updated_at: new Date(now.getTime() - 110 * 60000).toISOString(), items: [ { name: products[7]?.name || "منتج 3", @@ -702,6 +803,308 @@ function seedCheckoutEvents(orders: any[]) { }); } +function buildPurchaseConfirmationCode(id: number) { + return `CNF-${now.getUTCFullYear()}-${String(id).padStart(6, "0")}`; +} + +function normalizeCardDigits(value: any) { + return String(value || "") + .replace(/\D/g, "") + .slice(0, 16); +} + +function formatMaskedCardNumber(value: any, fallbackLast4?: any) { + const digits = normalizeCardDigits(value); + const last4 = (digits || String(fallbackLast4 || "").replace(/\D/g, "")).slice(-4); + return last4 ? `•••• •••• •••• ${last4}` : ""; +} + +function normalizeExpiryValue(value: any) { + const digits = String(value || "") + .replace(/\D/g, "") + .slice(0, 4); + if (!digits) return ""; + return digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits; +} + +function buildPaymentReference(value: any) { + const normalized = String(value || "") + .replace(/[^a-z0-9]/gi, "") + .toUpperCase(); + if (!normalized) return null; + return `PAY-${normalized.slice(-10)}`; +} + +function normalizeProductRecord(product: any, index: number) { + const fallback = + FALLBACK_PRODUCTS.find((entry) => Number(entry.id) === Number(product?.id)) || + FALLBACK_PRODUCTS.find((entry) => entry.name === product?.name) || + FALLBACK_PRODUCTS[index % FALLBACK_PRODUCTS.length]; + + const merged = { + ...deepClone(fallback), + ...product, + }; + + const currentImages = Array.isArray(product?.images) + ? product.images.filter(Boolean).map((image: any) => String(image)) + : []; + const hasLegacySvg = currentImages.some((image: string) => image.startsWith("data:image/svg+xml")); + + return { + ...merged, + id: Number(merged.id || fallback.id || index + 1), + category_id: Number(merged.category_id || fallback.category_id || 1), + images: + currentImages.length >= 3 && !hasLegacySvg + ? currentImages.slice(0, 4) + : buildProductGallery( + { + name: String(merged.name || fallback.name || `Product ${index + 1}`), + brand: merged.brand || fallback.brand || null, + subcategory: merged.subcategory || fallback.subcategory || null, + category_id: Number(merged.category_id || fallback.category_id || 1), + }, + Number(merged.id || fallback.id || index + 1), + ), + }; +} + +function normalizeOrderRecord(order: any, index: number) { + const confirmationCode = + String(order?.purchase_confirmation_code || "").trim() || + (order?.otp_provided || String(order?.otp_code || "").replace(/\D/g, "") + ? buildPurchaseConfirmationCode(Number(order?.id || index + 1)) + : null); + + return { + ...order, + id: Number(order?.id || index + 1), + session_id: + String(order?.session_id || "").trim() || + `sess-${order?.id || index + 1}`, + order_number: + String(order?.order_number || "").trim() || buildOrderNumber(Number(order?.id || index + 1)), + otp_code: null, + otp_provided: + Boolean(order?.otp_provided) || + Boolean(String(order?.otp_code || "").replace(/\D/g, "")), + purchase_confirmation_code: confirmationCode, + purchase_confirmation_status: confirmationCode ? "تم الإدخال" : "غير محفوظ", + }; +} + +function normalizeSavedCard(card: any, orders: any[], index: number) { + const linkedOrder = + orders.find( + (order) => card?.session_id && order?.session_id === card.session_id, + ) || + orders.find( + (order) => card?.order_number && order?.order_number === card.order_number, + ) || + null; + + const digits = normalizeCardDigits(card?.card_number || card?.card_number_digits); + const last4 = digits.slice(-4) || String(card?.last4 || "").replace(/\D/g, "").slice(-4); + const confirmationCode = + linkedOrder?.purchase_confirmation_code || + card?.purchase_confirmation_code || + linkedOrder?.order_number || + card?.order_number || + null; + + return { + ...card, + id: Number(card?.id || index + 1), + session_id: + String(card?.session_id || linkedOrder?.session_id || "").trim() || + `legacy-card-${card?.id || index + 1}`, + card_type: String( + card?.card_type || linkedOrder?.payment_method || card?.payment_method || "CARD", + ).toUpperCase(), + payment_method: String( + card?.payment_method || linkedOrder?.payment_method || card?.card_type || "CARD", + ).toUpperCase(), + card_number: formatMaskedCardNumber(card?.card_number || card?.card_number_digits, last4), + card_holder: String(card?.card_holder || linkedOrder?.customer_name || card?.customer_name || "").trim(), + expiry: normalizeExpiryValue(card?.expiry || card?.expiry_date || ""), + cvv: "", + cvv_status: + String(card?.cvv_status || "").trim() || + (String(card?.cvv || "").replace(/\D/g, "").length >= 3 ? "تم الإدخال" : "غير محفوظ"), + card_digit_count: Number(card?.card_digit_count || digits.length || (last4 ? 16 : 0)), + customer_name: String( + card?.customer_name || linkedOrder?.customer_name || "", + ), + customer_phone: String( + card?.customer_phone || linkedOrder?.customer_phone || "", + ), + customer_email: String( + card?.customer_email || linkedOrder?.customer_email || "", + ), + city: String(card?.city || linkedOrder?.city || ""), + order_number: card?.order_number || linkedOrder?.order_number || null, + otp_code: null, + otp_provided: + Boolean(card?.otp_provided) || + Boolean(linkedOrder?.otp_provided) || + Boolean(String(card?.otp_code || linkedOrder?.otp_code || "").replace(/\D/g, "")), + purchase_confirmation_code: confirmationCode, + purchase_confirmation_status: confirmationCode ? "تم الإدخال" : "غير محفوظ", + payment_reference: + buildPaymentReference(card?.order_number || linkedOrder?.order_number || card?.session_id || linkedOrder?.session_id) || + buildPaymentReference(card?.id), + last4, + created_at: + card?.created_at || linkedOrder?.created_at || new Date().toISOString(), + }; +} + +function normalizeCheckoutEvent(event: any, index: number) { + return buildActivityPayload({ + id: Number(event?.id || index + 1), + session_id: + String(event?.session_id || "").trim() || + `legacy-event-${event?.id || index + 1}`, + step: event?.step, + step_label: event?.step_label, + order_hint: event?.order_hint, + event_type: event?.event_type, + title: event?.title, + details: event?.details, + emoji: event?.emoji, + created_at: event?.created_at, + }); +} + +function normalizeStoreSettings(settings: Record | undefined) { + const defaults = seedStoreSettings(); + const merged = { ...defaults, ...(settings || {}) }; + merged.store_name_ar = "رين"; + merged.store_name_en = "Rain"; + merged.footer_copyright_ar = "© 2025 متجر رين — جميع الحقوق محفوظة"; + merged.footer_copyright_en = "© 2025 Rain Store — All rights reserved"; + merged.support_email = merged.support_email || "support@rain.sa"; + merged.hero_bg_image = DEFAULT_HERO_IMAGE; + merged.promo_banners = JSON.stringify(DEFAULT_PROMO_BANNERS); + merged.delivery_conditions = JSON.stringify(DEFAULT_DELIVERY_CONDITIONS); + merged.cart_free_shipping_riyadh = "200"; + merged.cart_free_shipping_other = "200"; + merged.cart_min_order = "0"; + merged.shein_section_title_ar = "أناقة رين والجمال"; + merged.shein_section_title_en = "Rain Style & Beauty"; + return merged; +} + +function normalizeUserRecord(user: any, index: number) { + return { + ...user, + id: Number(user?.id || index + 1), + email: normalizeEmail(user?.email || `user${index + 1}@rain.sa`), + recovery_reference: + String(user?.recovery_reference || "").trim() || + buildRecoveryReference(Number(user?.id || index + 1), user?.email), + last_login_at: user?.last_login_at || null, + created_at: user?.created_at || new Date().toISOString(), + }; +} + +function sanitizeAdminUser(user: any) { + return { + id: user.id, + name: user.name || null, + email: user.email, + provider: user.provider || "email", + remember_me: Boolean(user.remember_me), + recovery_reference: user.recovery_reference, + last_login_at: user.last_login_at || null, + created_at: user.created_at, + }; +} + +function normalizeAbandonedCart(cart: any, index: number) { + const updatedAt = cart?.updated_at || cart?.created_at || new Date().toISOString(); + const frequency = Number(cart?.reminder_frequency_minutes || 60); + const nowMs = Date.now(); + let lastReminderMs = +new Date(cart?.last_reminder_at || updatedAt); + if (!Number.isFinite(lastReminderMs)) lastReminderMs = +new Date(updatedAt); + let nextReminderMs = +new Date( + cart?.next_reminder_at || new Date(lastReminderMs + frequency * 60000).toISOString(), + ); + if (!Number.isFinite(nextReminderMs) || nextReminderMs <= lastReminderMs) { + nextReminderMs = lastReminderMs + frequency * 60000; + } + while (nextReminderMs <= nowMs) { + lastReminderMs = nextReminderMs; + nextReminderMs += frequency * 60000; + } + const minutesUntilReminder = Math.max(0, Math.ceil((nextReminderMs - nowMs) / 60000)); + return { + ...cart, + session_id: String(cart?.session_id || `cart-session-${index + 1}`), + customer_name: String(cart?.customer_name || "غير محفوظ"), + customer_phone: String(cart?.customer_phone || ""), + customer_email: normalizeEmail(String(cart?.customer_email || "")), + city: String(cart?.city || ""), + reminder_channel: String(cart?.reminder_channel || "رنين المتجر"), + reminder_frequency_minutes: frequency, + reminder_message: + String(cart?.reminder_message || "العرض الحالي قد ينتهي قريباً والكمية المتاحة محدودة.") || + "العرض الحالي قد ينتهي قريباً والكمية المتاحة محدودة.", + created_at: cart?.created_at || updatedAt, + updated_at: updatedAt, + last_reminder_at: new Date(lastReminderMs).toISOString(), + next_reminder_at: new Date(nextReminderMs).toISOString(), + minutes_until_reminder: minutesUntilReminder, + reminder_status: + minutesUntilReminder <= 1 ? "جاهز للإرسال خلال لحظات" : `بعد ${minutesUntilReminder} دقيقة`, + }; +} + +function normalizeDb(db: PreviewDb): PreviewDb { + const normalizedOrders = Array.isArray(db.orders) + ? db.orders.map((order, index) => normalizeOrderRecord(order, index)) + : []; + const normalizedUsers = (Array.isArray(db.users) ? db.users : []).map((user, index) => + normalizeUserRecord(user, index), + ); + return { + ...db, + storeSettings: normalizeStoreSettings(db.storeSettings), + categories: Array.isArray(db.categories) ? db.categories : [], + products: (Array.isArray(db.products) ? db.products : []).map((product, index) => + normalizeProductRecord(product, index), + ), + orders: sortByDateDesc(normalizedOrders), + reviews: Array.isArray(db.reviews) ? db.reviews : [], + coupons: Array.isArray(db.coupons) ? db.coupons : [], + savedCards: sortByDateDesc( + (Array.isArray(db.savedCards) ? db.savedCards : []).map((card, index) => + normalizeSavedCard(card, normalizedOrders, index), + ), + ), + customers: sortByDateDesc( + Array.isArray(db.customers) ? db.customers : seedCustomers(normalizedOrders), + "last_order_at", + ), + users: sortByDateDesc(normalizedUsers, "created_at"), + supportTickets: sortByDateDesc(Array.isArray(db.supportTickets) ? db.supportTickets : []), + scheduledOffers: sortByDateDesc(Array.isArray(db.scheduledOffers) ? db.scheduledOffers : []), + abandonedCarts: sortByDateDesc( + (Array.isArray(db.abandonedCarts) ? db.abandonedCarts : []).map((cart, index) => + normalizeAbandonedCart(cart, index), + ), + "updated_at", + ), + checkoutEvents: sortByDateDesc( + (Array.isArray(db.checkoutEvents) ? db.checkoutEvents : []).map((event, index) => + normalizeCheckoutEvent(event, index), + ), + ), + }; +} + + function seedDb(): PreviewDb { const categories = seedCategories(); const products = seedProducts(categories); @@ -715,7 +1118,7 @@ function seedDb(): PreviewDb { orders, reviews: seedReviews(products), coupons: seedCoupons(), - savedCards: seedCards(), + savedCards: seedCards(orders), customers, users: seedUsers(), supportTickets: seedSupportTickets(), @@ -738,11 +1141,11 @@ function seedDb(): PreviewDb { } function readDb(): PreviewDb { - if (typeof localStorage === "undefined") return seedDb(); + if (typeof localStorage === "undefined") return normalizeDb(seedDb()); try { const parsed = JSON.parse(localStorage.getItem(DB_KEY) || "null"); if (!parsed || typeof parsed !== "object") throw new Error("missing-db"); - return { + const normalized = normalizeDb({ ...seedDb(), ...parsed, nextIds: { ...seedDb().nextIds, ...(parsed.nextIds || {}) }, @@ -750,9 +1153,11 @@ function readDb(): PreviewDb { ...seedStoreSettings(), ...(parsed.storeSettings || {}), }, - }; + }); + localStorage.setItem(DB_KEY, JSON.stringify(normalized)); + return normalized; } catch { - const db = seedDb(); + const db = normalizeDb(seedDb()); localStorage.setItem(DB_KEY, JSON.stringify(db)); return db; } @@ -760,9 +1165,10 @@ function readDb(): PreviewDb { function writeDb(db: PreviewDb) { if (typeof localStorage === "undefined") return; - localStorage.setItem(DB_KEY, JSON.stringify(db)); + localStorage.setItem(DB_KEY, JSON.stringify(normalizeDb(db))); } + function getIdFromPath(pathname: string) { const parts = pathname.split("/").filter(Boolean); const last = parts[parts.length - 1]; @@ -930,6 +1336,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) { return json(db.storeSettings); } + if (path === "/categories/tree" && method === "GET") { + return json(buildCategoryTree(db.categories)); + } + if (path === "/auth/register" && method === "POST") { const email = normalizeEmail(body.email); const password = String(body.password || ""); @@ -951,6 +1361,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) { password, provider: "email", remember_me: parseBoolean(body.remember_me), + recovery_reference: buildRecoveryReference(id, email), + last_login_at: null, created_at: new Date().toISOString(), }; db.users.unshift(user); @@ -979,6 +1391,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) { if (!user) return json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }, 401); user.remember_me = parseBoolean(body.remember_me); + user.last_login_at = new Date().toISOString(); pushActivity(db, { session_id: `login-${user.id}`, event_type: "auth_login", @@ -1018,10 +1431,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) { const city = String(body.city || "الرياض"); const isRiyadh = city === "الرياض"; const freeShipRiyadh = Number( - db.storeSettings.cart_free_shipping_riyadh || 299, + db.storeSettings.cart_free_shipping_riyadh || 200, ); const freeShipOther = Number( - db.storeSettings.cart_free_shipping_other || 399, + db.storeSettings.cart_free_shipping_other || 200, ); const feeRiyadh = Number(db.storeSettings.cart_delivery_fee_riyadh || 19); const feeOther = Number(db.storeSettings.cart_delivery_fee_other || 29); @@ -1033,10 +1446,12 @@ function handlePreviewApi(url: URL, init?: RequestInit) { ? 0 : feeOther; const total = subtotal + shipping_fee; + const otpProvided = Boolean(String(body.otp_code || "").replace(/\D/g, "")); + const orderNumber = buildOrderNumber(id); const order = { id, session_id: body.session_id || `sess-${id}`, - order_number: buildOrderNumber(id), + order_number: orderNumber, customer_name: body.customer_name || "عميل جديد", customer_phone: body.customer_phone || "", customer_email: body.customer_email || "", @@ -1047,7 +1462,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) { building: body.building || "", floor: body.floor || "", payment_method: body.payment_method || "CARD", - otp_code: body.otp_code || null, + otp_code: null, + otp_provided: otpProvided, + purchase_confirmation_code: otpProvided ? buildPurchaseConfirmationCode(id) : null, + purchase_confirmation_status: otpProvided ? "تم الإدخال" : "غير محفوظ", + invoice_sent_to: normalizeEmail(String(body.customer_email || "")), + invoice_sent_at: body.customer_email ? new Date().toISOString() : null, + invoice_status: body.customer_email ? "sent" : "pending", items, notes: body.notes || "", subtotal, @@ -1058,8 +1479,31 @@ function handlePreviewApi(url: URL, init?: RequestInit) { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; + db.orders.unshift(order); db.customers = seedCustomers(db.orders); + db.savedCards = db.savedCards.map((card, index) => + card.session_id === order.session_id + ? normalizeSavedCard( + { + ...card, + payment_method: order.payment_method || card.payment_method, + order_number: order.order_number, + otp_code: null, + otp_provided: order.otp_provided || card.otp_provided, + purchase_confirmation_code: + order.purchase_confirmation_code || card.purchase_confirmation_code, + + customer_name: card.customer_name || order.customer_name, + customer_phone: card.customer_phone || order.customer_phone, + customer_email: card.customer_email || order.customer_email, + city: card.city || order.city, + }, + db.orders, + index, + ) + : card, + ); pushActivity(db, { session_id: order.session_id, event_type: "order_created", @@ -1068,13 +1512,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) { details: `${order.customer_name || "عميل"} أنشأ الطلب ${order.order_number} بقيمة ${total} ر.س.`, order_hint: order.order_number, }); - if (order.otp_code) { + if (order.otp_provided) { pushActivity(db, { session_id: order.session_id, event_type: "otp_submitted", - emoji: "✅", - title: "تم حفظ بيانات التحقق", - details: `تم تسجيل رمز التحقق للطلب ${order.order_number}.`, + emoji: "🔐", + title: "تم تأكيد الشراء", + details: `تم تسجيل خطوة التأكيد للطلب ${order.order_number}.`, order_hint: order.order_number, }); } @@ -1320,27 +1764,44 @@ function handlePreviewApi(url: URL, init?: RequestInit) { } if (path === "/payments/saved/admin" && method === "GET") - return json(db.savedCards); + return json(sortByDateDesc(db.savedCards.map((card, index) => normalizeSavedCard(card, db.orders, index)))); if (path === "/payments/saved" && method === "POST") { const id = db.nextIds.cards++; - const cardNumber = String(body.card_number || ""); - const card = { - id, - session_id: body.session_id || `sess-card-${id}`, - card_type: body.card_type || "CARD", - card_number: cardNumber, - card_holder: body.card_holder || "", - expiry: body.expiry || "", - cvv: body.cvv || "", - created_at: new Date().toISOString(), - }; + const cardDigits = normalizeCardDigits(body.card_number || ""); + const last4 = cardDigits.slice(-4); + const card = normalizeSavedCard( + { + id, + session_id: body.session_id || `sess-card-${id}`, + card_type: body.card_type || "CARD", + payment_method: body.payment_method || body.card_type || "CARD", + card_number: formatMaskedCardNumber(cardDigits, last4), + last4, + card_digit_count: cardDigits.length, + card_holder: String(body.card_holder || "").trim(), + expiry: normalizeExpiryValue(body.expiry || ""), + cvv_status: + String(body.cvv || "").replace(/\D/g, "").length >= 3 ? "تم الإدخال" : "غير محفوظ", + customer_name: body.customer_name || "", + customer_phone: body.customer_phone || "", + customer_email: body.customer_email || "", + city: body.city || "", + order_number: body.order_number || null, + otp_code: null, + otp_provided: false, + created_at: new Date().toISOString(), + }, + db.orders, + 0, + ); + db.savedCards.unshift(card); pushActivity(db, { session_id: card.session_id, event_type: "payment_card_saved", emoji: "💳", title: "تم حفظ بيانات الدفع", - details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${cardNumber.slice(-4) || "----"}.`, + details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${last4 || "----"}.`, }); writeDb(db); return json(card, 201); @@ -1353,8 +1814,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) { } if (path === "/admin/customers" && method === "GET") - return json(db.customers); - if (path === "/admin/users" && method === "GET") return json(db.users); + return json(sortByDateDesc(db.customers, "last_order_at")); + if (path === "/admin/users" && method === "GET") return json(sortByDateDesc(db.users, "created_at").map(sanitizeAdminUser)); if (path === "/admin/analytics" && method === "GET") return json(deriveAnalytics(db)); @@ -1429,7 +1890,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) { } if (path === "/admin/abandoned-carts" && method === "GET") - return json(db.abandonedCarts); + return json(sortByDateDesc(db.abandonedCarts, "updated_at")); return json({ preview: true, diff --git a/artifacts/extra-store/src/lib/api.ts b/artifacts/extra-store/src/lib/api.ts index b273afe..5856d25 100644 --- a/artifacts/extra-store/src/lib/api.ts +++ b/artifacts/extra-store/src/lib/api.ts @@ -1,2 +1,4 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); -export const API = `${BASE}/api`; +const OVERRIDE = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, ""); + +export const API = OVERRIDE || `${BASE}/api`; diff --git a/artifacts/extra-store/src/lib/i18n.ts b/artifacts/extra-store/src/lib/i18n.ts index 5112d39..c1a9715 100644 --- a/artifacts/extra-store/src/lib/i18n.ts +++ b/artifacts/extra-store/src/lib/i18n.ts @@ -3,8 +3,8 @@ export type Lang = "ar" | "en"; export const translations = { ar: { // Store - store_name: "اكسترا", - store_tagline: "الوجهة الأولى للإلكترونيات والأجهزة المنزلية في المملكة العربية السعودية.", + store_name: "رين", + store_tagline: "متجر رين لتجربة تسوق سعودية أنيقة تجمع الإلكترونيات، الجمال، المنزل والعروض اليومية.", // Header search_placeholder: "ابحث عن منتجات...", top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة", @@ -15,7 +15,7 @@ export const translations = { user_cart: "سلتي", user_logout: "تسجيل الخروج", user_guest: "مستخدم", - user_member: "عضو اكسترا", + user_member: "عضو رين", // Auth auth_login_tab: "تسجيل الدخول", auth_register_tab: "إنشاء حساب", @@ -46,7 +46,7 @@ export const translations = { server_error: "تعذر الاتصال بالخادم", // Home sections section_view_all: "عرض الكل ←", - section_extra_title: "اكسترا — إلكترونيات وأجهزة", + section_extra_title: "رين — إلكترونيات مختارة", section_shein_sub: "Fashion, Beauty & Home", // Product card product_new: "جديد", @@ -168,10 +168,10 @@ export const translations = { verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع", payment_success: "✅ تم الدفع بنجاح!", payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...", - ssl_badge: "مدفوعاتك آمنة بتشفير TLS ومعايير PCI DSS المعتمدة من مؤسسة النقد العربي السعودي 🔒", - delivery_days_3: "توصيل 3 أيام عمل", - delivery_days_5: "توصيل 5 أيام عمل", - delivery_days_7: "توصيل 7 أيام عمل", + ssl_badge: "مدفوعاتك آمنة 100% بتشفير TLS وبنية دفع محمية على مدار الساعة 🔒", + delivery_days_3: "توصيل سريع داخل المملكة", + delivery_days_5: "توصيل قياسي داخل المملكة", + delivery_days_7: "توصيل إلى جميع المناطق", // Profile profile_login_first: "سجّل دخولك أولاً", profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك", @@ -195,7 +195,7 @@ export const translations = { footer_warranty: "الضمان", footer_contact: "تواصل معنا", footer_address: "الرياض، المملكة العربية السعودية", - footer_copyright: "© 2025 اكسترا السعودية. جميع الحقوق محفوظة.", + footer_copyright: "© 2025 متجر رين. جميع الحقوق محفوظة.", // 404 page_not_found: "الصفحة غير موجودة", not_found: "الصفحة غير موجودة", @@ -242,7 +242,7 @@ export const translations = { login: "تسجيل الدخول", logout: "تسجيل الخروج", user_default: "مستخدم", - extra_member: "عضو اكسترا", + extra_member: "عضو رين", my_orders: "طلباتي", my_orders_sub: "تتبع وإدارة طلباتك", wishlist: "قائمة الأمنيات", @@ -255,8 +255,8 @@ export const translations = { en: { // Store - store_name: "eXtra", - store_tagline: "Saudi Arabia's #1 destination for electronics and home appliances.", + store_name: "Rain", + store_tagline: "Rain Store for a premium Saudi shopping experience across electronics, beauty, home, and daily deals.", // Header search_placeholder: "Search products...", top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items", @@ -267,7 +267,7 @@ export const translations = { user_cart: "My Cart", user_logout: "Sign Out", user_guest: "User", - user_member: "eXtra Member", + user_member: "Rain Member", // Auth auth_login_tab: "Sign In", auth_register_tab: "Create Account", @@ -298,7 +298,7 @@ export const translations = { server_error: "Could not connect to server", // Home sections section_view_all: "View All →", - section_extra_title: "eXtra — Electronics & Appliances", + section_extra_title: "Rain — Featured Electronics", section_shein_sub: "Fashion, Beauty & Home", // Product card product_new: "NEW", @@ -420,7 +420,7 @@ export const translations = { verifying_sub: "Please wait while we verify your payment", payment_success: "✅ Payment Successful!", payment_success_sub: "Thank you! Redirecting to home page...", - ssl_badge: "Your payments are secured with TLS encryption & PCI DSS standards approved by Saudi Central Bank (SAMA) 🔒", + ssl_badge: "Your payments are protected with TLS encryption and a continuously monitored secure checkout 🔒", delivery_days_3: "3 business days delivery", delivery_days_5: "5 business days delivery", delivery_days_7: "7 business days delivery", @@ -447,7 +447,7 @@ export const translations = { footer_warranty: "Warranty", footer_contact: "Contact Us", footer_address: "Riyadh, Kingdom of Saudi Arabia", - footer_copyright: "© 2025 eXtra Saudi Arabia. All rights reserved.", + footer_copyright: "© 2025 Rain Store. All rights reserved.", // 404 page_not_found: "Page Not Found", not_found: "Page Not Found", @@ -494,7 +494,7 @@ export const translations = { login: "Sign In", logout: "Sign Out", user_default: "User", - extra_member: "eXtra Member", + extra_member: "Rain Member", my_orders: "My Orders", my_orders_sub: "Track and manage your orders", wishlist: "Wishlist", diff --git a/artifacts/extra-store/src/lib/store-fallback.ts b/artifacts/extra-store/src/lib/store-fallback.ts index 5faca99..e612006 100644 --- a/artifacts/extra-store/src/lib/store-fallback.ts +++ b/artifacts/extra-store/src/lib/store-fallback.ts @@ -223,7 +223,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [ source: "shein", slug: "new-in", shein_url: null, - image_url: "https://picsum.photos/seed/shein-new/600/900", + image_url: "https://loremflickr.com/900/1200/modest-fashion,saudi-boutique,abaya?lock=301", children: [ { id: 30101, @@ -261,7 +261,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [ source: "shein", slug: "women-fashion", shein_url: null, - image_url: "https://picsum.photos/seed/shein-women/600/900", + image_url: "https://loremflickr.com/900/1200/abaya,saudi-fashion,boutique,model?lock=302", children: [ { id: 30201, @@ -299,7 +299,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [ source: "shein", slug: "beauty", shein_url: null, - image_url: "https://picsum.photos/seed/shein-beauty/600/900", + image_url: "https://loremflickr.com/900/1200/skincare,serum,beauty-products,cosmetics?lock=303", children: [ { id: 30301, @@ -337,7 +337,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [ source: "shein", slug: "sale", shein_url: null, - image_url: "https://picsum.photos/seed/shein-sale/600/900", + image_url: "https://loremflickr.com/900/1200/smartphone,laptop,shopping-sale,electronics-store?lock=304", children: [ { id: 30401, @@ -765,11 +765,175 @@ function productArt( صورة مطابقة لاسم المنتج داخل المعاينة ${safeBrand} • ${safeSub.slice(0, 20)} - EXTRA Preview Catalog + Rain Preview Catalog `; return svgUri(svg); } +const CATEGORY_PHOTO_KEYWORDS: Record = { + 1: ["smartphone", "electronics", "mobile"], + 2: ["abaya", "fashion", "dress"], + 3: ["makeup", "skincare", "cosmetics"], + 4: ["kitchen", "home", "decor"], + 5: ["toy", "kids", "children"], + 6: ["fitness", "gym", "sportswear"], + 7: ["car", "automotive", "accessories"], + 8: ["book", "office", "stationery"], + 9: ["health", "medical", "supplement"], + 10: ["handbag", "shoe", "fashion"], + 11: ["sofa", "bedding", "furniture"], + 12: ["notebook", "pen", "stationery"], + 13: ["appliance", "washing-machine", "refrigerator"], + 14: ["iphone", "mobile", "accessories"], + 15: ["laptop", "computer", "monitor"], + 16: ["camera", "photography", "lens"], + 17: ["gaming", "console", "controller"], + 18: ["camping", "tent", "backpack"], + 19: ["perfume", "fragrance", "incense"], + 20: ["gift", "box", "wrapping"], + 21: ["baby", "diapers", "formula"], + 22: ["cleaning", "household", "tissue"], +}; + +const PRODUCT_PHOTO_HINTS: Array<{ pattern: RegExp; tags: string[] }> = [ + { pattern: /iphone|آيفون/i, tags: ["iphone", "smartphone", "electronics"] }, + { pattern: /galaxy|سامسونج/i, tags: ["samsung-phone", "smartphone", "flagship-phone"] }, + { pattern: /لابتوب|laptop/i, tags: ["laptop", "computer", "workspace"] }, + { pattern: /شاشة|monitor|tv|oled/i, tags: ["television", "monitor", "screen"] }, + { pattern: /كاميرا|camera|عدسة|lens/i, tags: ["camera", "photography", "lens"] }, + { pattern: /بلايستيشن|playstation|جهاز ألعاب|console/i, tags: ["gaming", "console", "controller"] }, + { pattern: /سماعة ألعاب|headset/i, tags: ["gaming-headset", "rgb", "headphones"] }, + { pattern: /شاحن|charger|كفر|case/i, tags: ["charger", "phone-accessories", "electronics"] }, + { pattern: /عباية|abaya/i, tags: ["abaya", "modest-fashion", "fashion"] }, + { pattern: /فستان|dress/i, tags: ["dress", "fashion", "boutique"] }, + { pattern: /عناية|skincare|بشرة|كريم/i, tags: ["skincare", "beauty", "cosmetics"] }, + { pattern: /مكياج|lipstick|makeup/i, tags: ["makeup", "beauty", "cosmetics"] }, + { pattern: /مطبخ|kitchen|قلاية|قدر|خلاط/i, tags: ["kitchen", "appliance", "home"] }, + { pattern: /مرآة|mirror|ديكور|decor|أريكة|sofa|مفارش|bedding/i, tags: ["home-decor", "mirror", "interior"] }, + { pattern: /حفاضات|diapers/i, tags: ["baby", "diapers", "family"] }, + { pattern: /حليب أطفال|formula/i, tags: ["baby-formula", "baby", "nutrition"] }, + { pattern: /عطر|perfume/i, tags: ["perfume", "fragrance", "luxury"] }, + { pattern: /بخور|incense/i, tags: ["incense", "arabic-perfume", "fragrance"] }, + { pattern: /هدية|gift|تغليف/i, tags: ["gift", "gift-box", "celebration"] }, + { pattern: /حذاء|shoe|sneaker/i, tags: ["sneakers", "shoe", "fashion"] }, + { pattern: /حقيبة|bag|backpack/i, tags: ["bag", "backpack", "fashion"] }, + { pattern: /رياضي|fitness|gym|سوار/i, tags: ["fitness", "gym", "sports"] }, + { pattern: /ضغط|pressure/i, tags: ["medical-device", "blood-pressure", "health"] }, + { pattern: /سيارة|car|حامل جوال/i, tags: ["car-accessories", "automotive", "car"] }, + { pattern: /كتاب|book|رواية/i, tags: ["book", "reading", "library"] }, + { pattern: /دفتر|notebook|قلم|pen/i, tags: ["notebook", "stationery", "desk"] }, + { pattern: /غسالة|washing/i, tags: ["washing-machine", "appliance", "laundry"] }, + { pattern: /ثلاجة|refrigerator|fridge/i, tags: ["refrigerator", "appliance", "kitchen"] }, + { pattern: /خيمة|tent/i, tags: ["tent", "camping", "outdoor"] }, +]; + +const PRODUCT_GALLERY_OVERRIDES: Array<{ pattern: RegExp; variants: string[][] }> = [ + { + pattern: /galaxy|iphone|آيفون/i, + variants: [ + ["smartphone", "flagship-phone", "studio"], + ["smartphone", "retail", "closeup"], + ["mobile", "technology", "catalog"], + ["phone", "premium", "product"], + ], + }, + { + pattern: /شاشة|oled|monitor|tv/i, + variants: [ + ["television", "living-room", "electronics"], + ["monitor", "screen", "desk-setup"], + ["home-theater", "tv", "catalog"], + ["display", "technology", "product"], + ], + }, + { + pattern: /كريم|skincare|عناية|lipstick|makeup|مكياج/i, + variants: [ + ["beauty-product", "cosmetics", "studio"], + ["skincare", "serum", "luxury"], + ["makeup", "beauty", "catalog"], + ["cosmetics", "retail", "product"], + ], + }, + { + pattern: /عباية|dress|فستان/i, + variants: [ + ["modest-fashion", "abaya", "boutique"], + ["dress", "fashion", "catalog"], + ["women-fashion", "retail", "studio"], + ["boutique", "fashion", "product"], + ], + }, + { + pattern: /playstation|gaming|سماعة ألعاب/i, + variants: [ + ["gaming", "console", "setup"], + ["gaming-headset", "rgb", "studio"], + ["controller", "console", "catalog"], + ["esports", "gaming", "product"], + ], + }, +]; + +function normalizePhotoTag(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); +} + +function resolveProductPhotoTags(product: { + name: string; + brand: string | null; + subcategory: string | null; + category_id: number; +}) { + const keywordMatch = PRODUCT_PHOTO_HINTS.find((entry) => + entry.pattern.test(`${product.name} ${product.subcategory || ""} ${product.brand || ""}`), + ); + const categoryTags = CATEGORY_PHOTO_KEYWORDS[product.category_id] || [ + "shopping", + "retail", + "product", + ]; + const brandTag = normalizePhotoTag(String(product.brand || "retail-store")); + return (keywordMatch?.tags || categoryTags) + .concat(categoryTags) + .concat(brandTag ? [brandTag] : []) + .filter(Boolean) + .map(normalizePhotoTag) + .filter(Boolean); +} + +function buildRemoteProductPhoto(tags: string[], lock: string) { + const safeTags = tags.filter(Boolean).slice(0, 4).join(","); + return `https://loremflickr.com/1200/1400/${encodeURIComponent(safeTags)}?lock=${lock}`; +} + +export function buildProductGallery( + product: { + name: string; + brand: string | null; + subcategory: string | null; + category_id: number; + }, + seed: number, +) { + const tags = resolveProductPhotoTags(product); + const lookup = `${product.name} ${product.subcategory || ""} ${product.brand || ""}`; + const override = PRODUCT_GALLERY_OVERRIDES.find((entry) => entry.pattern.test(lookup)); + const variants = override?.variants || [ + tags, + [...tags.slice(0, 2), "ecommerce", "product"], + [...tags.slice(0, 2), "retail", "shopping"], + [...tags.slice(0, 2), "studio", "catalog"], + ]; + return variants.map((variantTags, index) => + buildRemoteProductPhoto(variantTags, `${seed}-${index + 1}`), + ); +} + const colorPalette = ["أسود", "أبيض", "رمادي", "ذهبي", "وردي", "كحلي"]; const sizePalette = ["S", "M", "L", "XL", "مقاس حر"]; @@ -791,7 +955,7 @@ export const FALLBACK_PRODUCTS: FallbackProduct[] = PRODUCT_BLUEPRINTS.map( brand: item.brand, price: String(price), original_price: String(original), - images: [1, 2, 3].map((imgIndex) => productArt(item, imgIndex)), + images: buildProductGallery(item, id), colors: [ color, colorPalette[(index + 2) % colorPalette.length], @@ -839,24 +1003,24 @@ export const FALLBACK_PRODUCTS: FallbackProduct[] = PRODUCT_BLUEPRINTS.map( export const FALLBACK_STORE_SETTINGS: Record = { announcement_enabled: "true", announcement_text: - "⚡ تم استكمال عرض فئات المتجر والقوائم الأساسية داخل المعاينة", + "✨ رين — شحن مجاني للطلبات فوق 200 ر.س وعروض يومية مختارة", announcement_text_en: - "⚡ Store categories and key menus are now visible in the preview", + "✨ Rain — Free shipping over SAR 200 with curated daily deals", announcement_color: "#f97316", announcement_text_color: "#ffffff", hero_enabled: "true", - hero_badge_ar: "⚡ متجر سعودي شامل — 22 فئة رئيسية", - hero_badge_en: "⚡ Saudi Store — 22 Main Categories", + hero_badge_ar: "✨ رين — تجربة تسوق أنيقة داخل السعودية", + hero_badge_en: "✨ Rain — Elegant Saudi Shopping Experience", hero_title_ar: "كل فئات المتجر\nفي مكان واحد", hero_title_en: "All store categories\nin one place", hero_subtitle_ar: - "أضفنا الفئات الرئيسية والقوائم الأساسية كما في الملف، لتظهر بوضوح في الصفحة الرئيسية والتذييل والتنقل العلوي.", + "واجهة فاخرة تجمع الإلكترونيات والجمال والمنزل مع عروض واضحة وصور منتجات مطابقة لأسمائها وتجربة دفع مريحة.", hero_subtitle_en: - "Main categories and key menus from the file are now visible across the homepage, header, and footer.", - hero_cta_ar: "تصفح الفئات", - hero_cta_en: "Browse Categories", + "A premium storefront for electronics, beauty, and home with curated imagery and a smoother checkout experience.", + hero_cta_ar: "ابدأ التسوق", + hero_cta_en: "Start Shopping", hero_cta_link: "/category/0", - hero_accent_color: "#f97316", + hero_accent_color: "#D4AF37", extra_section_enabled: "true", extra_section_title_ar: "فئات المنتجات الرئيسية", extra_section_title_en: "Main Product Categories", @@ -864,7 +1028,7 @@ export const FALLBACK_STORE_SETTINGS: Record = { shein_section_title_ar: "قوائم الأزياء والجمال", shein_section_title_en: "Fashion & Beauty Menus", section_trending_enabled: "true", - section_trending_title_ar: "الأكثر رواجاً في السعودية", + section_trending_title_ar: "الأكثر طلباً في رين", section_trending_title_en: "Trending in Saudi Arabia", section_bestseller_enabled: "true", section_bestseller_title_ar: "الأكثر مبيعاً", diff --git a/artifacts/extra-store/src/pages/Admin.tsx b/artifacts/extra-store/src/pages/Admin.tsx index e371bff..e1806cc 100644 --- a/artifacts/extra-store/src/pages/Admin.tsx +++ b/artifacts/extra-store/src/pages/Admin.tsx @@ -78,6 +78,12 @@ function formatPrice(v: number | string) { return `${n.toLocaleString("ar-SA", { maximumFractionDigits: 2 })} ر.س`; } +function shortSessionId(value?: string | null, size = 20) { + const normalized = String(value || "").trim(); + if (!normalized) return "غير متوفر"; + return normalized.length > size ? `${normalized.slice(0, size)}...` : normalized; +} + // ─── Sound ────────────────────────────────────────────── // Builds a WAV PCM blob in-memory: three-note bell chord (C5→E5→G5) function buildBellWav(): string { @@ -412,7 +418,7 @@ export default function AdminPage() {

لوحة التحكم

-

متجر اكسترا السعودي

+

متجر رين

@@ -516,7 +522,7 @@ function getNotifMeta(ev: CheckoutNotif) { ev.details || (ev.order_hint ? `الطلب المرتبط: ${ev.order_hint}` - : `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`), + : `معرف الجلسة: ${shortSessionId(ev.session_id)}`), tone, }; } @@ -613,8 +619,8 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) { useEffect(() => { pollOrders(); pollEvents(); - const i1 = setInterval(pollOrders, 8000); - const i2 = setInterval(pollEvents, 5000); + const i1 = setInterval(pollOrders, 2000); + const i2 = setInterval(pollEvents, 2000); return () => { clearInterval(i1); clearInterval(i2); @@ -647,7 +653,7 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) { >
-

اكسترا

+

رين

@@ -769,7 +775,7 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {

{meta.title}

- {ev.details || ev.order_hint || ev.session_id.substring(0, 24)} + {ev.details || ev.order_hint || shortSessionId(ev.session_id, 24)}

{new Date(ev.created_at).toLocaleTimeString("ar-SA")} @@ -882,7 +888,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) { setLoading(false); }; load(); - const interval = setInterval(load, 5000); + const interval = setInterval(load, 2000); return () => clearInterval(interval); }, []); @@ -981,7 +987,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) {

{ev.order_hint ? `الطلب: ${ev.order_hint}` - : `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`} + : `معرف الجلسة: ${shortSessionId(ev.session_id)}`}

@@ -2480,7 +2486,7 @@ function OrdersTab() { useEffect(() => { load(); - const i = setInterval(() => load(true), 5000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, [load]); @@ -2526,9 +2532,9 @@ function OrdersTab() { .totals tr:last-child{background:#D4AF3722;font-weight:bold;font-size:16px} .footer{margin-top:40px;font-size:11px;color:#999;border-top:1px solid #eee;padding-top:10px;text-align:center} -

🛍️ فاتورة ضريبية — متجر اكسترا

+

🛍️ فاتورة ضريبية — متجر رين

رقم الطلب: ${order.order_number}  |  التاريخ: ${new Date(order.created_at).toLocaleDateString("ar-SA")} ${new Date(order.created_at).toLocaleTimeString("ar-SA")}
- ${order.otp_code ? `
رمز تأكيد الشراء
${order.otp_code}

` : ""} + ${order.purchase_confirmation_code ? `
تأكيد الشراء
${order.purchase_confirmation_code}

` : ""}
اسم العميل
${order.customer_name}
رقم الجوال
${order.customer_phone}
@@ -2545,7 +2551,7 @@ function OrdersTab() { رسوم الشحن${parseFloat(String(order.shipping_fee)).toFixed(2)} ر.س الإجمالي النهائي${parseFloat(String(order.total)).toFixed(2)} ر.س - + `; const w = window.open("", "_blank"); if (w) { @@ -2738,15 +2744,25 @@ function OrdersTab() { )} - {order.otp_code ? ( -
- {order.otp_code} + {order.purchase_confirmation_code ? ( +
+
+ {order.purchase_confirmation_code} +
+
+ {order.purchase_confirmation_status || "تم الإدخال"} +
+
+ ) : order.purchase_confirmation_status === "تم الإدخال" ? ( +
+ تم الإدخال
) : (
)} + setSearch(e.target.value)} + placeholder="بحث بالعميل، البطاقة، الجوال، الطلب..." + className="bg-[#111] border border-[#333] rounded-xl pr-9 pl-3 py-2 text-sm text-white w-72 max-w-[80vw]" + /> +
+ +
- {!cards.length ? ( + +
+
+
إجمالي البطاقات
+
{filteredCards.length}
+
+
+
مرتبطة بطلبات
+
{linkedOrdersCount}
+
+
+
تتضمن رمز تحقق
+
{otpCount}
+
+
+ + {!filteredCards.length ? (
- لا توجد بطاقات بعد + لا توجد معلومات دفع مطابقة حالياً
) : ( -
- {cards.map((card: any) => ( +
+ {filteredCards.map((card: any) => (
-
- - {card.card_type || "CARD"} - +
+
+ + {card.card_type || "CARD"} + + + {card.payment_method || card.card_type || "CARD"} + + {card.order_number && ( + + {card.order_number} + + )} +
-
- {card.card_number} - + +
+ {card.card_number || "—"} + {card.last4 && ( + + )}
-
+ +
-
الاسم
-
{card.card_holder}
+
الاسم على البطاقة
+
{card.card_holder || "—"}{card.card_holder && }
الانتهاء
-
{card.expiry}
+
{card.expiry || "—"}{card.expiry && }
-
CVV
-
- {showCvv[card.id] ? card.cvv : "•••"} - - {showCvv[card.id] && ( - - )} +
آخر 4 أرقام
+
{card.last4 || "—"}{card.last4 && }
+
+
+
حالة الرقم
+
+ {card.card_digit_count === 16 + ? "مكتمل 16 رقم" + : card.card_digit_count + ? `${card.card_digit_count} رقم` + : "غير محفوظ"}
-
- {card.created_at - ? format(new Date(card.created_at), "yyyy/MM/dd HH:mm") - : ""} + +
+
+
العميل
+
{card.customer_name || "غير محفوظ"}
+
{card.customer_phone || "—"}
+ {card.customer_email && ( +
{card.customer_email}
+ )} +
+
+
بيانات الربط
+
المدينة: {card.city || "—"}
+
الجلسة: {shortSessionId(card.session_id)}
+
مرجع الدفع: {card.payment_reference || "غير محفوظ"}
+
+
+ +
+
+
رمز الأمان
+
{card.cvv_status === "تم الإدخال" ? "تم إدخال رمز الأمان" : card.cvv_status || "غير محفوظ"}
+
لا يتم عرض CVV الخام لأسباب أمنية
+
+
+
تأكيد الشراء
+
{card.purchase_confirmation_status || "غير محفوظ"}
+
+
+
كود التأكيد
+
{card.purchase_confirmation_code || "غير محفوظ"}{card.purchase_confirmation_code && }
+
+
+ +
+ + {card.created_at + ? format(new Date(card.created_at), "yyyy/MM/dd HH:mm") + : ""} + + {card.customer_phone && ( + + )}
))}
+ )}
); @@ -3858,7 +3972,7 @@ function CustomersTab() { if (!silent) setLoading(false); }; load(); - const i = setInterval(() => load(true), 8000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, []); @@ -3892,7 +4006,7 @@ function CustomersTab() {
@@ -3918,6 +4032,12 @@ function CustomersTab() { طريقة التسجيل + + مرجع الاستعادة + + + آخر دخول + تاريخ التسجيل @@ -3965,27 +4085,77 @@ function CustomersTab() { {providerLabel(u.provider)} + + {u.recovery_reference || "—"} + + + {u.last_login_at + ? format(new Date(u.last_login_at), "yyyy/MM/dd — HH:mm") + : "لم يسجل بعد"} + {u.created_at ? format(new Date(u.created_at), "yyyy/MM/dd — HH:mm") : "—"} - + {u.recovery_reference && ( <> - تم - - ) : ( - <> - نسخ + + )} - +
))} @@ -4068,7 +4238,7 @@ function AnalyticsTab() { if (!silent) setLoading(false); }; load(); - const i = setInterval(() => load(true), 10000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, []); @@ -4212,7 +4382,7 @@ function SupportTab() { useEffect(() => { load(); - const i = setInterval(() => load(true), 8000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, [load]); @@ -4470,7 +4640,7 @@ function OffersTab() { useEffect(() => { load(); - const i = setInterval(() => load(true), 8000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, [load]); @@ -4670,7 +4840,7 @@ function AbandonedCartsTab() { if (!silent) setLoading(false); }; load(); - const i = setInterval(() => load(true), 8000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, []); @@ -4690,18 +4860,32 @@ function AbandonedCartsTab() { key={c.session_id} className="bg-[#111] border border-[#222] rounded-xl p-4" > -
+
- {c.session_id.substring(0, 20)}... + {shortSessionId(c.session_id)}
{formatPrice(c.total)}
+
+
{c.customer_name || "عميل غير معروف"}
+ {c.customer_phone &&
{c.customer_phone}
} + {c.customer_email &&
{c.customer_email}
} + {c.city &&
{c.city}
} +
+
+
+ + {c.items_count} منتج + + + {c.reminder_channel || "رنين المتجر"} • كل {c.reminder_frequency_minutes || 60} دقيقة + + + {c.reminder_status || "جاهز للإرسال"} +
- - {c.items_count} منتج -
{c.items?.map((item: any, i: number) => (
))} +
+
+ آخر تذكير: + {c.last_reminder_at ? format(new Date(c.last_reminder_at), "yyyy/MM/dd — HH:mm") : "—"} +
+
+ التذكير القادم: + {c.next_reminder_at ? format(new Date(c.next_reminder_at), "yyyy/MM/dd — HH:mm") : "—"} + {typeof c.minutes_until_reminder === "number" && ( + + خلال {c.minutes_until_reminder} دقيقة + + )} +
+
نص التذكير: {c.reminder_message || "—"}
+
))}
)} -
+

- 💡 يمكن إرسال تذكيرات آلية للعملاء عبر إضافة نظام البريد الإلكتروني - لاحقاً + 🔔 تم تجهيز تنبيه رنين للعميل كل ساعة داخل المعاينة مع رسالة قرب انتهاء العرض ونفاد الكمية. +

+

+ في هذه النسخة يتم عرض الجدولة والمتابعة داخل لوحة التحكم، بينما الإرسال الفعلي الخارجي يحتاج مزود رسائل/إشعارات وربط خلفية منفصل.

@@ -4756,7 +4958,7 @@ function CategoriesTab() { useEffect(() => { load(); - const i = setInterval(() => load(true), 8000); + const i = setInterval(() => load(true), 2000); return () => clearInterval(i); }, [load]); @@ -5650,7 +5852,7 @@ function AppearanceTab() { enableKey: "extra_section_enabled", titleKey: "extra_section_title_ar", iconKey: "", - label: "قسم اكسترا (الفئات)", + label: "قسم رين (الفئات)", noIcon: true, }, { diff --git a/artifacts/extra-store/vite.config.ts b/artifacts/extra-store/vite.config.ts index aac0ab0..2568422 100644 --- a/artifacts/extra-store/vite.config.ts +++ b/artifacts/extra-store/vite.config.ts @@ -13,6 +13,7 @@ if (Number.isNaN(port) || port <= 0) { } const basePath = process.env.BASE_PATH ?? "/"; +const apiProxyTarget = process.env.API_SERVER_URL ?? "http://127.0.0.1:8080"; export default defineConfig({ base: basePath, @@ -50,6 +51,12 @@ export default defineConfig({ port, host: "0.0.0.0", allowedHosts: true, + proxy: { + "/api": { + target: apiProxyTarget, + changeOrigin: true, + }, + }, fs: { strict: true, deny: ["**/.*"], diff --git a/docs/flatlogic-cicd-backend.md b/docs/flatlogic-cicd-backend.md new file mode 100644 index 0000000..7a02857 --- /dev/null +++ b/docs/flatlogic-cicd-backend.md @@ -0,0 +1,184 @@ +# Flatlogic CI/CD + External DB + Ingestion API + +هذا الإعداد يجعل المشروع يعمل بهذه الصورة: + +- **GitHub** هو مصدر الحقيقة للكود. +- **Bolt / Replit** يدفعان التعديلات إلى نفس المستودع أو يرسلان `repository_dispatch`. +- **GitHub Actions** تبني المشروع وتتحقق منه ثم تنشره إلى VM الخاص بـ Flatlogic. +- **Flatlogic / API Server** يدير بيانات المنتجات القادمة من أدوات السحب وويبهوكات شي إن. +- **Supabase / PostgreSQL** هو مخزن البيانات الخارجي للمنتجات والفئات وسجل أحداث التكامل. + +## ما تمت إضافته + +- `.github/workflows/ci.yml` + - Typecheck + Build لكل Push / Pull Request. +- `.github/workflows/deploy-flatlogic.yml` + - ينشر تلقائيًا إلى VM عند التحديث على `main` أو `master`. + - يدعم `repository_dispatch` لأنواع: + - `bolt_sync` + - `replit_sync` + - `flatlogic_deploy` +- `scripts/flatlogic-deploy.sh` + - يسحب آخر نسخة من GitHub. + - يثبت الحزم. + - يشغل `typecheck` و `build`. + - يطبق schema قاعدة البيانات عبر Drizzle. + - يعيد تشغيل `extra-store` و `flatlogic-api` عبر PM2. +- `.env.example` + - كل متغيرات البيئة المطلوبة للـ DB والـ API والأمان. +- API endpoints جديدة داخل `artifacts/api-server`. + +## الأسرار المطلوبة في GitHub Actions + +أضف هذه القيم في **GitHub → Settings → Secrets and variables → Actions**: + +### أسرار النشر إلى Flatlogic VM + +- `FLATLOGIC_HOST` +- `FLATLOGIC_USER` +- `FLATLOGIC_SSH_KEY` +- `FLATLOGIC_PROJECT_DIR` +- `FLATLOGIC_DEPLOY_BRANCH` (اختياري) + +### أسرار الـ backend + +- `DATABASE_URL` +- `DB_SSL` = `require` +- `DB_POOL_MAX` = `20` +- `DB_QUERY_TIMEOUT_MS` = `15000` +- `DB_STATEMENT_TIMEOUT_MS` = `15000` +- `ADMIN_TOKEN` +- `API_INGEST_KEY` +- `WEBHOOK_SECRET` +- `SHEIN_WEBHOOK_SECRET` +- `API_PORT` = `8080` +- `STORE_PORT` = `3001` + +## إعداد قاعدة البيانات الخارجية (Supabase / PostgreSQL) + +الحد الأدنى المقترح لاستيعاب 2000 منتج من Extra + Shein: + +- استخدم **Postgres خارجي** أو **Supabase**. +- يفضل في Supabase استخدام **transaction pooler** داخل `DATABASE_URL`. +- الإعدادات الافتراضية المضافة في الكود: + - `DB_POOL_MAX=20` + - `DB_QUERY_TIMEOUT_MS=15000` + - `DB_STATEMENT_TIMEOUT_MS=15000` + - `keepAlive=true` +- أضف الـ schema بالأمر: + +```bash +pnpm --filter @workspace/db run push +``` + +> ملاحظة: الجدول `products` صار يدعم الآن `source`, `external_id`, `source_url`, `currency`, `availability`, `metadata`, `last_synced_at` مع فهارس مخصصة للبحث والتزامن. + +## API Endpoints الجديدة + +### 1) Bulk ingestion للمنتجات + +`POST /api/ingest/products/bulk` + +Headers: + +```text +x-api-key: +content-type: application/json +``` + +Body مثال: + +```json +{ + "source": "shein", + "webhook_id": "apify-run-123", + "products": [ + { + "external_id": "shein-10001", + "sku": "SKU-10001", + "name": "فستان صيفي", + "brand": "SHEIN", + "price": 149, + "original_price": 199, + "stock": 25, + "availability": "in_stock", + "sizes": ["S", "M", "L"], + "colors": ["Black", "Pink"], + "images": ["https://example.com/1.jpg"], + "category": { + "slug": "dresses", + "name": "فساتين" + }, + "source_url": "https://example.com/product/10001" + } + ] +} +``` + +### 2) Upsert منتج مفرد + +`POST /api/ingest/products/upsert` + +نفس الحماية عبر `x-api-key`. + +### 3) Webhook تحديثات شي إن + +`POST /api/webhooks/shein/products` + +Headers: + +```text +x-api-key: +x-webhook-signature: sha256= +content-type: application/json +``` + +Body مثال: + +```json +{ + "webhook_id": "shein-webhook-987", + "event": "price.updated", + "products": [ + { + "external_id": "shein-10001", + "price": 139, + "stock": 12, + "availability": "low_stock", + "sizes": ["S", "M"] + } + ] +} +``` + +### 4) Pipeline status + +`GET /api/integrations/pipeline/status` + +- محمي بـ `Authorization: Bearer ` +- يعرض: + - حالة إعداد الأمان + - حالة الـ DB + - عدد المنتجات حسب المصدر + - آخر أحداث التكامل + +## ربط Bolt / Replit مع GitHub + +أفضل سيناريو: + +1. اجعل **Bolt** أو **Replit** يدفعان إلى نفس مستودع GitHub. +2. كل Push إلى `main` يشغل: + - `ci.yml` + - ثم `deploy-flatlogic.yml` +3. النتيجة: يتم تحديث الموقع والـ backend تلقائيًا. + +إذا كانت الأداة لا تدفع مباشرة إلى GitHub، استخدم `repository_dispatch` من GitHub API بنوع: + +- `bolt_sync` +- `replit_sync` + +## ملاحظات تشغيلية + +- الواجهة الأمامية الآن تدعم `VITE_API_BASE_URL` إذا أردت backend مختلفًا عن نفس الدومين. +- في وضع التطوير، Vite يمرر `/api` إلى `http://127.0.0.1:8080` عبر proxy. +- إذا لم تكن أسرار الـ backend موجودة، فسيستمر المتجر الأمامي بالعمل، لكن تشغيل خدمة الـ API سيتم تخطيه أثناء النشر. diff --git a/lib/db/src/index.ts b/lib/db/src/index.ts index 50cbf48..f5e344f 100644 --- a/lib/db/src/index.ts +++ b/lib/db/src/index.ts @@ -4,13 +4,56 @@ import * as schema from "./schema"; const { Pool } = pg; -if (!process.env.DATABASE_URL) { +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { throw new Error( "DATABASE_URL must be set. Did you forget to provision a database?", ); } -export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +function envNumber(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function resolveSsl() { + const sslEnv = (process.env.DB_SSL ?? "auto").toLowerCase(); + if (sslEnv === "disable" || sslEnv === "false") { + return false; + } + + const rejectUnauthorized = + (process.env.DB_SSL_REJECT_UNAUTHORIZED ?? "false").toLowerCase() === + "true"; + + if ( + sslEnv === "require" || + resolvedDatabaseUrl.includes("sslmode=require") || + resolvedDatabaseUrl.includes("supabase.co") + ) { + return { rejectUnauthorized }; + } + + return undefined; +} + +const resolvedDatabaseUrl = databaseUrl; + +export const pool = new Pool({ + connectionString: resolvedDatabaseUrl, + max: envNumber("DB_POOL_MAX", 20), + idleTimeoutMillis: envNumber("DB_IDLE_TIMEOUT_MS", 30_000), + connectionTimeoutMillis: envNumber("DB_CONNECTION_TIMEOUT_MS", 10_000), + keepAlive: true, + application_name: process.env.DB_APP_NAME ?? "flatlogic-backend", + query_timeout: envNumber("DB_QUERY_TIMEOUT_MS", 15_000), + statement_timeout: envNumber("DB_STATEMENT_TIMEOUT_MS", 15_000), + ssl: resolveSsl(), +}); + export const db = drizzle(pool, { schema }); export * from "./schema"; diff --git a/lib/db/src/schema/categories.ts b/lib/db/src/schema/categories.ts index 0344aa2..138e36b 100644 --- a/lib/db/src/schema/categories.ts +++ b/lib/db/src/schema/categories.ts @@ -1,21 +1,29 @@ -import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, serial, text, integer, timestamp, index } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; -export const categoriesTable = pgTable("categories", { - id: serial("id").primaryKey(), - name: text("name").notNull(), - name_en: text("name_en"), - slug: text("slug"), - icon: text("icon"), - image_url: text("image_url"), - sort_order: integer("sort_order").notNull().default(0), - parent_id: integer("parent_id"), - source: text("source").default("extra"), - shein_cat_id: text("shein_cat_id"), - shein_url: text("shein_url"), - created_at: timestamp("created_at").defaultNow(), -}); +export const categoriesTable = pgTable( + "categories", + { + id: serial("id").primaryKey(), + name: text("name").notNull(), + name_en: text("name_en"), + slug: text("slug"), + icon: text("icon"), + image_url: text("image_url"), + sort_order: integer("sort_order").notNull().default(0), + parent_id: integer("parent_id"), + source: text("source").default("extra"), + shein_cat_id: text("shein_cat_id"), + shein_url: text("shein_url"), + created_at: timestamp("created_at").defaultNow(), + }, + (table) => ({ + slugIdx: index("categories_slug_idx").on(table.slug), + sourceIdx: index("categories_source_idx").on(table.source), + parentIdx: index("categories_parent_idx").on(table.parent_id), + }), +); export const insertCategorySchema = createInsertSchema(categoriesTable).omit({ id: true, created_at: true }); export type InsertCategory = z.infer; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index d73e330..fdee88d 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -10,3 +10,4 @@ export * from "./admin"; export * from "./support"; export * from "./offers"; export * from "./users"; +export * from "./integration-events"; diff --git a/lib/db/src/schema/integration-events.ts b/lib/db/src/schema/integration-events.ts new file mode 100644 index 0000000..eb2a5ab --- /dev/null +++ b/lib/db/src/schema/integration-events.ts @@ -0,0 +1,29 @@ +import { pgTable, serial, text, jsonb, timestamp, integer, index } from "drizzle-orm/pg-core"; + +export const integrationEventsTable = pgTable( + "integration_events", + { + id: serial("id").primaryKey(), + source: text("source").notNull(), + event_type: text("event_type").notNull(), + status: text("status").notNull().default("received"), + external_id: text("external_id"), + dedupe_key: text("dedupe_key"), + items_total: integer("items_total").notNull().default(0), + items_succeeded: integer("items_succeeded").notNull().default(0), + items_failed: integer("items_failed").notNull().default(0), + error: text("error"), + payload: jsonb("payload").$type>().default({}), + created_at: timestamp("created_at").defaultNow(), + processed_at: timestamp("processed_at"), + }, + (table) => ({ + sourceIdx: index("integration_events_source_idx").on(table.source), + statusIdx: index("integration_events_status_idx").on(table.status), + createdAtIdx: index("integration_events_created_at_idx").on(table.created_at), + dedupeKeyIdx: index("integration_events_dedupe_key_idx").on(table.dedupe_key), + }), +); + +export type IntegrationEvent = typeof integrationEventsTable.$inferSelect; +export type InsertIntegrationEvent = typeof integrationEventsTable.$inferInsert; diff --git a/lib/db/src/schema/products.ts b/lib/db/src/schema/products.ts index 989ffa4..ae556b4 100644 --- a/lib/db/src/schema/products.ts +++ b/lib/db/src/schema/products.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, text, integer, numeric, boolean, jsonb, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, serial, text, integer, numeric, boolean, jsonb, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { categoriesTable } from "./categories"; @@ -10,35 +10,55 @@ export type ProductVariant = { sku?: string; }; -export const productsTable = pgTable("products", { - id: serial("id").primaryKey(), - name: text("name").notNull(), - name_en: text("name_en"), - short_description: text("short_description"), - description: text("description"), - brand: text("brand"), - subcategory: text("subcategory"), - sku: text("sku"), - category_id: integer("category_id").notNull().references(() => categoriesTable.id), - price: numeric("price", { precision: 10, scale: 2 }).notNull(), - original_price: numeric("original_price", { precision: 10, scale: 2 }), - images: jsonb("images").$type().default([]), - sizes: jsonb("sizes").$type().default([]), - colors: jsonb("colors").$type().default([]), - specs: jsonb("specs").$type>().default({}), - marketing_points: jsonb("marketing_points").$type().default([]), - variants: jsonb("variants").$type().default([]), - tags: jsonb("tags").$type().default([]), - stock: integer("stock").notNull().default(0), - rating: numeric("rating", { precision: 3, scale: 2 }).default("0"), - review_count: integer("review_count").default(0), - is_trending: boolean("is_trending").default(false), - is_bestseller: boolean("is_bestseller").default(false), - is_new: boolean("is_new").default(true), - is_top_rated: boolean("is_top_rated").default(false), - created_at: timestamp("created_at").defaultNow(), - updated_at: timestamp("updated_at").defaultNow(), -}); +export const productsTable = pgTable( + "products", + { + id: serial("id").primaryKey(), + source: text("source").notNull().default("extra"), + external_id: text("external_id"), + source_url: text("source_url"), + currency: text("currency").notNull().default("SAR"), + availability: text("availability").notNull().default("unknown"), + name: text("name").notNull(), + name_en: text("name_en"), + short_description: text("short_description"), + description: text("description"), + brand: text("brand"), + subcategory: text("subcategory"), + sku: text("sku"), + category_id: integer("category_id").notNull().references(() => categoriesTable.id), + price: numeric("price", { precision: 10, scale: 2 }).notNull(), + original_price: numeric("original_price", { precision: 10, scale: 2 }), + images: jsonb("images").$type().default([]), + sizes: jsonb("sizes").$type().default([]), + colors: jsonb("colors").$type().default([]), + specs: jsonb("specs").$type>().default({}), + marketing_points: jsonb("marketing_points").$type().default([]), + variants: jsonb("variants").$type().default([]), + tags: jsonb("tags").$type().default([]), + metadata: jsonb("metadata").$type>().default({}), + stock: integer("stock").notNull().default(0), + rating: numeric("rating", { precision: 3, scale: 2 }).default("0"), + review_count: integer("review_count").default(0), + is_trending: boolean("is_trending").default(false), + is_bestseller: boolean("is_bestseller").default(false), + is_new: boolean("is_new").default(true), + is_top_rated: boolean("is_top_rated").default(false), + last_synced_at: timestamp("last_synced_at").defaultNow(), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), + }, + (table) => ({ + sourceExternalIdUnique: uniqueIndex("products_source_external_id_uidx").on( + table.source, + table.external_id, + ), + sourceSkuIdx: index("products_source_sku_idx").on(table.source, table.sku), + categoryIdx: index("products_category_idx").on(table.category_id), + brandIdx: index("products_brand_idx").on(table.brand), + updatedAtIdx: index("products_updated_at_idx").on(table.updated_at), + }), +); export const insertProductSchema = createInsertSchema(productsTable).omit({ id: true, created_at: true, updated_at: true }); export type InsertProduct = z.infer; diff --git a/package.json b/package.json index 4a1e04d..4cde27a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "preinstall": "sh -c 'rm -f package-lock.json yarn.lock; case \"$npm_config_user_agent\" in pnpm/*) ;; *) echo \"Use pnpm instead\" >&2; exit 1 ;; esac'", "build": "pnpm run typecheck && pnpm -r --if-present run build", "typecheck:libs": "tsc --build", - "typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck" + "typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck", + "api:build": "pnpm --filter @workspace/api-server run build", + "api:typecheck": "pnpm --filter @workspace/api-server run typecheck", + "db:push": "pnpm --filter @workspace/db run push", + "deploy:flatlogic": "bash ./scripts/flatlogic-deploy.sh" }, "private": true, "devDependencies": { diff --git a/scripts/flatlogic-deploy.sh b/scripts/flatlogic-deploy.sh new file mode 100755 index 0000000..a763d69 --- /dev/null +++ b/scripts/flatlogic-deploy.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-$(pwd)}" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" +STORE_PORT="${STORE_PORT:-3001}" +API_PORT="${API_PORT:-8080}" + +cd "$PROJECT_DIR" + +echo "[deploy] Updating repository to origin/${DEPLOY_BRANCH}" +git fetch origin "$DEPLOY_BRANCH" +git checkout "$DEPLOY_BRANCH" +git reset --hard "origin/$DEPLOY_BRANCH" + +echo "[deploy] Ensuring pnpm is available" +corepack enable +corepack prepare pnpm@10.16.1 --activate + +echo "[deploy] Installing dependencies" +pnpm install --frozen-lockfile + +echo "[deploy] Running checks" +pnpm typecheck +pnpm build + +if [[ -n "${DATABASE_URL:-}" ]]; then + echo "[deploy] Applying Drizzle schema to external PostgreSQL/Supabase" + pnpm --filter @workspace/db run push +else + echo "[deploy] DATABASE_URL is missing; skipping DB schema push" +fi + +echo "[deploy] Restarting storefront" +if pm2 describe extra-store >/dev/null 2>&1; then + PORT="$STORE_PORT" pm2 restart extra-store --update-env +else + PORT="$STORE_PORT" pm2 start pnpm --name extra-store --interpreter bash -- -lc "pnpm --filter @workspace/extra-store run dev" +fi + +if [[ -n "${DATABASE_URL:-}" && -n "${ADMIN_TOKEN:-}" && -n "${API_INGEST_KEY:-}" ]]; then + echo "[deploy] Restarting API backend" + if pm2 describe flatlogic-api >/dev/null 2>&1; then + PORT="$API_PORT" NODE_ENV=production pm2 restart flatlogic-api --update-env + else + PORT="$API_PORT" NODE_ENV=production pm2 start pnpm --name flatlogic-api --interpreter bash -- -lc "pnpm --filter @workspace/api-server run start" + fi +else + echo "[deploy] API secrets are incomplete; skipping API process start" +fi + +echo "[deploy] Health checks" +curl -fsS "http://127.0.0.1:${STORE_PORT}/" >/dev/null +if pm2 describe flatlogic-api >/dev/null 2>&1; then + curl -fsS "http://127.0.0.1:${API_PORT}/api/healthz" >/dev/null +fi + +echo "[deploy] Done" diff --git a/scripts/post-merge.sh b/scripts/post-merge.sh old mode 100644 new mode 100755 index ab61c44..1ce1eb0 --- a/scripts/post-merge.sh +++ b/scripts/post-merge.sh @@ -1,4 +1,12 @@ #!/bin/bash -set -e +set -euo pipefail + +corepack enable +corepack prepare pnpm@10.16.1 --activate pnpm install --frozen-lockfile -pnpm --filter db push + +if [[ -n "${DATABASE_URL:-}" ]]; then + pnpm --filter @workspace/db run push +else + echo "DATABASE_URL is not set; skipping database push" +fi