Autosave: 20260328-044712
This commit is contained in:
parent
de5aa451c1
commit
e0d6d4fcaf
34
.env.example
Normal file
34
.env.example
Normal 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
42
.github/workflows/ci.yml
vendored
Normal 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
83
.github/workflows/deploy-flatlogic.yml
vendored
Normal 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'"
|
||||||
@ -6,6 +6,12 @@ import { logger } from "./lib/logger";
|
|||||||
|
|
||||||
const app: Express = express();
|
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(
|
app.use(
|
||||||
pinoHttp({
|
pinoHttp({
|
||||||
logger,
|
logger,
|
||||||
@ -26,8 +32,8 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json({ verify: captureRawBody }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, verify: captureRawBody }));
|
||||||
|
|
||||||
app.use("/api", router);
|
app.use("/api", router);
|
||||||
|
|
||||||
|
|||||||
423
artifacts/api-server/src/lib/ingest.ts
Normal file
423
artifacts/api-server/src/lib/ingest.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
artifacts/api-server/src/middleware/api-key.ts
Normal file
67
artifacts/api-server/src/middleware/api-key.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import checkoutEventsRouter from "./checkout-events";
|
|||||||
import storeSettingsRouter from "./store-settings";
|
import storeSettingsRouter from "./store-settings";
|
||||||
import imageProxyRouter from "./image-proxy";
|
import imageProxyRouter from "./image-proxy";
|
||||||
import integrationsRouter from "./integrations";
|
import integrationsRouter from "./integrations";
|
||||||
|
import ingestRouter from "./ingest";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -34,5 +35,6 @@ router.use(analyticsRouter);
|
|||||||
router.use(storeSettingsRouter);
|
router.use(storeSettingsRouter);
|
||||||
router.use(imageProxyRouter);
|
router.use(imageProxyRouter);
|
||||||
router.use(integrationsRouter);
|
router.use(integrationsRouter);
|
||||||
|
router.use(ingestRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
193
artifacts/api-server/src/routes/ingest.ts
Normal file
193
artifacts/api-server/src/routes/ingest.ts
Normal 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;
|
||||||
9
artifacts/api-server/src/types/express.d.ts
vendored
Normal file
9
artifacts/api-server/src/types/express.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
rawBody?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
import { API } from "./api";
|
import { API } from "./api";
|
||||||
import { PREVIEW_ADMIN_TOKEN } from "./mock-auth";
|
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>;
|
type JsonRecord = Record<string, any>;
|
||||||
|
|
||||||
@ -46,6 +46,61 @@ function buildOrderNumber(id: number) {
|
|||||||
return `EX-${now.getUTCFullYear()}${String(id).padStart(4, "0")}`;
|
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) {
|
function parseBoolean(value: any) {
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
if (typeof value === "string") return value === "true";
|
if (typeof value === "string") return value === "true";
|
||||||
@ -82,44 +137,45 @@ function getBaseApiPath() {
|
|||||||
|
|
||||||
function seedStoreSettings() {
|
function seedStoreSettings() {
|
||||||
return {
|
return {
|
||||||
store_name_ar: "اكسترا السعودية",
|
store_name_ar: "رين",
|
||||||
store_name_en: "Extra Saudi",
|
store_name_en: "Rain",
|
||||||
store_tagline_ar:
|
store_tagline_ar:
|
||||||
"متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.",
|
"متجر رين لتجربة تسوق سعودية أنيقة بواجهة فاخرة ولوحة تحكم سريعة ومباشرة.",
|
||||||
store_tagline_en:
|
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:
|
footer_tagline_ar:
|
||||||
"متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.",
|
"متجر رين — تسوق أنيق للإلكترونيات والجمال والمنزل مع دعم مباشر.",
|
||||||
top_bar_offer_ar:
|
top_bar_offer_ar:
|
||||||
"شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة",
|
"شحن مجاني للطلبات فوق 200 ر.س + عروض يومية مختارة من متجر رين",
|
||||||
top_bar_offer_en:
|
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_ar: "ابحث عن الجوالات، الأجهزة، الأزياء...",
|
||||||
header_search_placeholder_en: "Search mobiles, electronics, fashion...",
|
header_search_placeholder_en: "Search mobiles, electronics, fashion...",
|
||||||
menu_strip_label_ar: "القوائم",
|
menu_strip_label_ar: "القوائم",
|
||||||
menu_strip_label_en: "Store Menus",
|
menu_strip_label_en: "Store Menus",
|
||||||
footer_address_ar: "الرياض، المملكة العربية السعودية",
|
footer_address_ar: "الرياض، المملكة العربية السعودية",
|
||||||
footer_address_en: "Riyadh, Saudi Arabia",
|
footer_address_en: "Riyadh, Saudi Arabia",
|
||||||
footer_contact_phone: "920003117",
|
footer_contact_phone: "920000742",
|
||||||
footer_copyright_ar: "© 2026 اكسترا السعودية — جميع الحقوق محفوظة",
|
support_email: "support@rain.sa",
|
||||||
footer_copyright_en: "© 2026 Extra Saudi — All rights reserved",
|
footer_copyright_ar: "© 2025 متجر رين — جميع الحقوق محفوظة",
|
||||||
|
footer_copyright_en: "© 2025 Rain Store — All rights reserved",
|
||||||
primary_color: "#D4AF37",
|
primary_color: "#D4AF37",
|
||||||
logo: "EXTRA",
|
logo: "RAIN",
|
||||||
store_logo_url: "https://picsum.photos/seed/extra-logo/320/120",
|
store_logo_url: "https://loremflickr.com/320/120/luxury-logo,gold?lock=410",
|
||||||
store_icon: "https://picsum.photos/seed/extra-icon/128/128",
|
store_icon: "https://loremflickr.com/128/128/luxury-logo,monogram?lock=411",
|
||||||
announcement_enabled: "true",
|
announcement_enabled: "true",
|
||||||
announcement_text:
|
announcement_text:
|
||||||
"شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة",
|
"شحن مجاني فوق 200 ر.س + عروض رين المختارة للإلكترونيات والجمال والمنزل",
|
||||||
announcement_color: "#D4AF37",
|
announcement_color: "#D4AF37",
|
||||||
announcement_text_color: "#111111",
|
announcement_text_color: "#111111",
|
||||||
hero_enabled: "true",
|
hero_enabled: "true",
|
||||||
hero_title_ar: "تجربة تسوق سعودية حديثة بواجهة أنيقة وإدارة كاملة",
|
hero_title_ar: "رين — متجر أنيق بتجربة شراء سعودية فاخرة",
|
||||||
hero_subtitle_ar:
|
hero_subtitle_ar:
|
||||||
"اعرض الأقسام والمنتجات والعروض وخصّص الصفحة الرئيسية وطرق الدفع والتوصيل من مكان واحد.",
|
"واجهة راقية مع صور مطابقة للمنتجات، دفع مريح، تتبع حي، ولوحة مسؤول مرتبة بالأحدث أولاً.",
|
||||||
hero_badge_ar: "لوحة تحكم جاهزة للمعاينة",
|
hero_badge_ar: "تجربة رين المباشرة",
|
||||||
hero_cta_ar: "تصفح أحدث العروض",
|
hero_cta_ar: "ابدأ التسوق الآن",
|
||||||
hero_cta_link: "/",
|
hero_cta_link: "/",
|
||||||
hero_bg_image: "https://picsum.photos/seed/extra-hero/1600/900",
|
hero_bg_image: DEFAULT_HERO_IMAGE,
|
||||||
hero_accent_color: "#D4AF37",
|
hero_accent_color: "#D4AF37",
|
||||||
section_trending_enabled: "true",
|
section_trending_enabled: "true",
|
||||||
section_trending_title_ar: "الأكثر رواجاً",
|
section_trending_title_ar: "الأكثر رواجاً",
|
||||||
@ -131,11 +187,12 @@ function seedStoreSettings() {
|
|||||||
section_bestseller_title_ar: "الأكثر مبيعاً",
|
section_bestseller_title_ar: "الأكثر مبيعاً",
|
||||||
section_bestseller_icon: "🏆",
|
section_bestseller_icon: "🏆",
|
||||||
shein_section_enabled: "true",
|
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_enabled: "true",
|
||||||
extra_section_title_ar: "مختارات اكسترا",
|
extra_section_title_ar: "مختارات رين",
|
||||||
cart_banner_enabled: "true",
|
cart_banner_enabled: "true",
|
||||||
cart_banner_text: "الدفع آمن — التوصيل داخل السعودية خلال 2 إلى 4 أيام عمل",
|
cart_banner_text: "الدفع محمي بالكامل — شحن مجاني فوق 200 ر.س داخل السعودية",
|
||||||
cart_banner_color: "#1f2937",
|
cart_banner_color: "#1f2937",
|
||||||
cart_banner_text_color: "#ffffff",
|
cart_banner_text_color: "#ffffff",
|
||||||
cart_page_title_ar: "سلة التسوق",
|
cart_page_title_ar: "سلة التسوق",
|
||||||
@ -148,11 +205,11 @@ function seedStoreSettings() {
|
|||||||
cart_checkout_button_en: "Proceed to Checkout",
|
cart_checkout_button_en: "Proceed to Checkout",
|
||||||
cart_secure_label_ar: "دفع مشفر وآمن 100%",
|
cart_secure_label_ar: "دفع مشفر وآمن 100%",
|
||||||
cart_secure_label_en: "100% encrypted secure checkout",
|
cart_secure_label_en: "100% encrypted secure checkout",
|
||||||
cart_checkout_note: "يرجى التأكد من رقم الجوال والعنوان لتسريع الشحن.",
|
cart_checkout_note: "تأكد من رقم الجوال والبريد والعنوان لتجربة أسرع وفاتورة تلقائية بعد إتمام الطلب.",
|
||||||
checkout_page_title_ar: "إتمام الطلب",
|
checkout_page_title_ar: "إتمام الطلب في رين",
|
||||||
checkout_page_title_en: "Checkout",
|
checkout_page_title_en: "Checkout",
|
||||||
checkout_page_subtitle_ar:
|
checkout_page_subtitle_ar:
|
||||||
"أكمل بيانات التوصيل والدفع ثم رمز التحقق لتأكيد الطلب.",
|
"أدخل بيانات التوصيل، راجع ملخص الدفع، ثم أكّد العملية للحصول على فاتورة تلقائية.",
|
||||||
checkout_page_subtitle_en:
|
checkout_page_subtitle_en:
|
||||||
"Complete delivery, payment, and verification to confirm the order.",
|
"Complete delivery, payment, and verification to confirm the order.",
|
||||||
checkout_step_delivery_ar: "التوصيل",
|
checkout_step_delivery_ar: "التوصيل",
|
||||||
@ -164,21 +221,21 @@ function seedStoreSettings() {
|
|||||||
delivery_saved_badge_ar: "عنوان محفوظ",
|
delivery_saved_badge_ar: "عنوان محفوظ",
|
||||||
delivery_saved_badge_en: "Saved Address",
|
delivery_saved_badge_en: "Saved Address",
|
||||||
delivery_peak_warning_ar:
|
delivery_peak_warning_ar:
|
||||||
"قد تتأثر مواعيد التسليم خلال أوقات الذروة والمواسم.",
|
"سيتم تحديث حالة التوصيل مباشرة بعد تأكيد الطلب دون إظهار مدة توصيل ثابتة داخل المدينة.",
|
||||||
delivery_peak_warning_en: "Delivery windows may vary during peak seasons.",
|
delivery_peak_warning_en: "Delivery windows may vary during peak seasons.",
|
||||||
delivery_continue_button_ar: "المتابعة إلى الدفع",
|
delivery_continue_button_ar: "المتابعة إلى الدفع",
|
||||||
delivery_continue_button_en: "Continue to Payment",
|
delivery_continue_button_en: "Continue to Payment",
|
||||||
payment_section_title_ar: "معلومات الدفع",
|
payment_section_title_ar: "محفظة الدفع الآمنة",
|
||||||
payment_section_title_en: "Payment Information",
|
payment_section_title_en: "Payment Information",
|
||||||
payment_section_subtitle_ar:
|
payment_section_subtitle_ar:
|
||||||
"يمكنك تخصيص النصوص الظاهرة في هذه الصفحة من لوحة المسؤول.",
|
"واجهة دفع أنيقة وواضحة مع مراجعة فورية للمبلغ وتفاصيل البطاقة بشكل منظم.",
|
||||||
payment_section_subtitle_en:
|
payment_section_subtitle_en:
|
||||||
"You can customize the text shown on this page from admin.",
|
"You can customize the text shown on this page from admin.",
|
||||||
payment_submit_button_ar: "ادفع الآن",
|
payment_submit_button_ar: "ادفع الآن",
|
||||||
payment_submit_button_en: "Pay Now",
|
payment_submit_button_en: "Pay Now",
|
||||||
verification_section_title_ar: "التحقق من العملية",
|
verification_section_title_ar: "تأكيد العملية",
|
||||||
verification_section_title_en: "Verify Purchase",
|
verification_section_title_en: "Verify Purchase",
|
||||||
verification_section_subtitle_ar: "أدخل رمز التحقق المرسل لإتمام الطلب.",
|
verification_section_subtitle_ar: "أدخل رمز التحقق لإكمال الطلب وإصدار الفاتورة تلقائياً.",
|
||||||
verification_section_subtitle_en:
|
verification_section_subtitle_en:
|
||||||
"Enter the verification code sent to complete your order.",
|
"Enter the verification code sent to complete your order.",
|
||||||
verification_hint_ar: "يمكن إدخال 4 أو 6 أرقام حسب بوابة الدفع.",
|
verification_hint_ar: "يمكن إدخال 4 أو 6 أرقام حسب بوابة الدفع.",
|
||||||
@ -189,51 +246,24 @@ function seedStoreSettings() {
|
|||||||
verification_processing_msg_ar: "نراجع بيانات الدفع ورمز التحقق الآن...",
|
verification_processing_msg_ar: "نراجع بيانات الدفع ورمز التحقق الآن...",
|
||||||
verification_processing_msg_en:
|
verification_processing_msg_en:
|
||||||
"We are validating your payment and verification code...",
|
"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_title_en: "Order Confirmed Successfully",
|
||||||
verification_success_msg_ar:
|
verification_success_msg_ar:
|
||||||
"تم استلام طلبك وسيتم تحديث حالته من لوحة المسؤول.",
|
"تم إنشاء الطلب والفاتورة تلقائياً، وسيظهر كل شيء داخل لوحة المسؤول مباشرة.",
|
||||||
verification_success_msg_en:
|
verification_success_msg_en:
|
||||||
"Your order has been received and will appear in admin.",
|
"Your order has been received and will appear in admin.",
|
||||||
cart_delivery_fee_riyadh: "19",
|
cart_delivery_fee_riyadh: "19",
|
||||||
cart_delivery_fee_other: "29",
|
cart_delivery_fee_other: "29",
|
||||||
cart_free_shipping_riyadh: "299",
|
cart_free_shipping_riyadh: "200",
|
||||||
cart_free_shipping_other: "399",
|
cart_free_shipping_other: "200",
|
||||||
cart_min_order: "50",
|
cart_min_order: "0",
|
||||||
cart_max_qty: "8",
|
cart_max_qty: "8",
|
||||||
cart_payment_mada: "true",
|
cart_payment_mada: "true",
|
||||||
cart_payment_visa: "true",
|
cart_payment_visa: "true",
|
||||||
cart_payment_applepay: "true",
|
cart_payment_applepay: "true",
|
||||||
cart_payment_stcpay: "true",
|
cart_payment_stcpay: "true",
|
||||||
promo_banners: JSON.stringify([
|
promo_banners: JSON.stringify(DEFAULT_PROMO_BANNERS),
|
||||||
{
|
delivery_conditions: JSON.stringify(DEFAULT_DELIVERY_CONDITIONS),
|
||||||
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,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,7 +388,7 @@ function seedOrders(products: any[]) {
|
|||||||
order_number: `EX-${2026000 + index + 1}`,
|
order_number: `EX-${2026000 + index + 1}`,
|
||||||
customer_name: template.name,
|
customer_name: template.name,
|
||||||
customer_phone: template.phone,
|
customer_phone: template.phone,
|
||||||
customer_email: `customer${index + 1}@extra.sa`,
|
customer_email: `customer${index + 1}@rain.sa`,
|
||||||
city: template.city,
|
city: template.city,
|
||||||
neighborhood: index % 2 === 0 ? "الملقا" : "الروضة",
|
neighborhood: index % 2 === 0 ? "الملقا" : "الروضة",
|
||||||
street: `شارع ${index + 5}`,
|
street: `شارع ${index + 5}`,
|
||||||
@ -387,37 +417,45 @@ function seedUsers() {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "العميل التجريبي",
|
name: "العميل التجريبي",
|
||||||
email: "demo@extra.sa",
|
email: "demo@rain.sa",
|
||||||
password: "Extra123",
|
password: "Extra123",
|
||||||
provider: "email",
|
provider: "email",
|
||||||
remember_me: true,
|
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(),
|
created_at: new Date(now.getTime() - 15 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "سارة محمد",
|
name: "سارة محمد",
|
||||||
email: "sara@extra.sa",
|
email: "sara@rain.sa",
|
||||||
password: "Sara1234",
|
password: "Sara1234",
|
||||||
provider: "google",
|
provider: "google",
|
||||||
remember_me: false,
|
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(),
|
created_at: new Date(now.getTime() - 10 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: "خالد علي",
|
name: "خالد علي",
|
||||||
email: "khaled@extra.sa",
|
email: "khaled@rain.sa",
|
||||||
password: "Khaled123",
|
password: "Khaled123",
|
||||||
provider: "apple",
|
provider: "apple",
|
||||||
remember_me: true,
|
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(),
|
created_at: new Date(now.getTime() - 6 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
name: "ريم عبدالله",
|
name: "ريم عبدالله",
|
||||||
email: "reem@extra.sa",
|
email: "reem@rain.sa",
|
||||||
password: "Reem1234",
|
password: "Reem1234",
|
||||||
provider: "email",
|
provider: "email",
|
||||||
remember_me: false,
|
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(),
|
created_at: new Date(now.getTime() - 2 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -446,6 +484,25 @@ function seedCustomers(orders: any[]) {
|
|||||||
return [...map.values()];
|
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[]) {
|
function seedReviews(products: any[]) {
|
||||||
return products.slice(0, 12).map((product, index) => ({
|
return products.slice(0, 12).map((product, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@ -508,24 +565,46 @@ function seedCoupons() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedCards() {
|
function seedCards(orders: any[]) {
|
||||||
|
const primaryOrder = orders[0];
|
||||||
|
const secondaryOrder = orders[1];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
session_id: primaryOrder?.session_id || "seed-card-1",
|
||||||
card_type: "VISA",
|
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",
|
card_holder: "SARA A",
|
||||||
expiry: "08/29",
|
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(),
|
created_at: new Date(now.getTime() - 3 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
session_id: secondaryOrder?.session_id || "seed-card-2",
|
||||||
card_type: "MADA",
|
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",
|
card_holder: "KHALED M",
|
||||||
expiry: "01/28",
|
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(),
|
created_at: new Date(now.getTime() - 9 * 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -595,8 +674,19 @@ function seedAbandonedCarts(products: any[]) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
session_id: "cart-session-riyadh-001",
|
session_id: "cart-session-riyadh-001",
|
||||||
|
customer_name: "سارة الغامدي",
|
||||||
|
customer_phone: "0501234567",
|
||||||
|
customer_email: "sara@rain.sa",
|
||||||
|
city: "الرياض",
|
||||||
total: 648,
|
total: 648,
|
||||||
items_count: 2,
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
name: products[1]?.name || "منتج 1",
|
name: products[1]?.name || "منتج 1",
|
||||||
@ -612,8 +702,19 @@ function seedAbandonedCarts(products: any[]) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
session_id: "cart-session-jeddah-002",
|
session_id: "cart-session-jeddah-002",
|
||||||
|
customer_name: "خالد محمد",
|
||||||
|
customer_phone: "0500000002",
|
||||||
|
customer_email: "khaled@rain.sa",
|
||||||
|
city: "جدة",
|
||||||
total: 419,
|
total: 419,
|
||||||
items_count: 1,
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
name: products[7]?.name || "منتج 3",
|
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 {
|
function seedDb(): PreviewDb {
|
||||||
const categories = seedCategories();
|
const categories = seedCategories();
|
||||||
const products = seedProducts(categories);
|
const products = seedProducts(categories);
|
||||||
@ -715,7 +1118,7 @@ function seedDb(): PreviewDb {
|
|||||||
orders,
|
orders,
|
||||||
reviews: seedReviews(products),
|
reviews: seedReviews(products),
|
||||||
coupons: seedCoupons(),
|
coupons: seedCoupons(),
|
||||||
savedCards: seedCards(),
|
savedCards: seedCards(orders),
|
||||||
customers,
|
customers,
|
||||||
users: seedUsers(),
|
users: seedUsers(),
|
||||||
supportTickets: seedSupportTickets(),
|
supportTickets: seedSupportTickets(),
|
||||||
@ -738,11 +1141,11 @@ function seedDb(): PreviewDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readDb(): PreviewDb {
|
function readDb(): PreviewDb {
|
||||||
if (typeof localStorage === "undefined") return seedDb();
|
if (typeof localStorage === "undefined") return normalizeDb(seedDb());
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem(DB_KEY) || "null");
|
const parsed = JSON.parse(localStorage.getItem(DB_KEY) || "null");
|
||||||
if (!parsed || typeof parsed !== "object") throw new Error("missing-db");
|
if (!parsed || typeof parsed !== "object") throw new Error("missing-db");
|
||||||
return {
|
const normalized = normalizeDb({
|
||||||
...seedDb(),
|
...seedDb(),
|
||||||
...parsed,
|
...parsed,
|
||||||
nextIds: { ...seedDb().nextIds, ...(parsed.nextIds || {}) },
|
nextIds: { ...seedDb().nextIds, ...(parsed.nextIds || {}) },
|
||||||
@ -750,9 +1153,11 @@ function readDb(): PreviewDb {
|
|||||||
...seedStoreSettings(),
|
...seedStoreSettings(),
|
||||||
...(parsed.storeSettings || {}),
|
...(parsed.storeSettings || {}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
localStorage.setItem(DB_KEY, JSON.stringify(normalized));
|
||||||
|
return normalized;
|
||||||
} catch {
|
} catch {
|
||||||
const db = seedDb();
|
const db = normalizeDb(seedDb());
|
||||||
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
@ -760,9 +1165,10 @@ function readDb(): PreviewDb {
|
|||||||
|
|
||||||
function writeDb(db: PreviewDb) {
|
function writeDb(db: PreviewDb) {
|
||||||
if (typeof localStorage === "undefined") return;
|
if (typeof localStorage === "undefined") return;
|
||||||
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
localStorage.setItem(DB_KEY, JSON.stringify(normalizeDb(db)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getIdFromPath(pathname: string) {
|
function getIdFromPath(pathname: string) {
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const last = parts[parts.length - 1];
|
const last = parts[parts.length - 1];
|
||||||
@ -930,6 +1336,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
return json(db.storeSettings);
|
return json(db.storeSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === "/categories/tree" && method === "GET") {
|
||||||
|
return json(buildCategoryTree(db.categories));
|
||||||
|
}
|
||||||
|
|
||||||
if (path === "/auth/register" && method === "POST") {
|
if (path === "/auth/register" && method === "POST") {
|
||||||
const email = normalizeEmail(body.email);
|
const email = normalizeEmail(body.email);
|
||||||
const password = String(body.password || "");
|
const password = String(body.password || "");
|
||||||
@ -951,6 +1361,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
password,
|
password,
|
||||||
provider: "email",
|
provider: "email",
|
||||||
remember_me: parseBoolean(body.remember_me),
|
remember_me: parseBoolean(body.remember_me),
|
||||||
|
recovery_reference: buildRecoveryReference(id, email),
|
||||||
|
last_login_at: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
db.users.unshift(user);
|
db.users.unshift(user);
|
||||||
@ -979,6 +1391,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
if (!user)
|
if (!user)
|
||||||
return json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }, 401);
|
return json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }, 401);
|
||||||
user.remember_me = parseBoolean(body.remember_me);
|
user.remember_me = parseBoolean(body.remember_me);
|
||||||
|
user.last_login_at = new Date().toISOString();
|
||||||
pushActivity(db, {
|
pushActivity(db, {
|
||||||
session_id: `login-${user.id}`,
|
session_id: `login-${user.id}`,
|
||||||
event_type: "auth_login",
|
event_type: "auth_login",
|
||||||
@ -1018,10 +1431,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
const city = String(body.city || "الرياض");
|
const city = String(body.city || "الرياض");
|
||||||
const isRiyadh = city === "الرياض";
|
const isRiyadh = city === "الرياض";
|
||||||
const freeShipRiyadh = Number(
|
const freeShipRiyadh = Number(
|
||||||
db.storeSettings.cart_free_shipping_riyadh || 299,
|
db.storeSettings.cart_free_shipping_riyadh || 200,
|
||||||
);
|
);
|
||||||
const freeShipOther = Number(
|
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 feeRiyadh = Number(db.storeSettings.cart_delivery_fee_riyadh || 19);
|
||||||
const feeOther = Number(db.storeSettings.cart_delivery_fee_other || 29);
|
const feeOther = Number(db.storeSettings.cart_delivery_fee_other || 29);
|
||||||
@ -1033,10 +1446,12 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
? 0
|
? 0
|
||||||
: feeOther;
|
: feeOther;
|
||||||
const total = subtotal + shipping_fee;
|
const total = subtotal + shipping_fee;
|
||||||
|
const otpProvided = Boolean(String(body.otp_code || "").replace(/\D/g, ""));
|
||||||
|
const orderNumber = buildOrderNumber(id);
|
||||||
const order = {
|
const order = {
|
||||||
id,
|
id,
|
||||||
session_id: body.session_id || `sess-${id}`,
|
session_id: body.session_id || `sess-${id}`,
|
||||||
order_number: buildOrderNumber(id),
|
order_number: orderNumber,
|
||||||
customer_name: body.customer_name || "عميل جديد",
|
customer_name: body.customer_name || "عميل جديد",
|
||||||
customer_phone: body.customer_phone || "",
|
customer_phone: body.customer_phone || "",
|
||||||
customer_email: body.customer_email || "",
|
customer_email: body.customer_email || "",
|
||||||
@ -1047,7 +1462,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
building: body.building || "",
|
building: body.building || "",
|
||||||
floor: body.floor || "",
|
floor: body.floor || "",
|
||||||
payment_method: body.payment_method || "CARD",
|
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,
|
items,
|
||||||
notes: body.notes || "",
|
notes: body.notes || "",
|
||||||
subtotal,
|
subtotal,
|
||||||
@ -1058,8 +1479,31 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
db.orders.unshift(order);
|
db.orders.unshift(order);
|
||||||
db.customers = seedCustomers(db.orders);
|
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, {
|
pushActivity(db, {
|
||||||
session_id: order.session_id,
|
session_id: order.session_id,
|
||||||
event_type: "order_created",
|
event_type: "order_created",
|
||||||
@ -1068,13 +1512,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
details: `${order.customer_name || "عميل"} أنشأ الطلب ${order.order_number} بقيمة ${total} ر.س.`,
|
details: `${order.customer_name || "عميل"} أنشأ الطلب ${order.order_number} بقيمة ${total} ر.س.`,
|
||||||
order_hint: order.order_number,
|
order_hint: order.order_number,
|
||||||
});
|
});
|
||||||
if (order.otp_code) {
|
if (order.otp_provided) {
|
||||||
pushActivity(db, {
|
pushActivity(db, {
|
||||||
session_id: order.session_id,
|
session_id: order.session_id,
|
||||||
event_type: "otp_submitted",
|
event_type: "otp_submitted",
|
||||||
emoji: "✅",
|
emoji: "🔐",
|
||||||
title: "تم حفظ بيانات التحقق",
|
title: "تم تأكيد الشراء",
|
||||||
details: `تم تسجيل رمز التحقق للطلب ${order.order_number}.`,
|
details: `تم تسجيل خطوة التأكيد للطلب ${order.order_number}.`,
|
||||||
order_hint: 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")
|
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") {
|
if (path === "/payments/saved" && method === "POST") {
|
||||||
const id = db.nextIds.cards++;
|
const id = db.nextIds.cards++;
|
||||||
const cardNumber = String(body.card_number || "");
|
const cardDigits = normalizeCardDigits(body.card_number || "");
|
||||||
const card = {
|
const last4 = cardDigits.slice(-4);
|
||||||
|
const card = normalizeSavedCard(
|
||||||
|
{
|
||||||
id,
|
id,
|
||||||
session_id: body.session_id || `sess-card-${id}`,
|
session_id: body.session_id || `sess-card-${id}`,
|
||||||
card_type: body.card_type || "CARD",
|
card_type: body.card_type || "CARD",
|
||||||
card_number: cardNumber,
|
payment_method: body.payment_method || body.card_type || "CARD",
|
||||||
card_holder: body.card_holder || "",
|
card_number: formatMaskedCardNumber(cardDigits, last4),
|
||||||
expiry: body.expiry || "",
|
last4,
|
||||||
cvv: body.cvv || "",
|
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(),
|
created_at: new Date().toISOString(),
|
||||||
};
|
},
|
||||||
|
db.orders,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
db.savedCards.unshift(card);
|
db.savedCards.unshift(card);
|
||||||
pushActivity(db, {
|
pushActivity(db, {
|
||||||
session_id: card.session_id,
|
session_id: card.session_id,
|
||||||
event_type: "payment_card_saved",
|
event_type: "payment_card_saved",
|
||||||
emoji: "💳",
|
emoji: "💳",
|
||||||
title: "تم حفظ بيانات الدفع",
|
title: "تم حفظ بيانات الدفع",
|
||||||
details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${cardNumber.slice(-4) || "----"}.`,
|
details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${last4 || "----"}.`,
|
||||||
});
|
});
|
||||||
writeDb(db);
|
writeDb(db);
|
||||||
return json(card, 201);
|
return json(card, 201);
|
||||||
@ -1353,8 +1814,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/admin/customers" && method === "GET")
|
if (path === "/admin/customers" && method === "GET")
|
||||||
return json(db.customers);
|
return json(sortByDateDesc(db.customers, "last_order_at"));
|
||||||
if (path === "/admin/users" && method === "GET") return json(db.users);
|
if (path === "/admin/users" && method === "GET") return json(sortByDateDesc(db.users, "created_at").map(sanitizeAdminUser));
|
||||||
if (path === "/admin/analytics" && method === "GET")
|
if (path === "/admin/analytics" && method === "GET")
|
||||||
return json(deriveAnalytics(db));
|
return json(deriveAnalytics(db));
|
||||||
|
|
||||||
@ -1429,7 +1890,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/admin/abandoned-carts" && method === "GET")
|
if (path === "/admin/abandoned-carts" && method === "GET")
|
||||||
return json(db.abandonedCarts);
|
return json(sortByDateDesc(db.abandonedCarts, "updated_at"));
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
preview: true,
|
preview: true,
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
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`;
|
||||||
|
|||||||
@ -3,8 +3,8 @@ export type Lang = "ar" | "en";
|
|||||||
export const translations = {
|
export const translations = {
|
||||||
ar: {
|
ar: {
|
||||||
// Store
|
// Store
|
||||||
store_name: "اكسترا",
|
store_name: "رين",
|
||||||
store_tagline: "الوجهة الأولى للإلكترونيات والأجهزة المنزلية في المملكة العربية السعودية.",
|
store_tagline: "متجر رين لتجربة تسوق سعودية أنيقة تجمع الإلكترونيات، الجمال، المنزل والعروض اليومية.",
|
||||||
// Header
|
// Header
|
||||||
search_placeholder: "ابحث عن منتجات...",
|
search_placeholder: "ابحث عن منتجات...",
|
||||||
top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة",
|
top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة",
|
||||||
@ -15,7 +15,7 @@ export const translations = {
|
|||||||
user_cart: "سلتي",
|
user_cart: "سلتي",
|
||||||
user_logout: "تسجيل الخروج",
|
user_logout: "تسجيل الخروج",
|
||||||
user_guest: "مستخدم",
|
user_guest: "مستخدم",
|
||||||
user_member: "عضو اكسترا",
|
user_member: "عضو رين",
|
||||||
// Auth
|
// Auth
|
||||||
auth_login_tab: "تسجيل الدخول",
|
auth_login_tab: "تسجيل الدخول",
|
||||||
auth_register_tab: "إنشاء حساب",
|
auth_register_tab: "إنشاء حساب",
|
||||||
@ -46,7 +46,7 @@ export const translations = {
|
|||||||
server_error: "تعذر الاتصال بالخادم",
|
server_error: "تعذر الاتصال بالخادم",
|
||||||
// Home sections
|
// Home sections
|
||||||
section_view_all: "عرض الكل ←",
|
section_view_all: "عرض الكل ←",
|
||||||
section_extra_title: "اكسترا — إلكترونيات وأجهزة",
|
section_extra_title: "رين — إلكترونيات مختارة",
|
||||||
section_shein_sub: "Fashion, Beauty & Home",
|
section_shein_sub: "Fashion, Beauty & Home",
|
||||||
// Product card
|
// Product card
|
||||||
product_new: "جديد",
|
product_new: "جديد",
|
||||||
@ -168,10 +168,10 @@ export const translations = {
|
|||||||
verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع",
|
verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع",
|
||||||
payment_success: "✅ تم الدفع بنجاح!",
|
payment_success: "✅ تم الدفع بنجاح!",
|
||||||
payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...",
|
payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...",
|
||||||
ssl_badge: "مدفوعاتك آمنة بتشفير TLS ومعايير PCI DSS المعتمدة من مؤسسة النقد العربي السعودي 🔒",
|
ssl_badge: "مدفوعاتك آمنة 100% بتشفير TLS وبنية دفع محمية على مدار الساعة 🔒",
|
||||||
delivery_days_3: "توصيل 3 أيام عمل",
|
delivery_days_3: "توصيل سريع داخل المملكة",
|
||||||
delivery_days_5: "توصيل 5 أيام عمل",
|
delivery_days_5: "توصيل قياسي داخل المملكة",
|
||||||
delivery_days_7: "توصيل 7 أيام عمل",
|
delivery_days_7: "توصيل إلى جميع المناطق",
|
||||||
// Profile
|
// Profile
|
||||||
profile_login_first: "سجّل دخولك أولاً",
|
profile_login_first: "سجّل دخولك أولاً",
|
||||||
profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك",
|
profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك",
|
||||||
@ -195,7 +195,7 @@ export const translations = {
|
|||||||
footer_warranty: "الضمان",
|
footer_warranty: "الضمان",
|
||||||
footer_contact: "تواصل معنا",
|
footer_contact: "تواصل معنا",
|
||||||
footer_address: "الرياض، المملكة العربية السعودية",
|
footer_address: "الرياض، المملكة العربية السعودية",
|
||||||
footer_copyright: "© 2025 اكسترا السعودية. جميع الحقوق محفوظة.",
|
footer_copyright: "© 2025 متجر رين. جميع الحقوق محفوظة.",
|
||||||
// 404
|
// 404
|
||||||
page_not_found: "الصفحة غير موجودة",
|
page_not_found: "الصفحة غير موجودة",
|
||||||
not_found: "الصفحة غير موجودة",
|
not_found: "الصفحة غير موجودة",
|
||||||
@ -242,7 +242,7 @@ export const translations = {
|
|||||||
login: "تسجيل الدخول",
|
login: "تسجيل الدخول",
|
||||||
logout: "تسجيل الخروج",
|
logout: "تسجيل الخروج",
|
||||||
user_default: "مستخدم",
|
user_default: "مستخدم",
|
||||||
extra_member: "عضو اكسترا",
|
extra_member: "عضو رين",
|
||||||
my_orders: "طلباتي",
|
my_orders: "طلباتي",
|
||||||
my_orders_sub: "تتبع وإدارة طلباتك",
|
my_orders_sub: "تتبع وإدارة طلباتك",
|
||||||
wishlist: "قائمة الأمنيات",
|
wishlist: "قائمة الأمنيات",
|
||||||
@ -255,8 +255,8 @@ export const translations = {
|
|||||||
|
|
||||||
en: {
|
en: {
|
||||||
// Store
|
// Store
|
||||||
store_name: "eXtra",
|
store_name: "Rain",
|
||||||
store_tagline: "Saudi Arabia's #1 destination for electronics and home appliances.",
|
store_tagline: "Rain Store for a premium Saudi shopping experience across electronics, beauty, home, and daily deals.",
|
||||||
// Header
|
// Header
|
||||||
search_placeholder: "Search products...",
|
search_placeholder: "Search products...",
|
||||||
top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items",
|
top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items",
|
||||||
@ -267,7 +267,7 @@ export const translations = {
|
|||||||
user_cart: "My Cart",
|
user_cart: "My Cart",
|
||||||
user_logout: "Sign Out",
|
user_logout: "Sign Out",
|
||||||
user_guest: "User",
|
user_guest: "User",
|
||||||
user_member: "eXtra Member",
|
user_member: "Rain Member",
|
||||||
// Auth
|
// Auth
|
||||||
auth_login_tab: "Sign In",
|
auth_login_tab: "Sign In",
|
||||||
auth_register_tab: "Create Account",
|
auth_register_tab: "Create Account",
|
||||||
@ -298,7 +298,7 @@ export const translations = {
|
|||||||
server_error: "Could not connect to server",
|
server_error: "Could not connect to server",
|
||||||
// Home sections
|
// Home sections
|
||||||
section_view_all: "View All →",
|
section_view_all: "View All →",
|
||||||
section_extra_title: "eXtra — Electronics & Appliances",
|
section_extra_title: "Rain — Featured Electronics",
|
||||||
section_shein_sub: "Fashion, Beauty & Home",
|
section_shein_sub: "Fashion, Beauty & Home",
|
||||||
// Product card
|
// Product card
|
||||||
product_new: "NEW",
|
product_new: "NEW",
|
||||||
@ -420,7 +420,7 @@ export const translations = {
|
|||||||
verifying_sub: "Please wait while we verify your payment",
|
verifying_sub: "Please wait while we verify your payment",
|
||||||
payment_success: "✅ Payment Successful!",
|
payment_success: "✅ Payment Successful!",
|
||||||
payment_success_sub: "Thank you! Redirecting to home page...",
|
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_3: "3 business days delivery",
|
||||||
delivery_days_5: "5 business days delivery",
|
delivery_days_5: "5 business days delivery",
|
||||||
delivery_days_7: "7 business days delivery",
|
delivery_days_7: "7 business days delivery",
|
||||||
@ -447,7 +447,7 @@ export const translations = {
|
|||||||
footer_warranty: "Warranty",
|
footer_warranty: "Warranty",
|
||||||
footer_contact: "Contact Us",
|
footer_contact: "Contact Us",
|
||||||
footer_address: "Riyadh, Kingdom of Saudi Arabia",
|
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
|
// 404
|
||||||
page_not_found: "Page Not Found",
|
page_not_found: "Page Not Found",
|
||||||
not_found: "Page Not Found",
|
not_found: "Page Not Found",
|
||||||
@ -494,7 +494,7 @@ export const translations = {
|
|||||||
login: "Sign In",
|
login: "Sign In",
|
||||||
logout: "Sign Out",
|
logout: "Sign Out",
|
||||||
user_default: "User",
|
user_default: "User",
|
||||||
extra_member: "eXtra Member",
|
extra_member: "Rain Member",
|
||||||
my_orders: "My Orders",
|
my_orders: "My Orders",
|
||||||
my_orders_sub: "Track and manage your orders",
|
my_orders_sub: "Track and manage your orders",
|
||||||
wishlist: "Wishlist",
|
wishlist: "Wishlist",
|
||||||
|
|||||||
@ -223,7 +223,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
|||||||
source: "shein",
|
source: "shein",
|
||||||
slug: "new-in",
|
slug: "new-in",
|
||||||
shein_url: null,
|
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: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 30101,
|
id: 30101,
|
||||||
@ -261,7 +261,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
|||||||
source: "shein",
|
source: "shein",
|
||||||
slug: "women-fashion",
|
slug: "women-fashion",
|
||||||
shein_url: null,
|
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: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 30201,
|
id: 30201,
|
||||||
@ -299,7 +299,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
|||||||
source: "shein",
|
source: "shein",
|
||||||
slug: "beauty",
|
slug: "beauty",
|
||||||
shein_url: null,
|
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: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 30301,
|
id: 30301,
|
||||||
@ -337,7 +337,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
|||||||
source: "shein",
|
source: "shein",
|
||||||
slug: "sale",
|
slug: "sale",
|
||||||
shein_url: null,
|
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: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 30401,
|
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"/>
|
<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="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="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>`;
|
</svg>`;
|
||||||
return svgUri(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 colorPalette = ["أسود", "أبيض", "رمادي", "ذهبي", "وردي", "كحلي"];
|
||||||
const sizePalette = ["S", "M", "L", "XL", "مقاس حر"];
|
const sizePalette = ["S", "M", "L", "XL", "مقاس حر"];
|
||||||
|
|
||||||
@ -791,7 +955,7 @@ export const FALLBACK_PRODUCTS: FallbackProduct[] = PRODUCT_BLUEPRINTS.map(
|
|||||||
brand: item.brand,
|
brand: item.brand,
|
||||||
price: String(price),
|
price: String(price),
|
||||||
original_price: String(original),
|
original_price: String(original),
|
||||||
images: [1, 2, 3].map((imgIndex) => productArt(item, imgIndex)),
|
images: buildProductGallery(item, id),
|
||||||
colors: [
|
colors: [
|
||||||
color,
|
color,
|
||||||
colorPalette[(index + 2) % colorPalette.length],
|
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> = {
|
export const FALLBACK_STORE_SETTINGS: Record<string, string> = {
|
||||||
announcement_enabled: "true",
|
announcement_enabled: "true",
|
||||||
announcement_text:
|
announcement_text:
|
||||||
"⚡ تم استكمال عرض فئات المتجر والقوائم الأساسية داخل المعاينة",
|
"✨ رين — شحن مجاني للطلبات فوق 200 ر.س وعروض يومية مختارة",
|
||||||
announcement_text_en:
|
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_color: "#f97316",
|
||||||
announcement_text_color: "#ffffff",
|
announcement_text_color: "#ffffff",
|
||||||
hero_enabled: "true",
|
hero_enabled: "true",
|
||||||
hero_badge_ar: "⚡ متجر سعودي شامل — 22 فئة رئيسية",
|
hero_badge_ar: "✨ رين — تجربة تسوق أنيقة داخل السعودية",
|
||||||
hero_badge_en: "⚡ Saudi Store — 22 Main Categories",
|
hero_badge_en: "✨ Rain — Elegant Saudi Shopping Experience",
|
||||||
hero_title_ar: "كل فئات المتجر\nفي مكان واحد",
|
hero_title_ar: "كل فئات المتجر\nفي مكان واحد",
|
||||||
hero_title_en: "All store categories\nin one place",
|
hero_title_en: "All store categories\nin one place",
|
||||||
hero_subtitle_ar:
|
hero_subtitle_ar:
|
||||||
"أضفنا الفئات الرئيسية والقوائم الأساسية كما في الملف، لتظهر بوضوح في الصفحة الرئيسية والتذييل والتنقل العلوي.",
|
"واجهة فاخرة تجمع الإلكترونيات والجمال والمنزل مع عروض واضحة وصور منتجات مطابقة لأسمائها وتجربة دفع مريحة.",
|
||||||
hero_subtitle_en:
|
hero_subtitle_en:
|
||||||
"Main categories and key menus from the file are now visible across the homepage, header, and footer.",
|
"A premium storefront for electronics, beauty, and home with curated imagery and a smoother checkout experience.",
|
||||||
hero_cta_ar: "تصفح الفئات",
|
hero_cta_ar: "ابدأ التسوق",
|
||||||
hero_cta_en: "Browse Categories",
|
hero_cta_en: "Start Shopping",
|
||||||
hero_cta_link: "/category/0",
|
hero_cta_link: "/category/0",
|
||||||
hero_accent_color: "#f97316",
|
hero_accent_color: "#D4AF37",
|
||||||
extra_section_enabled: "true",
|
extra_section_enabled: "true",
|
||||||
extra_section_title_ar: "فئات المنتجات الرئيسية",
|
extra_section_title_ar: "فئات المنتجات الرئيسية",
|
||||||
extra_section_title_en: "Main Product Categories",
|
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_ar: "قوائم الأزياء والجمال",
|
||||||
shein_section_title_en: "Fashion & Beauty Menus",
|
shein_section_title_en: "Fashion & Beauty Menus",
|
||||||
section_trending_enabled: "true",
|
section_trending_enabled: "true",
|
||||||
section_trending_title_ar: "الأكثر رواجاً في السعودية",
|
section_trending_title_ar: "الأكثر طلباً في رين",
|
||||||
section_trending_title_en: "Trending in Saudi Arabia",
|
section_trending_title_en: "Trending in Saudi Arabia",
|
||||||
section_bestseller_enabled: "true",
|
section_bestseller_enabled: "true",
|
||||||
section_bestseller_title_ar: "الأكثر مبيعاً",
|
section_bestseller_title_ar: "الأكثر مبيعاً",
|
||||||
|
|||||||
@ -78,6 +78,12 @@ function formatPrice(v: number | string) {
|
|||||||
return `${n.toLocaleString("ar-SA", { maximumFractionDigits: 2 })} ر.س`;
|
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 ──────────────────────────────────────────────
|
// ─── Sound ──────────────────────────────────────────────
|
||||||
// Builds a WAV PCM blob in-memory: three-note bell chord (C5→E5→G5)
|
// Builds a WAV PCM blob in-memory: three-note bell chord (C5→E5→G5)
|
||||||
function buildBellWav(): string {
|
function buildBellWav(): string {
|
||||||
@ -412,7 +418,7 @@ export default function AdminPage() {
|
|||||||
<LayoutDashboard className="w-8 h-8 text-[#D4AF37]" />
|
<LayoutDashboard className="w-8 h-8 text-[#D4AF37]" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-black text-white">لوحة التحكم</h1>
|
<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>
|
</div>
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -516,7 +522,7 @@ function getNotifMeta(ev: CheckoutNotif) {
|
|||||||
ev.details ||
|
ev.details ||
|
||||||
(ev.order_hint
|
(ev.order_hint
|
||||||
? `الطلب المرتبط: ${ev.order_hint}`
|
? `الطلب المرتبط: ${ev.order_hint}`
|
||||||
: `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`),
|
: `معرف الجلسة: ${shortSessionId(ev.session_id)}`),
|
||||||
tone,
|
tone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -613,8 +619,8 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pollOrders();
|
pollOrders();
|
||||||
pollEvents();
|
pollEvents();
|
||||||
const i1 = setInterval(pollOrders, 8000);
|
const i1 = setInterval(pollOrders, 2000);
|
||||||
const i2 = setInterval(pollEvents, 5000);
|
const i2 = setInterval(pollEvents, 2000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(i1);
|
clearInterval(i1);
|
||||||
clearInterval(i2);
|
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 className="p-4 border-b border-[#222] flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<span className="relative flex h-2 w-2">
|
<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" />
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-semibold">{meta.title}</p>
|
<p className="text-xs font-semibold">{meta.title}</p>
|
||||||
<p className="text-[10px] text-gray-500 truncate">
|
<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>
|
||||||
<p className="text-[10px] text-gray-600">
|
<p className="text-[10px] text-gray-600">
|
||||||
{new Date(ev.created_at).toLocaleTimeString("ar-SA")}
|
{new Date(ev.created_at).toLocaleTimeString("ar-SA")}
|
||||||
@ -882,7 +888,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const interval = setInterval(load, 5000);
|
const interval = setInterval(load, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -981,7 +987,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) {
|
|||||||
<p className="text-[10px] text-gray-600 truncate mt-1">
|
<p className="text-[10px] text-gray-600 truncate mt-1">
|
||||||
{ev.order_hint
|
{ev.order_hint
|
||||||
? `الطلب: ${ev.order_hint}`
|
? `الطلب: ${ev.order_hint}`
|
||||||
: `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`}
|
: `معرف الجلسة: ${shortSessionId(ev.session_id)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 shrink-0">
|
<p className="text-xs text-gray-600 shrink-0">
|
||||||
@ -2480,7 +2486,7 @@ function OrdersTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 5000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -2526,9 +2532,9 @@ function OrdersTab() {
|
|||||||
.totals tr:last-child{background:#D4AF3722;font-weight:bold;font-size:16px}
|
.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}
|
.footer{margin-top:40px;font-size:11px;color:#999;border-top:1px solid #eee;padding-top:10px;text-align:center}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<h1>🛍️ فاتورة ضريبية — متجر اكسترا</h1>
|
<h1>🛍️ فاتورة ضريبية — متجر رين</h1>
|
||||||
<div class="subtitle">رقم الطلب: <strong>${order.order_number}</strong> | التاريخ: ${new Date(order.created_at).toLocaleDateString("ar-SA")} ${new Date(order.created_at).toLocaleTimeString("ar-SA")}</div>
|
<div class="subtitle">رقم الطلب: <strong>${order.order_number}</strong> | التاريخ: ${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 class="info-grid">
|
||||||
<div><strong>اسم العميل</strong><br/>${order.customer_name}</div>
|
<div><strong>اسم العميل</strong><br/>${order.customer_name}</div>
|
||||||
<div><strong>رقم الجوال</strong><br/>${order.customer_phone}</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.shipping_fee)).toFixed(2)} ر.س</td></tr>
|
||||||
<tr><td>الإجمالي النهائي</td><td style="text-align:left">${parseFloat(String(order.total)).toFixed(2)} ر.س</td></tr>
|
<tr><td>الإجمالي النهائي</td><td style="text-align:left">${parseFloat(String(order.total)).toFixed(2)} ر.س</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<div class="footer">متجر اكسترا السعودي للإلكترونيات — جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
|
<div class="footer">متجر رين للإلكترونيات — جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
const w = window.open("", "_blank");
|
const w = window.open("", "_blank");
|
||||||
if (w) {
|
if (w) {
|
||||||
@ -2738,15 +2744,25 @@ function OrdersTab() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{order.otp_code ? (
|
{order.purchase_confirmation_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">
|
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/30 rounded-lg px-3 py-1.5 text-center">
|
||||||
{order.otp_code}
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-700 text-xs text-center">
|
<div className="text-gray-700 text-xs text-center">
|
||||||
—
|
—
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select
|
<select
|
||||||
@ -2822,19 +2838,24 @@ function OrdersTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-5 text-sm">
|
<div className="p-6 space-y-5 text-sm">
|
||||||
{/* OTP */}
|
{/* 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 className="bg-[#D4AF37]/10 border border-[#D4AF37]/40 rounded-xl p-4 flex items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 mb-1">
|
<div className="text-xs text-gray-500 mb-1">
|
||||||
رمز تأكيد الشراء (OTP)
|
تأكيد الشراء
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono font-black text-[#D4AF37] text-3xl tracking-[10px]">
|
<div className="font-mono font-black text-[#D4AF37] text-xl tracking-[2px] break-all">
|
||||||
{selectedOrder.otp_code}
|
{selectedOrder.purchase_confirmation_code || "تم الإدخال"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{selectedOrder.purchase_confirmation_status || "تم الإدخال"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Customer info */}
|
{/* Customer info */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-white mb-3 pb-2 border-b border-[#222]">
|
<h4 className="font-bold text-white mb-3 pb-2 border-b border-[#222]">
|
||||||
@ -3422,7 +3443,7 @@ function CouponsTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -3511,7 +3532,7 @@ function CouponsTab() {
|
|||||||
setForm({ ...form, code: e.target.value.toUpperCase() })
|
setForm({ ...form, code: e.target.value.toUpperCase() })
|
||||||
}
|
}
|
||||||
className={SH}
|
className={SH}
|
||||||
placeholder="EXTRA10"
|
placeholder="RAIN10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -3688,7 +3709,7 @@ function CouponsTab() {
|
|||||||
function CardsTab() {
|
function CardsTab() {
|
||||||
const [cards, setCards] = useState<any[]>([]);
|
const [cards, setCards] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCvv, setShowCvv] = useState<Record<number, boolean>>({});
|
const [search, setSearch] = useState("");
|
||||||
const adminAuth = () => ({
|
const adminAuth = () => ({
|
||||||
Authorization: `Bearer ${localStorage.getItem("admin_token") ?? ""}`,
|
Authorization: `Bearer ${localStorage.getItem("admin_token") ?? ""}`,
|
||||||
});
|
});
|
||||||
@ -3706,7 +3727,7 @@ function CardsTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 5000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -3724,15 +3745,50 @@ function CardsTab() {
|
|||||||
adminToast("تم النسخ");
|
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 />;
|
if (loading) return <Spinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex flex-wrap justify-between items-start mb-6 gap-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="بطاقات الدفع المحفوظة"
|
title="معلومات الدفع المحفوظة"
|
||||||
subtitle={`${cards.length} بطاقة`}
|
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
|
<button
|
||||||
onClick={() => load()}
|
onClick={() => load()}
|
||||||
className="p-2 text-gray-500 hover:text-white border border-[#333] rounded-xl"
|
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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="text-center py-20 text-gray-600">
|
||||||
لا توجد بطاقات بعد
|
لا توجد معلومات دفع مطابقة حالياً
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{cards.map((card: any) => (
|
{filteredCards.map((card: any) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className="bg-gradient-to-br from-[#1a1a2e] to-[#16213e] border border-[#333] rounded-2xl p-5 text-white"
|
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
|
<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"}`}
|
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"}
|
{card.card_type || "CARD"}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={() => handleDelete(card.id)}
|
onClick={() => handleDelete(card.id)}
|
||||||
className="text-red-400 hover:text-red-300"
|
className="text-red-400 hover:text-red-300"
|
||||||
@ -3764,65 +3847,96 @@ function CardsTab() {
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="font-mono text-base tracking-widest mb-3 flex items-center gap-2"
|
<div className="font-mono text-base tracking-widest mb-3 flex items-center justify-between gap-3" dir="ltr">
|
||||||
dir="ltr"
|
<span>{card.card_number || "—"}</span>
|
||||||
>
|
{card.last4 && (
|
||||||
{card.card_number}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => copyText(card.card_number)}
|
onClick={() => copyText(card.card_number || "")}
|
||||||
className="text-white/40 hover:text-white"
|
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>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
<div className="text-white/40 mb-0.5">الاسم</div>
|
<div className="text-white/40 mb-0.5">الاسم على البطاقة</div>
|
||||||
<div className="font-bold uppercase">{card.card_holder}</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>
|
<div>
|
||||||
<div className="text-white/40 mb-0.5">الانتهاء</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>
|
<div>
|
||||||
<div className="text-white/40 mb-0.5">CVV</div>
|
<div className="text-white/40 mb-0.5">آخر 4 أرقام</div>
|
||||||
<div className="font-mono flex items-center gap-1">
|
<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>
|
||||||
{showCvv[card.id] ? card.cvv : "•••"}
|
</div>
|
||||||
<button
|
<div>
|
||||||
onClick={() =>
|
<div className="text-white/40 mb-0.5">حالة الرقم</div>
|
||||||
setShowCvv((prev) => ({
|
<div className="font-semibold">
|
||||||
...prev,
|
{card.card_digit_count === 16
|
||||||
[card.id]: !prev[card.id],
|
? "مكتمل 16 رقم"
|
||||||
}))
|
: card.card_digit_count
|
||||||
}
|
? `${card.card_digit_count} رقم`
|
||||||
className="text-white/40 hover:text-white"
|
: "غير محفوظ"}
|
||||||
>
|
</div>
|
||||||
{showCvv[card.id] ? (
|
</div>
|
||||||
<EyeOff className="w-3 h-3" />
|
</div>
|
||||||
) : (
|
|
||||||
<Eye className="w-3 h-3" />
|
<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>
|
||||||
</button>
|
<div className="text-white/40 mb-1">العميل</div>
|
||||||
{showCvv[card.id] && (
|
<div className="font-semibold">{card.customer_name || "غير محفوظ"}</div>
|
||||||
<button
|
<div className="text-white/50 mt-1" dir="ltr">{card.customer_phone || "—"}</div>
|
||||||
onClick={() => copyText(card.cvv)}
|
{card.customer_email && (
|
||||||
className="text-white/40 hover:text-white"
|
<div className="text-white/40 mt-1 break-all">{card.customer_email}</div>
|
||||||
>
|
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</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
|
{card.created_at
|
||||||
? format(new Date(card.created_at), "yyyy/MM/dd HH:mm")
|
? 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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -3858,7 +3972,7 @@ function CustomersTab() {
|
|||||||
if (!silent) setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -3892,7 +4006,7 @@ function CustomersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="حسابات تسجيل الدخول"
|
title="حسابات تسجيل الدخول"
|
||||||
subtitle={`${users.length} حساب مسجل`}
|
subtitle={`${users.length} حساب مسجل — البريد ومرجع الاستعادة متاحان بشكل آمن دون عرض كلمات المرور`}
|
||||||
/>
|
/>
|
||||||
<div className="mb-4 relative">
|
<div className="mb-4 relative">
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-600" />
|
<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 className="px-4 py-3 font-medium whitespace-nowrap">
|
||||||
طريقة التسجيل
|
طريقة التسجيل
|
||||||
</th>
|
</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 className="px-4 py-3 font-medium whitespace-nowrap">
|
||||||
تاريخ التسجيل
|
تاريخ التسجيل
|
||||||
</th>
|
</th>
|
||||||
@ -3965,12 +4085,21 @@ function CustomersTab() {
|
|||||||
{providerLabel(u.provider)}
|
{providerLabel(u.provider)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||||
{u.created_at
|
{u.created_at
|
||||||
? format(new Date(u.created_at), "yyyy/MM/dd — HH:mm")
|
? format(new Date(u.created_at), "yyyy/MM/dd — HH:mm")
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => copyText(u.email, `email-${u.id}`)}
|
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"
|
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>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -4068,7 +4238,7 @@ function AnalyticsTab() {
|
|||||||
if (!silent) setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 10000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -4212,7 +4382,7 @@ function SupportTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -4470,7 +4640,7 @@ function OffersTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -4670,7 +4840,7 @@ function AbandonedCartsTab() {
|
|||||||
if (!silent) setLoading(false);
|
if (!silent) setLoading(false);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -4690,18 +4860,32 @@ function AbandonedCartsTab() {
|
|||||||
key={c.session_id}
|
key={c.session_id}
|
||||||
className="bg-[#111] border border-[#222] rounded-xl p-4"
|
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>
|
||||||
<div className="font-mono text-xs text-gray-600">
|
<div className="font-mono text-xs text-gray-600">
|
||||||
{c.session_id.substring(0, 20)}...
|
{shortSessionId(c.session_id)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-bold text-[#D4AF37] text-lg mt-1">
|
<div className="font-bold text-[#D4AF37] text-lg mt-1">
|
||||||
{formatPrice(c.total)}
|
{formatPrice(c.total)}
|
||||||
</div>
|
</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>
|
||||||
|
<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">
|
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-lg font-bold">
|
||||||
{c.items_count} منتج
|
{c.items_count} منتج
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{c.items?.map((item: any, i: number) => (
|
{c.items?.map((item: any, i: number) => (
|
||||||
<div
|
<div
|
||||||
@ -4716,14 +4900,32 @@ function AbandonedCartsTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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 className="text-sm text-blue-400">
|
||||||
💡 يمكن إرسال تذكيرات آلية للعملاء عبر إضافة نظام البريد الإلكتروني
|
🔔 تم تجهيز تنبيه رنين للعميل كل ساعة داخل المعاينة مع رسالة قرب انتهاء العرض ونفاد الكمية.
|
||||||
لاحقاً
|
</p>
|
||||||
|
<p className="text-xs text-blue-300/80">
|
||||||
|
في هذه النسخة يتم عرض الجدولة والمتابعة داخل لوحة التحكم، بينما الإرسال الفعلي الخارجي يحتاج مزود رسائل/إشعارات وربط خلفية منفصل.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4756,7 +4958,7 @@ function CategoriesTab() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const i = setInterval(() => load(true), 8000);
|
const i = setInterval(() => load(true), 2000);
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
@ -5650,7 +5852,7 @@ function AppearanceTab() {
|
|||||||
enableKey: "extra_section_enabled",
|
enableKey: "extra_section_enabled",
|
||||||
titleKey: "extra_section_title_ar",
|
titleKey: "extra_section_title_ar",
|
||||||
iconKey: "",
|
iconKey: "",
|
||||||
label: "قسم اكسترا (الفئات)",
|
label: "قسم رين (الفئات)",
|
||||||
noIcon: true,
|
noIcon: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,6 +13,7 @@ if (Number.isNaN(port) || port <= 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const basePath = process.env.BASE_PATH ?? "/";
|
const basePath = process.env.BASE_PATH ?? "/";
|
||||||
|
const apiProxyTarget = process.env.API_SERVER_URL ?? "http://127.0.0.1:8080";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: basePath,
|
base: basePath,
|
||||||
@ -50,6 +51,12 @@ export default defineConfig({
|
|||||||
port,
|
port,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
fs: {
|
fs: {
|
||||||
strict: true,
|
strict: true,
|
||||||
deny: ["**/.*"],
|
deny: ["**/.*"],
|
||||||
|
|||||||
184
docs/flatlogic-cicd-backend.md
Normal file
184
docs/flatlogic-cicd-backend.md
Normal 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 سيتم تخطيه أثناء النشر.
|
||||||
@ -4,13 +4,56 @@ import * as schema from "./schema";
|
|||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DATABASE_URL must be set. Did you forget to provision a database?",
|
"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 const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
|||||||
@ -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 { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const categoriesTable = pgTable("categories", {
|
export const categoriesTable = pgTable(
|
||||||
|
"categories",
|
||||||
|
{
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
name_en: text("name_en"),
|
name_en: text("name_en"),
|
||||||
@ -15,7 +17,13 @@ export const categoriesTable = pgTable("categories", {
|
|||||||
shein_cat_id: text("shein_cat_id"),
|
shein_cat_id: text("shein_cat_id"),
|
||||||
shein_url: text("shein_url"),
|
shein_url: text("shein_url"),
|
||||||
created_at: timestamp("created_at").defaultNow(),
|
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 const insertCategorySchema = createInsertSchema(categoriesTable).omit({ id: true, created_at: true });
|
||||||
export type InsertCategory = z.infer<typeof insertCategorySchema>;
|
export type InsertCategory = z.infer<typeof insertCategorySchema>;
|
||||||
|
|||||||
@ -10,3 +10,4 @@ export * from "./admin";
|
|||||||
export * from "./support";
|
export * from "./support";
|
||||||
export * from "./offers";
|
export * from "./offers";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./integration-events";
|
||||||
|
|||||||
29
lib/db/src/schema/integration-events.ts
Normal file
29
lib/db/src/schema/integration-events.ts
Normal 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;
|
||||||
@ -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 { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { categoriesTable } from "./categories";
|
import { categoriesTable } from "./categories";
|
||||||
@ -10,8 +10,15 @@ export type ProductVariant = {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const productsTable = pgTable("products", {
|
export const productsTable = pgTable(
|
||||||
|
"products",
|
||||||
|
{
|
||||||
id: serial("id").primaryKey(),
|
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: text("name").notNull(),
|
||||||
name_en: text("name_en"),
|
name_en: text("name_en"),
|
||||||
short_description: text("short_description"),
|
short_description: text("short_description"),
|
||||||
@ -29,6 +36,7 @@ export const productsTable = pgTable("products", {
|
|||||||
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
||||||
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
||||||
tags: jsonb("tags").$type<string[]>().default([]),
|
tags: jsonb("tags").$type<string[]>().default([]),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||||
stock: integer("stock").notNull().default(0),
|
stock: integer("stock").notNull().default(0),
|
||||||
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
||||||
review_count: integer("review_count").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_bestseller: boolean("is_bestseller").default(false),
|
||||||
is_new: boolean("is_new").default(true),
|
is_new: boolean("is_new").default(true),
|
||||||
is_top_rated: boolean("is_top_rated").default(false),
|
is_top_rated: boolean("is_top_rated").default(false),
|
||||||
|
last_synced_at: timestamp("last_synced_at").defaultNow(),
|
||||||
created_at: timestamp("created_at").defaultNow(),
|
created_at: timestamp("created_at").defaultNow(),
|
||||||
updated_at: timestamp("updated_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 const insertProductSchema = createInsertSchema(productsTable).omit({ id: true, created_at: true, updated_at: true });
|
||||||
export type InsertProduct = z.infer<typeof insertProductSchema>;
|
export type InsertProduct = z.infer<typeof insertProductSchema>;
|
||||||
|
|||||||
@ -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'",
|
"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",
|
"build": "pnpm run typecheck && pnpm -r --if-present run build",
|
||||||
"typecheck:libs": "tsc --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,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
58
scripts/flatlogic-deploy.sh
Executable file
58
scripts/flatlogic-deploy.sh
Executable 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
12
scripts/post-merge.sh
Normal file → Executable file
@ -1,4 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@10.16.1 --activate
|
||||||
pnpm install --frozen-lockfile
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user