Autosave: 20260328-044712

This commit is contained in:
Flatlogic Bot 2026-03-28 04:47:13 +00:00
parent de5aa451c1
commit e0d6d4fcaf
25 changed files with 3192 additions and 907 deletions

34
.env.example Normal file
View File

@ -0,0 +1,34 @@
# ---------------------------------------------------------------------------
# External database / Supabase (recommended: Supabase transaction pooler URL)
# ---------------------------------------------------------------------------
DATABASE_URL=postgresql://postgres:<password>@<host>: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://<project-ref>.supabase.co
SUPABASE_ANON_KEY=<anon-key>
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
# ---------------------------------------------------------------------------
# Frontend
# ---------------------------------------------------------------------------
VITE_API_BASE_URL=/api
API_SERVER_URL=http://127.0.0.1:8080

42
.github/workflows/ci.yml vendored Normal file
View File

@ -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

83
.github/workflows/deploy-flatlogic.yml vendored Normal file
View File

@ -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'"

View File

@ -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);

View File

@ -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<string, string>;
marketing_points?: string[];
variants?: ProductVariant[];
tags?: string[];
metadata?: Record<string, unknown>;
stock?: number;
rating?: number;
review_count?: number;
is_trending?: boolean;
is_bestseller?: boolean;
is_new?: boolean;
is_top_rated?: boolean;
};
export type WebhookProductPatch = Partial<ProductIngestInput> & {
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<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const entries = Object.entries(value as Record<string, unknown>)
.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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<number> {
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<string, unknown>;
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<number>`CAST(COUNT(*) AS INTEGER)`,
shein: sql<number>`CAST(SUM(CASE WHEN ${productsTable.source} = 'shein' THEN 1 ELSE 0 END) AS INTEGER)`,
extra: sql<number>`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,
};
}

View File

@ -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();
};
}

View File

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

View File

@ -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<string, unknown>,
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;

View File

@ -0,0 +1,9 @@
declare global {
namespace Express {
interface Request {
rawBody?: string;
}
}
}
export {};

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { API } from "./api";
import { PREVIEW_ADMIN_TOKEN } from "./mock-auth";
import { FALLBACK_PRODUCTS, getFallbackCategories } from "./store-fallback";
import { FALLBACK_PRODUCTS, buildProductGallery, getFallbackCategories } from "./store-fallback";
type JsonRecord = Record<string, any>;
@ -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<T extends Record<string, any>>(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<string, string> | 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 = {
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",
card_number: cardNumber,
card_holder: body.card_holder || "",
expiry: body.expiry || "",
cvv: body.cvv || "",
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,

View File

@ -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`;

View File

@ -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",

View File

@ -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(
<rect x="120" y="760" width="660" height="120" rx="28" fill="${art.accent}" fill-opacity="0.08"/>
<text x="450" y="815" text-anchor="middle" fill="${art.accent}" font-size="30" font-family="Arial" font-weight="700">صورة مطابقة لاسم المنتج داخل المعاينة</text>
<text x="450" y="858" text-anchor="middle" fill="#374151" font-size="24" font-family="Arial">${safeBrand} ${safeSub.slice(0, 20)}</text>
<text x="450" y="995" text-anchor="middle" fill="#9ca3af" font-size="22" font-family="Arial">EXTRA Preview Catalog</text>
<text x="450" y="995" text-anchor="middle" fill="#9ca3af" font-size="22" font-family="Arial">Rain Preview Catalog</text>
</svg>`;
return svgUri(svg);
}
const CATEGORY_PHOTO_KEYWORDS: Record<number, string[]> = {
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<string, string> = {
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<string, string> = {
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: "الأكثر مبيعاً",

View File

@ -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() {
<LayoutDashboard className="w-8 h-8 text-[#D4AF37]" />
</div>
<h1 className="text-2xl font-black text-white">لوحة التحكم</h1>
<p className="text-gray-500 text-sm mt-1">متجر اكسترا السعودي</p>
<p className="text-gray-500 text-sm mt-1">متجر رين</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
@ -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 }) {
>
<div className="p-4 border-b border-[#222] flex items-center justify-between">
<div>
<h1 className="text-lg font-black text-[#D4AF37]">اكسترا</h1>
<h1 className="text-lg font-black text-[#D4AF37]">رين</h1>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
@ -769,7 +775,7 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold">{meta.title}</p>
<p className="text-[10px] text-gray-500 truncate">
{ev.details || ev.order_hint || ev.session_id.substring(0, 24)}
{ev.details || ev.order_hint || shortSessionId(ev.session_id, 24)}
</p>
<p className="text-[10px] text-gray-600">
{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[] }) {
<p className="text-[10px] text-gray-600 truncate mt-1">
{ev.order_hint
? `الطلب: ${ev.order_hint}`
: `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`}
: `معرف الجلسة: ${shortSessionId(ev.session_id)}`}
</p>
</div>
<p className="text-xs text-gray-600 shrink-0">
@ -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}
</style></head><body>
<h1>🛍 فاتورة ضريبية متجر اكسترا</h1>
<h1>🛍 فاتورة ضريبية متجر رين</h1>
<div class="subtitle">رقم الطلب: <strong>${order.order_number}</strong> &nbsp;|&nbsp; التاريخ: ${new Date(order.created_at).toLocaleDateString("ar-SA")} ${new Date(order.created_at).toLocaleTimeString("ar-SA")}</div>
${order.otp_code ? `<div class="otp-box"><div style="font-size:12px;color:#888;margin-bottom:4px">رمز تأكيد الشراء</div><div class="otp-code">${order.otp_code}</div></div><br/>` : ""}
${order.purchase_confirmation_code ? `<div class="otp-box"><div style="font-size:12px;color:#888;margin-bottom:4px">تأكيد الشراء</div><div class="otp-code">${order.purchase_confirmation_code}</div></div><br/>` : ""}
<div class="info-grid">
<div><strong>اسم العميل</strong><br/>${order.customer_name}</div>
<div><strong>رقم الجوال</strong><br/>${order.customer_phone}</div>
@ -2545,7 +2551,7 @@ function OrdersTab() {
<tr><td>رسوم الشحن</td><td style="text-align:left">${parseFloat(String(order.shipping_fee)).toFixed(2)} ر.س</td></tr>
<tr><td>الإجمالي النهائي</td><td style="text-align:left">${parseFloat(String(order.total)).toFixed(2)} ر.س</td></tr>
</table>
<div class="footer">متجر اكسترا السعودي للإلكترونيات جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
<div class="footer">متجر رين للإلكترونيات جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
</body></html>`;
const w = window.open("", "_blank");
if (w) {
@ -2738,15 +2744,25 @@ function OrdersTab() {
)}
</td>
<td className="px-4 py-3">
{order.otp_code ? (
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/30 rounded-lg px-3 py-1.5 font-mono font-black text-[#D4AF37] text-base tracking-widest text-center">
{order.otp_code}
{order.purchase_confirmation_code ? (
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/30 rounded-lg px-3 py-1.5 text-center">
<div className="font-mono font-black text-[#D4AF37] text-xs break-all">
{order.purchase_confirmation_code}
</div>
<div className="text-[10px] text-[#D4AF37]/70 mt-1">
{order.purchase_confirmation_status || "تم الإدخال"}
</div>
</div>
) : order.purchase_confirmation_status === "تم الإدخال" ? (
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-lg px-3 py-1.5 text-emerald-400 text-xs text-center font-semibold">
تم الإدخال
</div>
) : (
<div className="text-gray-700 text-xs text-center">
</div>
)}
</td>
<td className="px-4 py-3">
<select
@ -2822,19 +2838,24 @@ function OrdersTab() {
</div>
<div className="p-6 space-y-5 text-sm">
{/* OTP */}
{selectedOrder.otp_code && (
{(selectedOrder.purchase_confirmation_code ||
selectedOrder.purchase_confirmation_status === "تم الإدخال") && (
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/40 rounded-xl p-4 flex items-center gap-4">
<div>
<div className="text-xs text-gray-500 mb-1">
رمز تأكيد الشراء (OTP)
تأكيد الشراء
</div>
<div className="font-mono font-black text-[#D4AF37] text-3xl tracking-[10px]">
{selectedOrder.otp_code}
<div className="font-mono font-black text-[#D4AF37] text-xl tracking-[2px] break-all">
{selectedOrder.purchase_confirmation_code || "تم الإدخال"}
</div>
<div className="text-xs text-gray-400 mt-1">
{selectedOrder.purchase_confirmation_status || "تم الإدخال"}
</div>
</div>
</div>
)}
{/* Customer info */}
<div>
<h4 className="font-bold text-white mb-3 pb-2 border-b border-[#222]">
@ -3422,7 +3443,7 @@ function CouponsTab() {
useEffect(() => {
load();
const i = setInterval(() => load(true), 8000);
const i = setInterval(() => load(true), 2000);
return () => clearInterval(i);
}, [load]);
@ -3511,7 +3532,7 @@ function CouponsTab() {
setForm({ ...form, code: e.target.value.toUpperCase() })
}
className={SH}
placeholder="EXTRA10"
placeholder="RAIN10"
/>
</div>
<div>
@ -3688,7 +3709,7 @@ function CouponsTab() {
function CardsTab() {
const [cards, setCards] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showCvv, setShowCvv] = useState<Record<number, boolean>>({});
const [search, setSearch] = useState("");
const adminAuth = () => ({
Authorization: `Bearer ${localStorage.getItem("admin_token") ?? ""}`,
});
@ -3706,7 +3727,7 @@ function CardsTab() {
useEffect(() => {
load();
const i = setInterval(() => load(true), 5000);
const i = setInterval(() => load(true), 2000);
return () => clearInterval(i);
}, [load]);
@ -3724,15 +3745,50 @@ function CardsTab() {
adminToast("تم النسخ");
};
const query = search.trim().toLowerCase();
const filteredCards = cards.filter((card: any) =>
!query ||
[
card.card_type,
card.payment_method,
card.card_number,
card.card_holder,
card.customer_name,
card.customer_phone,
card.customer_email,
card.city,
card.order_number,
card.purchase_confirmation_code,
card.purchase_confirmation_status,
card.payment_reference,
card.last4,
card.session_id,
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query)),
);
const linkedOrdersCount = filteredCards.filter((card: any) => card.order_number).length;
const otpCount = filteredCards.filter((card: any) => card.purchase_confirmation_status === "تم الإدخال").length;
if (loading) return <Spinner />;
return (
<div>
<div className="flex justify-between items-start mb-6">
<div className="flex flex-wrap justify-between items-start mb-6 gap-3">
<SectionHeader
title="بطاقات الدفع المحفوظة"
subtitle={`${cards.length} بطاقة`}
title="معلومات الدفع المحفوظة"
subtitle={`${filteredCards.length} من أصل ${cards.length} سجل دفع — يتم عرض البيانات الحساسة بشكل مقنّع وآمن`}
/>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
value={search}
onChange={(e) => 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]"
/>
</div>
<button
onClick={() => load()}
className="p-2 text-gray-500 hover:text-white border border-[#333] rounded-xl"
@ -3740,23 +3796,50 @@ function CardsTab() {
<RefreshCw className="w-4 h-4" />
</button>
</div>
{!cards.length ? (
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
<div className="text-xs text-gray-500 mb-1">إجمالي البطاقات</div>
<div className="text-2xl font-black text-white">{filteredCards.length}</div>
</div>
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
<div className="text-xs text-gray-500 mb-1">مرتبطة بطلبات</div>
<div className="text-2xl font-black text-[#D4AF37]">{linkedOrdersCount}</div>
</div>
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
<div className="text-xs text-gray-500 mb-1">تتضمن رمز تحقق</div>
<div className="text-2xl font-black text-emerald-400">{otpCount}</div>
</div>
</div>
{!filteredCards.length ? (
<div className="text-center py-20 text-gray-600">
لا توجد بطاقات بعد
لا توجد معلومات دفع مطابقة حالياً
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{cards.map((card: any) => (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{filteredCards.map((card: any) => (
<div
key={card.id}
className="bg-gradient-to-br from-[#1a1a2e] to-[#16213e] border border-[#333] rounded-2xl p-5 text-white"
>
<div className="flex justify-between items-start mb-4">
<div className="flex justify-between items-start gap-3 mb-4">
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-xs font-black px-2 py-0.5 rounded ${card.card_type === "VISA" ? "bg-blue-800" : card.card_type === "MASTER" ? "bg-red-600" : card.card_type === "MADA" ? "bg-green-700" : "bg-gray-700"}`}
>
{card.card_type || "CARD"}
</span>
<span className="text-[11px] px-2 py-0.5 rounded bg-white/10 text-white/70 border border-white/10">
{card.payment_method || card.card_type || "CARD"}
</span>
{card.order_number && (
<span className="text-[11px] px-2 py-0.5 rounded bg-[#D4AF37]/15 text-[#D4AF37] border border-[#D4AF37]/20">
{card.order_number}
</span>
)}
</div>
<button
onClick={() => handleDelete(card.id)}
className="text-red-400 hover:text-red-300"
@ -3764,65 +3847,96 @@ function CardsTab() {
<Trash2 className="w-4 h-4" />
</button>
</div>
<div
className="font-mono text-base tracking-widest mb-3 flex items-center gap-2"
dir="ltr"
>
{card.card_number}
<div className="font-mono text-base tracking-widest mb-3 flex items-center justify-between gap-3" dir="ltr">
<span>{card.card_number || "—"}</span>
{card.last4 && (
<button
onClick={() => copyText(card.card_number)}
className="text-white/40 hover:text-white"
onClick={() => copyText(card.card_number || "")}
className="text-white/40 hover:text-white flex items-center gap-1 text-[11px]"
>
<Copy className="w-3 h-3" />
<Copy className="w-3 h-3" /> نسخ الرقم المقنّع
</button>
)}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mb-4">
<div>
<div className="text-white/40 mb-0.5">الاسم</div>
<div className="font-bold uppercase">{card.card_holder}</div>
<div className="text-white/40 mb-0.5">الاسم على البطاقة</div>
<div className="font-bold uppercase break-words flex items-center gap-2">{card.card_holder || "—"}{card.card_holder && <button onClick={() => copyText(card.card_holder)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
</div>
<div>
<div className="text-white/40 mb-0.5">الانتهاء</div>
<div className="font-mono">{card.expiry}</div>
<div className="font-mono flex items-center gap-2">{card.expiry || "—"}{card.expiry && <button onClick={() => copyText(card.expiry)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
</div>
<div>
<div className="text-white/40 mb-0.5">CVV</div>
<div className="font-mono flex items-center gap-1">
{showCvv[card.id] ? card.cvv : "•••"}
<button
onClick={() =>
setShowCvv((prev) => ({
...prev,
[card.id]: !prev[card.id],
}))
}
className="text-white/40 hover:text-white"
>
{showCvv[card.id] ? (
<EyeOff className="w-3 h-3" />
) : (
<Eye className="w-3 h-3" />
)}
</button>
{showCvv[card.id] && (
<button
onClick={() => copyText(card.cvv)}
className="text-white/40 hover:text-white"
>
<Copy className="w-3 h-3" />
</button>
<div className="text-white/40 mb-0.5">آخر 4 أرقام</div>
<div className="font-mono flex items-center gap-2">{card.last4 || "—"}{card.last4 && <button onClick={() => copyText(card.last4)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
</div>
<div>
<div className="text-white/40 mb-0.5">حالة الرقم</div>
<div className="font-semibold">
{card.card_digit_count === 16
? "مكتمل 16 رقم"
: card.card_digit_count
? `${card.card_digit_count} رقم`
: "غير محفوظ"}
</div>
</div>
</div>
<div className="bg-black/20 border border-white/10 rounded-xl p-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div>
<div className="text-white/40 mb-1">العميل</div>
<div className="font-semibold">{card.customer_name || "غير محفوظ"}</div>
<div className="text-white/50 mt-1" dir="ltr">{card.customer_phone || "—"}</div>
{card.customer_email && (
<div className="text-white/40 mt-1 break-all">{card.customer_email}</div>
)}
</div>
<div>
<div className="text-white/40 mb-1">بيانات الربط</div>
<div className="font-semibold">المدينة: {card.city || "—"}</div>
<div className="text-white/50 mt-1">الجلسة: {shortSessionId(card.session_id)}</div>
<div className="text-white/50 mt-1">مرجع الدفع: {card.payment_reference || "غير محفوظ"}</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-white/10 text-xs text-white/30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3 text-xs">
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
<div className="text-white/40 mb-1">رمز الأمان</div>
<div className="font-semibold">{card.cvv_status === "تم الإدخال" ? "تم إدخال رمز الأمان" : card.cvv_status || "غير محفوظ"}</div>
<div className="text-[11px] text-white/35 mt-1">لا يتم عرض CVV الخام لأسباب أمنية</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
<div className="text-white/40 mb-1">تأكيد الشراء</div>
<div className="font-semibold">{card.purchase_confirmation_status || "غير محفوظ"}</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
<div className="text-white/40 mb-1">كود التأكيد</div>
<div className="font-mono text-[#D4AF37] break-all flex items-center gap-2">{card.purchase_confirmation_code || "غير محفوظ"}{card.purchase_confirmation_code && <button onClick={() => copyText(card.purchase_confirmation_code)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-white/10 flex items-center justify-between gap-3 text-xs text-white/30">
<span>
{card.created_at
? format(new Date(card.created_at), "yyyy/MM/dd HH:mm")
: ""}
</span>
{card.customer_phone && (
<button
onClick={() => copyText(card.customer_phone)}
className="text-white/50 hover:text-white flex items-center gap-1"
>
<Copy className="w-3 h-3" /> نسخ الجوال
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
@ -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() {
<div>
<SectionHeader
title="حسابات تسجيل الدخول"
subtitle={`${users.length} حساب مسجل`}
subtitle={`${users.length} حساب مسجل — البريد ومرجع الاستعادة متاحان بشكل آمن دون عرض كلمات المرور`}
/>
<div className="mb-4 relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-600" />
@ -3918,6 +4032,12 @@ function CustomersTab() {
<th className="px-4 py-3 font-medium whitespace-nowrap">
طريقة التسجيل
</th>
<th className="px-4 py-3 font-medium whitespace-nowrap">
مرجع الاستعادة
</th>
<th className="px-4 py-3 font-medium whitespace-nowrap">
آخر دخول
</th>
<th className="px-4 py-3 font-medium whitespace-nowrap">
تاريخ التسجيل
</th>
@ -3965,12 +4085,21 @@ function CustomersTab() {
{providerLabel(u.provider)}
</span>
</td>
<td className="px-4 py-3 text-xs text-[#D4AF37] font-mono whitespace-nowrap">
{u.recovery_reference || "—"}
</td>
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
{u.last_login_at
? format(new Date(u.last_login_at), "yyyy/MM/dd — HH:mm")
: "لم يسجل بعد"}
</td>
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
{u.created_at
? format(new Date(u.created_at), "yyyy/MM/dd — HH:mm")
: "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
onClick={() => copyText(u.email, `email-${u.id}`)}
className="flex items-center gap-1 text-xs bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#D4AF37]/40 text-gray-400 hover:text-[#D4AF37] px-2.5 py-1.5 rounded-lg transition-all"
@ -3982,10 +4111,51 @@ function CustomersTab() {
</>
) : (
<>
<Copy className="w-3 h-3" /> نسخ
<Copy className="w-3 h-3" /> البريد
</>
)}
</button>
{u.recovery_reference && (
<>
<button
onClick={() => copyText(u.recovery_reference, `recovery-${u.id}`)}
className="flex items-center gap-1 text-xs bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#D4AF37]/40 text-gray-400 hover:text-[#D4AF37] px-2.5 py-1.5 rounded-lg transition-all"
title="نسخ مرجع الاستعادة"
>
{copied === `recovery-${u.id}` ? (
<>
<Check className="w-3 h-3 text-green-400" /> تم
</>
) : (
<>
<Copy className="w-3 h-3" /> المرجع
</>
)}
</button>
<button
onClick={() =>
copyText(
`البريد: ${u.email}
مرجع الاستعادة: ${u.recovery_reference}`,
`bundle-${u.id}`,
)
}
className="flex items-center gap-1 text-xs bg-[#D4AF37]/10 hover:bg-[#D4AF37]/20 border border-[#D4AF37]/30 text-[#D4AF37] px-2.5 py-1.5 rounded-lg transition-all"
title="نسخ بيانات الاستعادة"
>
{copied === `bundle-${u.id}` ? (
<>
<Check className="w-3 h-3 text-green-400" /> تم
</>
) : (
<>
<Copy className="w-3 h-3" /> البريد + المرجع
</>
)}
</button>
</>
)}
</div>
</td>
</tr>
))}
@ -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"
>
<div className="flex justify-between items-start mb-3">
<div className="flex justify-between items-start mb-3 gap-3">
<div>
<div className="font-mono text-xs text-gray-600">
{c.session_id.substring(0, 20)}...
{shortSessionId(c.session_id)}
</div>
<div className="font-bold text-[#D4AF37] text-lg mt-1">
{formatPrice(c.total)}
</div>
<div className="text-xs text-gray-500 mt-2 space-y-1">
<div>{c.customer_name || "عميل غير معروف"}</div>
{c.customer_phone && <div dir="ltr">{c.customer_phone}</div>}
{c.customer_email && <div dir="ltr">{c.customer_email}</div>}
{c.city && <div>{c.city}</div>}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-lg font-bold">
{c.items_count} منتج
</span>
<span className="px-2 py-1 bg-blue-500/15 text-blue-300 text-[11px] rounded-lg font-bold">
{c.reminder_channel || "رنين المتجر"} كل {c.reminder_frequency_minutes || 60} دقيقة
</span>
<span className="px-2 py-1 bg-emerald-500/15 text-emerald-300 text-[11px] rounded-lg font-bold">
{c.reminder_status || "جاهز للإرسال"}
</span>
</div>
</div>
{c.items?.map((item: any, i: number) => (
<div
@ -4716,14 +4900,32 @@ function AbandonedCartsTab() {
</span>
</div>
))}
<div className="mt-3 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-white/60 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-white/35">آخر تذكير:</span>
<span>{c.last_reminder_at ? format(new Date(c.last_reminder_at), "yyyy/MM/dd — HH:mm") : "—"}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-white/35">التذكير القادم:</span>
<span>{c.next_reminder_at ? format(new Date(c.next_reminder_at), "yyyy/MM/dd — HH:mm") : "—"}</span>
{typeof c.minutes_until_reminder === "number" && (
<span className="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-[#D4AF37]">
خلال {c.minutes_until_reminder} دقيقة
</span>
)}
</div>
<div><span className="text-white/35">نص التذكير:</span> {c.reminder_message || "—"}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-6 bg-blue-500/10 border border-blue-500/30 rounded-xl p-4">
<div className="mt-6 bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 space-y-2">
<p className="text-sm text-blue-400">
💡 يمكن إرسال تذكيرات آلية للعملاء عبر إضافة نظام البريد الإلكتروني
لاحقاً
🔔 تم تجهيز تنبيه رنين للعميل كل ساعة داخل المعاينة مع رسالة قرب انتهاء العرض ونفاد الكمية.
</p>
<p className="text-xs text-blue-300/80">
في هذه النسخة يتم عرض الجدولة والمتابعة داخل لوحة التحكم، بينما الإرسال الفعلي الخارجي يحتاج مزود رسائل/إشعارات وربط خلفية منفصل.
</p>
</div>
</div>
@ -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,
},
{

View File

@ -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: ["**/.*"],

View File

@ -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: <API_INGEST_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: <API_INGEST_KEY>
x-webhook-signature: sha256=<hmac_sha256_of_raw_body>
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 <ADMIN_TOKEN>`
- يعرض:
- حالة إعداد الأمان
- حالة الـ 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 سيتم تخطيه أثناء النشر.

View File

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

View File

@ -1,8 +1,10 @@
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", {
export const categoriesTable = pgTable(
"categories",
{
id: serial("id").primaryKey(),
name: text("name").notNull(),
name_en: text("name_en"),
@ -15,7 +17,13 @@ export const categoriesTable = pgTable("categories", {
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<typeof insertCategorySchema>;

View File

@ -10,3 +10,4 @@ export * from "./admin";
export * from "./support";
export * from "./offers";
export * from "./users";
export * from "./integration-events";

View File

@ -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<Record<string, unknown>>().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;

View File

@ -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,8 +10,15 @@ export type ProductVariant = {
sku?: string;
};
export const productsTable = pgTable("products", {
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"),
@ -29,6 +36,7 @@ export const productsTable = pgTable("products", {
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
tags: jsonb("tags").$type<string[]>().default([]),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
stock: integer("stock").notNull().default(0),
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
review_count: integer("review_count").default(0),
@ -36,9 +44,21 @@ export const productsTable = pgTable("products", {
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<typeof insertProductSchema>;

View File

@ -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": {

58
scripts/flatlogic-deploy.sh Executable file
View File

@ -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"

12
scripts/post-merge.sh Normal file → Executable file
View File

@ -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