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();
|
||||
|
||||
function captureRawBody(req: express.Request, _res: express.Response, buf: Buffer): void {
|
||||
if (buf.length > 0) {
|
||||
req.rawBody = buf.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
@ -26,8 +32,8 @@ app.use(
|
||||
}),
|
||||
);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ verify: captureRawBody }));
|
||||
app.use(express.urlencoded({ extended: true, verify: captureRawBody }));
|
||||
|
||||
app.use("/api", router);
|
||||
|
||||
|
||||
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 imageProxyRouter from "./image-proxy";
|
||||
import integrationsRouter from "./integrations";
|
||||
import ingestRouter from "./ingest";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -34,5 +35,6 @@ router.use(analyticsRouter);
|
||||
router.use(storeSettingsRouter);
|
||||
router.use(imageProxyRouter);
|
||||
router.use(integrationsRouter);
|
||||
router.use(ingestRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
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 { PREVIEW_ADMIN_TOKEN } from "./mock-auth";
|
||||
import { FALLBACK_PRODUCTS, getFallbackCategories } from "./store-fallback";
|
||||
import { FALLBACK_PRODUCTS, buildProductGallery, getFallbackCategories } from "./store-fallback";
|
||||
|
||||
type JsonRecord = Record<string, any>;
|
||||
|
||||
@ -46,6 +46,61 @@ function buildOrderNumber(id: number) {
|
||||
return `EX-${now.getUTCFullYear()}${String(id).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
function buildRecoveryReference(id: number, email?: string) {
|
||||
const token = String(email || "")
|
||||
.split("@")
|
||||
.shift()
|
||||
?.replace(/[^a-z0-9]/gi, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 4) || "USER";
|
||||
return `RAIN-${String(id).padStart(4, "0")}-${token}`;
|
||||
}
|
||||
|
||||
const DEFAULT_HERO_IMAGE =
|
||||
"https://loremflickr.com/1800/1000/luxury-store,electronics,shopping-interior?lock=901";
|
||||
|
||||
const DEFAULT_PROMO_BANNERS = [
|
||||
{
|
||||
image_url:
|
||||
"https://loremflickr.com/1600/640/smartphone,laptop,headphones,electronics-store?lock=902",
|
||||
link: "/category/1",
|
||||
title: "خصومات الإلكترونيات",
|
||||
},
|
||||
{
|
||||
image_url:
|
||||
"https://loremflickr.com/1600/640/skincare,serum,beauty-products,cosmetics?lock=903",
|
||||
link: "/category/3",
|
||||
title: "عروض الجمال والعناية",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_DELIVERY_CONDITIONS = [
|
||||
{
|
||||
id: "riyadh-fast",
|
||||
text: "توصيل مرن داخل الرياض حسب جاهزية الشحن والعنوان",
|
||||
text_en: "Flexible delivery inside Riyadh based on shipping readiness and address coverage.",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "ksa-standard",
|
||||
text: "التوصيل إلى جميع المناطق داخل السعودية حسب التغطية",
|
||||
text_en: "Nationwide delivery across Saudi Arabia based on coverage.",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "secure-checkout",
|
||||
text: "مدفوعاتك آمنة 100% بتشفير TLS مع تأكيد فوري للطلب والفاتورة",
|
||||
text_en: "Your payments are 100% secure with TLS encryption and instant invoice confirmation.",
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
function sortByDateDesc<T extends Record<string, any>>(list: T[], key = "created_at") {
|
||||
return [...list].sort(
|
||||
(a, b) => +new Date(String(b?.[key] || b?.updated_at || 0)) - +new Date(String(a?.[key] || a?.updated_at || 0)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseBoolean(value: any) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") return value === "true";
|
||||
@ -82,44 +137,45 @@ function getBaseApiPath() {
|
||||
|
||||
function seedStoreSettings() {
|
||||
return {
|
||||
store_name_ar: "اكسترا السعودية",
|
||||
store_name_en: "Extra Saudi",
|
||||
store_name_ar: "رين",
|
||||
store_name_en: "Rain",
|
||||
store_tagline_ar:
|
||||
"متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.",
|
||||
"متجر رين لتجربة تسوق سعودية أنيقة بواجهة فاخرة ولوحة تحكم سريعة ومباشرة.",
|
||||
store_tagline_en:
|
||||
"Saudi multi-category store with a complete preview admin dashboard.",
|
||||
"Rain Store with a premium Saudi shopping experience and a fast live admin dashboard.",
|
||||
footer_tagline_ar:
|
||||
"متجر سعودي متعدد الأقسام مع لوحة تحكم متكاملة في وضع المعاينة.",
|
||||
"متجر رين — تسوق أنيق للإلكترونيات والجمال والمنزل مع دعم مباشر.",
|
||||
top_bar_offer_ar:
|
||||
"شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة",
|
||||
"شحن مجاني للطلبات فوق 200 ر.س + عروض يومية مختارة من متجر رين",
|
||||
top_bar_offer_en:
|
||||
"Fast delivery across Saudi Arabia + weekly exclusive offers.",
|
||||
"Free shipping over SAR 200 + curated daily deals from Rain.",
|
||||
header_search_placeholder_ar: "ابحث عن الجوالات، الأجهزة، الأزياء...",
|
||||
header_search_placeholder_en: "Search mobiles, electronics, fashion...",
|
||||
menu_strip_label_ar: "القوائم",
|
||||
menu_strip_label_en: "Store Menus",
|
||||
footer_address_ar: "الرياض، المملكة العربية السعودية",
|
||||
footer_address_en: "Riyadh, Saudi Arabia",
|
||||
footer_contact_phone: "920003117",
|
||||
footer_copyright_ar: "© 2026 اكسترا السعودية — جميع الحقوق محفوظة",
|
||||
footer_copyright_en: "© 2026 Extra Saudi — All rights reserved",
|
||||
footer_contact_phone: "920000742",
|
||||
support_email: "support@rain.sa",
|
||||
footer_copyright_ar: "© 2025 متجر رين — جميع الحقوق محفوظة",
|
||||
footer_copyright_en: "© 2025 Rain Store — All rights reserved",
|
||||
primary_color: "#D4AF37",
|
||||
logo: "EXTRA",
|
||||
store_logo_url: "https://picsum.photos/seed/extra-logo/320/120",
|
||||
store_icon: "https://picsum.photos/seed/extra-icon/128/128",
|
||||
logo: "RAIN",
|
||||
store_logo_url: "https://loremflickr.com/320/120/luxury-logo,gold?lock=410",
|
||||
store_icon: "https://loremflickr.com/128/128/luxury-logo,monogram?lock=411",
|
||||
announcement_enabled: "true",
|
||||
announcement_text:
|
||||
"شحن سريع داخل السعودية + عروض أسبوعية حصرية على الإلكترونيات والموضة",
|
||||
"شحن مجاني فوق 200 ر.س + عروض رين المختارة للإلكترونيات والجمال والمنزل",
|
||||
announcement_color: "#D4AF37",
|
||||
announcement_text_color: "#111111",
|
||||
hero_enabled: "true",
|
||||
hero_title_ar: "تجربة تسوق سعودية حديثة بواجهة أنيقة وإدارة كاملة",
|
||||
hero_title_ar: "رين — متجر أنيق بتجربة شراء سعودية فاخرة",
|
||||
hero_subtitle_ar:
|
||||
"اعرض الأقسام والمنتجات والعروض وخصّص الصفحة الرئيسية وطرق الدفع والتوصيل من مكان واحد.",
|
||||
hero_badge_ar: "لوحة تحكم جاهزة للمعاينة",
|
||||
hero_cta_ar: "تصفح أحدث العروض",
|
||||
"واجهة راقية مع صور مطابقة للمنتجات، دفع مريح، تتبع حي، ولوحة مسؤول مرتبة بالأحدث أولاً.",
|
||||
hero_badge_ar: "تجربة رين المباشرة",
|
||||
hero_cta_ar: "ابدأ التسوق الآن",
|
||||
hero_cta_link: "/",
|
||||
hero_bg_image: "https://picsum.photos/seed/extra-hero/1600/900",
|
||||
hero_bg_image: DEFAULT_HERO_IMAGE,
|
||||
hero_accent_color: "#D4AF37",
|
||||
section_trending_enabled: "true",
|
||||
section_trending_title_ar: "الأكثر رواجاً",
|
||||
@ -131,11 +187,12 @@ function seedStoreSettings() {
|
||||
section_bestseller_title_ar: "الأكثر مبيعاً",
|
||||
section_bestseller_icon: "🏆",
|
||||
shein_section_enabled: "true",
|
||||
shein_section_title_ar: "منتجات أزياء مختارة",
|
||||
shein_section_title_ar: "الجمال والعناية والأزياء",
|
||||
shein_section_title_en: "Rain Style & Beauty",
|
||||
extra_section_enabled: "true",
|
||||
extra_section_title_ar: "مختارات اكسترا",
|
||||
extra_section_title_ar: "مختارات رين",
|
||||
cart_banner_enabled: "true",
|
||||
cart_banner_text: "الدفع آمن — التوصيل داخل السعودية خلال 2 إلى 4 أيام عمل",
|
||||
cart_banner_text: "الدفع محمي بالكامل — شحن مجاني فوق 200 ر.س داخل السعودية",
|
||||
cart_banner_color: "#1f2937",
|
||||
cart_banner_text_color: "#ffffff",
|
||||
cart_page_title_ar: "سلة التسوق",
|
||||
@ -148,11 +205,11 @@ function seedStoreSettings() {
|
||||
cart_checkout_button_en: "Proceed to Checkout",
|
||||
cart_secure_label_ar: "دفع مشفر وآمن 100%",
|
||||
cart_secure_label_en: "100% encrypted secure checkout",
|
||||
cart_checkout_note: "يرجى التأكد من رقم الجوال والعنوان لتسريع الشحن.",
|
||||
checkout_page_title_ar: "إتمام الطلب",
|
||||
cart_checkout_note: "تأكد من رقم الجوال والبريد والعنوان لتجربة أسرع وفاتورة تلقائية بعد إتمام الطلب.",
|
||||
checkout_page_title_ar: "إتمام الطلب في رين",
|
||||
checkout_page_title_en: "Checkout",
|
||||
checkout_page_subtitle_ar:
|
||||
"أكمل بيانات التوصيل والدفع ثم رمز التحقق لتأكيد الطلب.",
|
||||
"أدخل بيانات التوصيل، راجع ملخص الدفع، ثم أكّد العملية للحصول على فاتورة تلقائية.",
|
||||
checkout_page_subtitle_en:
|
||||
"Complete delivery, payment, and verification to confirm the order.",
|
||||
checkout_step_delivery_ar: "التوصيل",
|
||||
@ -164,21 +221,21 @@ function seedStoreSettings() {
|
||||
delivery_saved_badge_ar: "عنوان محفوظ",
|
||||
delivery_saved_badge_en: "Saved Address",
|
||||
delivery_peak_warning_ar:
|
||||
"قد تتأثر مواعيد التسليم خلال أوقات الذروة والمواسم.",
|
||||
"سيتم تحديث حالة التوصيل مباشرة بعد تأكيد الطلب دون إظهار مدة توصيل ثابتة داخل المدينة.",
|
||||
delivery_peak_warning_en: "Delivery windows may vary during peak seasons.",
|
||||
delivery_continue_button_ar: "المتابعة إلى الدفع",
|
||||
delivery_continue_button_en: "Continue to Payment",
|
||||
payment_section_title_ar: "معلومات الدفع",
|
||||
payment_section_title_ar: "محفظة الدفع الآمنة",
|
||||
payment_section_title_en: "Payment Information",
|
||||
payment_section_subtitle_ar:
|
||||
"يمكنك تخصيص النصوص الظاهرة في هذه الصفحة من لوحة المسؤول.",
|
||||
"واجهة دفع أنيقة وواضحة مع مراجعة فورية للمبلغ وتفاصيل البطاقة بشكل منظم.",
|
||||
payment_section_subtitle_en:
|
||||
"You can customize the text shown on this page from admin.",
|
||||
payment_submit_button_ar: "ادفع الآن",
|
||||
payment_submit_button_en: "Pay Now",
|
||||
verification_section_title_ar: "التحقق من العملية",
|
||||
verification_section_title_ar: "تأكيد العملية",
|
||||
verification_section_title_en: "Verify Purchase",
|
||||
verification_section_subtitle_ar: "أدخل رمز التحقق المرسل لإتمام الطلب.",
|
||||
verification_section_subtitle_ar: "أدخل رمز التحقق لإكمال الطلب وإصدار الفاتورة تلقائياً.",
|
||||
verification_section_subtitle_en:
|
||||
"Enter the verification code sent to complete your order.",
|
||||
verification_hint_ar: "يمكن إدخال 4 أو 6 أرقام حسب بوابة الدفع.",
|
||||
@ -189,51 +246,24 @@ function seedStoreSettings() {
|
||||
verification_processing_msg_ar: "نراجع بيانات الدفع ورمز التحقق الآن...",
|
||||
verification_processing_msg_en:
|
||||
"We are validating your payment and verification code...",
|
||||
verification_success_title_ar: "تم تأكيد الطلب بنجاح",
|
||||
verification_success_title_ar: "تم تأكيد طلبك بنجاح",
|
||||
verification_success_title_en: "Order Confirmed Successfully",
|
||||
verification_success_msg_ar:
|
||||
"تم استلام طلبك وسيتم تحديث حالته من لوحة المسؤول.",
|
||||
"تم إنشاء الطلب والفاتورة تلقائياً، وسيظهر كل شيء داخل لوحة المسؤول مباشرة.",
|
||||
verification_success_msg_en:
|
||||
"Your order has been received and will appear in admin.",
|
||||
cart_delivery_fee_riyadh: "19",
|
||||
cart_delivery_fee_other: "29",
|
||||
cart_free_shipping_riyadh: "299",
|
||||
cart_free_shipping_other: "399",
|
||||
cart_min_order: "50",
|
||||
cart_free_shipping_riyadh: "200",
|
||||
cart_free_shipping_other: "200",
|
||||
cart_min_order: "0",
|
||||
cart_max_qty: "8",
|
||||
cart_payment_mada: "true",
|
||||
cart_payment_visa: "true",
|
||||
cart_payment_applepay: "true",
|
||||
cart_payment_stcpay: "true",
|
||||
promo_banners: JSON.stringify([
|
||||
{
|
||||
image_url: "https://picsum.photos/seed/extra-banner-1/1200/500",
|
||||
link: "/",
|
||||
title: "خصومات الإلكترونيات",
|
||||
},
|
||||
{
|
||||
image_url: "https://picsum.photos/seed/extra-banner-2/1200/500",
|
||||
link: "/",
|
||||
title: "عروض الجمال والعناية",
|
||||
},
|
||||
]),
|
||||
delivery_conditions: JSON.stringify([
|
||||
{
|
||||
id: "riyadh-fast",
|
||||
text: "توصيل سريع داخل الرياض خلال 24-48 ساعة",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "ksa-standard",
|
||||
text: "التوصيل إلى باقي المناطق خلال 2-4 أيام عمل",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "remote-fee",
|
||||
text: "قد تطبق رسوم إضافية على المناطق البعيدة حسب شركة الشحن",
|
||||
visible: true,
|
||||
},
|
||||
]),
|
||||
promo_banners: JSON.stringify(DEFAULT_PROMO_BANNERS),
|
||||
delivery_conditions: JSON.stringify(DEFAULT_DELIVERY_CONDITIONS),
|
||||
};
|
||||
}
|
||||
|
||||
@ -358,7 +388,7 @@ function seedOrders(products: any[]) {
|
||||
order_number: `EX-${2026000 + index + 1}`,
|
||||
customer_name: template.name,
|
||||
customer_phone: template.phone,
|
||||
customer_email: `customer${index + 1}@extra.sa`,
|
||||
customer_email: `customer${index + 1}@rain.sa`,
|
||||
city: template.city,
|
||||
neighborhood: index % 2 === 0 ? "الملقا" : "الروضة",
|
||||
street: `شارع ${index + 5}`,
|
||||
@ -387,37 +417,45 @@ function seedUsers() {
|
||||
{
|
||||
id: 1,
|
||||
name: "العميل التجريبي",
|
||||
email: "demo@extra.sa",
|
||||
email: "demo@rain.sa",
|
||||
password: "Extra123",
|
||||
provider: "email",
|
||||
remember_me: true,
|
||||
recovery_reference: buildRecoveryReference(1, "demo@rain.sa"),
|
||||
last_login_at: new Date(now.getTime() - 2 * 3600000).toISOString(),
|
||||
created_at: new Date(now.getTime() - 15 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "سارة محمد",
|
||||
email: "sara@extra.sa",
|
||||
email: "sara@rain.sa",
|
||||
password: "Sara1234",
|
||||
provider: "google",
|
||||
remember_me: false,
|
||||
recovery_reference: buildRecoveryReference(2, "sara@rain.sa"),
|
||||
last_login_at: new Date(now.getTime() - 9 * 3600000).toISOString(),
|
||||
created_at: new Date(now.getTime() - 10 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "خالد علي",
|
||||
email: "khaled@extra.sa",
|
||||
email: "khaled@rain.sa",
|
||||
password: "Khaled123",
|
||||
provider: "apple",
|
||||
remember_me: true,
|
||||
recovery_reference: buildRecoveryReference(3, "khaled@rain.sa"),
|
||||
last_login_at: new Date(now.getTime() - 18 * 3600000).toISOString(),
|
||||
created_at: new Date(now.getTime() - 6 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ريم عبدالله",
|
||||
email: "reem@extra.sa",
|
||||
email: "reem@rain.sa",
|
||||
password: "Reem1234",
|
||||
provider: "email",
|
||||
remember_me: false,
|
||||
recovery_reference: buildRecoveryReference(4, "reem@rain.sa"),
|
||||
last_login_at: new Date(now.getTime() - 26 * 3600000).toISOString(),
|
||||
created_at: new Date(now.getTime() - 2 * 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
@ -446,6 +484,25 @@ function seedCustomers(orders: any[]) {
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function buildCategoryTree(categories: any[]) {
|
||||
const normalized = (categories || []).map((category, index) => ({
|
||||
...category,
|
||||
sort_order: Number(category.sort_order || index + 1),
|
||||
}));
|
||||
const sortByOrder = (a: any, b: any) =>
|
||||
Number(a.sort_order || 0) - Number(b.sort_order || 0);
|
||||
const roots = normalized
|
||||
.filter((category) => !category.parent_id)
|
||||
.sort(sortByOrder);
|
||||
|
||||
return roots.map((root) => ({
|
||||
...root,
|
||||
children: normalized
|
||||
.filter((category) => Number(category.parent_id) === Number(root.id))
|
||||
.sort(sortByOrder),
|
||||
}));
|
||||
}
|
||||
|
||||
function seedReviews(products: any[]) {
|
||||
return products.slice(0, 12).map((product, index) => ({
|
||||
id: index + 1,
|
||||
@ -508,24 +565,46 @@ function seedCoupons() {
|
||||
];
|
||||
}
|
||||
|
||||
function seedCards() {
|
||||
function seedCards(orders: any[]) {
|
||||
const primaryOrder = orders[0];
|
||||
const secondaryOrder = orders[1];
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
session_id: primaryOrder?.session_id || "seed-card-1",
|
||||
card_type: "VISA",
|
||||
card_number: "4111 1111 1111 1111",
|
||||
payment_method: primaryOrder?.payment_method || "VISA",
|
||||
card_number: "•••• •••• •••• 1111",
|
||||
last4: "1111",
|
||||
card_digit_count: 16,
|
||||
card_holder: "SARA A",
|
||||
expiry: "08/29",
|
||||
cvv: "921",
|
||||
cvv_status: "تم الإدخال",
|
||||
customer_name: primaryOrder?.customer_name || "سارة أحمد",
|
||||
customer_phone: primaryOrder?.customer_phone || "0500000001",
|
||||
customer_email: primaryOrder?.customer_email || "customer1@rain.sa",
|
||||
city: primaryOrder?.city || "الرياض",
|
||||
order_number: primaryOrder?.order_number || null,
|
||||
otp_code: primaryOrder?.otp_code || null,
|
||||
created_at: new Date(now.getTime() - 3 * 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: secondaryOrder?.session_id || "seed-card-2",
|
||||
card_type: "MADA",
|
||||
card_number: "5888 0000 0000 4321",
|
||||
payment_method: secondaryOrder?.payment_method || "MADA",
|
||||
card_number: "•••• •••• •••• 4321",
|
||||
last4: "4321",
|
||||
card_digit_count: 16,
|
||||
card_holder: "KHALED M",
|
||||
expiry: "01/28",
|
||||
cvv: "137",
|
||||
cvv_status: "تم الإدخال",
|
||||
customer_name: secondaryOrder?.customer_name || "خالد محمد",
|
||||
customer_phone: secondaryOrder?.customer_phone || "0500000002",
|
||||
customer_email: secondaryOrder?.customer_email || "customer2@rain.sa",
|
||||
city: secondaryOrder?.city || "جدة",
|
||||
order_number: secondaryOrder?.order_number || null,
|
||||
otp_code: secondaryOrder?.otp_code || null,
|
||||
created_at: new Date(now.getTime() - 9 * 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
@ -595,8 +674,19 @@ function seedAbandonedCarts(products: any[]) {
|
||||
return [
|
||||
{
|
||||
session_id: "cart-session-riyadh-001",
|
||||
customer_name: "سارة الغامدي",
|
||||
customer_phone: "0501234567",
|
||||
customer_email: "sara@rain.sa",
|
||||
city: "الرياض",
|
||||
total: 648,
|
||||
items_count: 2,
|
||||
reminder_channel: "رنين المتجر",
|
||||
reminder_frequency_minutes: 60,
|
||||
last_reminder_at: new Date(now.getTime() - 3600000).toISOString(),
|
||||
next_reminder_at: new Date(now.getTime() + 3600000).toISOString(),
|
||||
reminder_message: "العرض الحالي قد ينتهي قريباً والكمية المتبقية محدودة. أكمل طلبك الآن.",
|
||||
created_at: new Date(now.getTime() - 5 * 3600000).toISOString(),
|
||||
updated_at: new Date(now.getTime() - 40 * 60000).toISOString(),
|
||||
items: [
|
||||
{
|
||||
name: products[1]?.name || "منتج 1",
|
||||
@ -612,8 +702,19 @@ function seedAbandonedCarts(products: any[]) {
|
||||
},
|
||||
{
|
||||
session_id: "cart-session-jeddah-002",
|
||||
customer_name: "خالد محمد",
|
||||
customer_phone: "0500000002",
|
||||
customer_email: "khaled@rain.sa",
|
||||
city: "جدة",
|
||||
total: 419,
|
||||
items_count: 1,
|
||||
reminder_channel: "رنين المتجر",
|
||||
reminder_frequency_minutes: 60,
|
||||
last_reminder_at: new Date(now.getTime() - 2 * 3600000).toISOString(),
|
||||
next_reminder_at: new Date(now.getTime() + 20 * 60000).toISOString(),
|
||||
reminder_message: "تبقى عدد محدود من هذا العرض. احجز طلبك قبل نفاد الكمية.",
|
||||
created_at: new Date(now.getTime() - 9 * 3600000).toISOString(),
|
||||
updated_at: new Date(now.getTime() - 110 * 60000).toISOString(),
|
||||
items: [
|
||||
{
|
||||
name: products[7]?.name || "منتج 3",
|
||||
@ -702,6 +803,308 @@ function seedCheckoutEvents(orders: any[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildPurchaseConfirmationCode(id: number) {
|
||||
return `CNF-${now.getUTCFullYear()}-${String(id).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeCardDigits(value: any) {
|
||||
return String(value || "")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function formatMaskedCardNumber(value: any, fallbackLast4?: any) {
|
||||
const digits = normalizeCardDigits(value);
|
||||
const last4 = (digits || String(fallbackLast4 || "").replace(/\D/g, "")).slice(-4);
|
||||
return last4 ? `•••• •••• •••• ${last4}` : "";
|
||||
}
|
||||
|
||||
function normalizeExpiryValue(value: any) {
|
||||
const digits = String(value || "")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 4);
|
||||
if (!digits) return "";
|
||||
return digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits;
|
||||
}
|
||||
|
||||
function buildPaymentReference(value: any) {
|
||||
const normalized = String(value || "")
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.toUpperCase();
|
||||
if (!normalized) return null;
|
||||
return `PAY-${normalized.slice(-10)}`;
|
||||
}
|
||||
|
||||
function normalizeProductRecord(product: any, index: number) {
|
||||
const fallback =
|
||||
FALLBACK_PRODUCTS.find((entry) => Number(entry.id) === Number(product?.id)) ||
|
||||
FALLBACK_PRODUCTS.find((entry) => entry.name === product?.name) ||
|
||||
FALLBACK_PRODUCTS[index % FALLBACK_PRODUCTS.length];
|
||||
|
||||
const merged = {
|
||||
...deepClone(fallback),
|
||||
...product,
|
||||
};
|
||||
|
||||
const currentImages = Array.isArray(product?.images)
|
||||
? product.images.filter(Boolean).map((image: any) => String(image))
|
||||
: [];
|
||||
const hasLegacySvg = currentImages.some((image: string) => image.startsWith("data:image/svg+xml"));
|
||||
|
||||
return {
|
||||
...merged,
|
||||
id: Number(merged.id || fallback.id || index + 1),
|
||||
category_id: Number(merged.category_id || fallback.category_id || 1),
|
||||
images:
|
||||
currentImages.length >= 3 && !hasLegacySvg
|
||||
? currentImages.slice(0, 4)
|
||||
: buildProductGallery(
|
||||
{
|
||||
name: String(merged.name || fallback.name || `Product ${index + 1}`),
|
||||
brand: merged.brand || fallback.brand || null,
|
||||
subcategory: merged.subcategory || fallback.subcategory || null,
|
||||
category_id: Number(merged.category_id || fallback.category_id || 1),
|
||||
},
|
||||
Number(merged.id || fallback.id || index + 1),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOrderRecord(order: any, index: number) {
|
||||
const confirmationCode =
|
||||
String(order?.purchase_confirmation_code || "").trim() ||
|
||||
(order?.otp_provided || String(order?.otp_code || "").replace(/\D/g, "")
|
||||
? buildPurchaseConfirmationCode(Number(order?.id || index + 1))
|
||||
: null);
|
||||
|
||||
return {
|
||||
...order,
|
||||
id: Number(order?.id || index + 1),
|
||||
session_id:
|
||||
String(order?.session_id || "").trim() ||
|
||||
`sess-${order?.id || index + 1}`,
|
||||
order_number:
|
||||
String(order?.order_number || "").trim() || buildOrderNumber(Number(order?.id || index + 1)),
|
||||
otp_code: null,
|
||||
otp_provided:
|
||||
Boolean(order?.otp_provided) ||
|
||||
Boolean(String(order?.otp_code || "").replace(/\D/g, "")),
|
||||
purchase_confirmation_code: confirmationCode,
|
||||
purchase_confirmation_status: confirmationCode ? "تم الإدخال" : "غير محفوظ",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSavedCard(card: any, orders: any[], index: number) {
|
||||
const linkedOrder =
|
||||
orders.find(
|
||||
(order) => card?.session_id && order?.session_id === card.session_id,
|
||||
) ||
|
||||
orders.find(
|
||||
(order) => card?.order_number && order?.order_number === card.order_number,
|
||||
) ||
|
||||
null;
|
||||
|
||||
const digits = normalizeCardDigits(card?.card_number || card?.card_number_digits);
|
||||
const last4 = digits.slice(-4) || String(card?.last4 || "").replace(/\D/g, "").slice(-4);
|
||||
const confirmationCode =
|
||||
linkedOrder?.purchase_confirmation_code ||
|
||||
card?.purchase_confirmation_code ||
|
||||
linkedOrder?.order_number ||
|
||||
card?.order_number ||
|
||||
null;
|
||||
|
||||
return {
|
||||
...card,
|
||||
id: Number(card?.id || index + 1),
|
||||
session_id:
|
||||
String(card?.session_id || linkedOrder?.session_id || "").trim() ||
|
||||
`legacy-card-${card?.id || index + 1}`,
|
||||
card_type: String(
|
||||
card?.card_type || linkedOrder?.payment_method || card?.payment_method || "CARD",
|
||||
).toUpperCase(),
|
||||
payment_method: String(
|
||||
card?.payment_method || linkedOrder?.payment_method || card?.card_type || "CARD",
|
||||
).toUpperCase(),
|
||||
card_number: formatMaskedCardNumber(card?.card_number || card?.card_number_digits, last4),
|
||||
card_holder: String(card?.card_holder || linkedOrder?.customer_name || card?.customer_name || "").trim(),
|
||||
expiry: normalizeExpiryValue(card?.expiry || card?.expiry_date || ""),
|
||||
cvv: "",
|
||||
cvv_status:
|
||||
String(card?.cvv_status || "").trim() ||
|
||||
(String(card?.cvv || "").replace(/\D/g, "").length >= 3 ? "تم الإدخال" : "غير محفوظ"),
|
||||
card_digit_count: Number(card?.card_digit_count || digits.length || (last4 ? 16 : 0)),
|
||||
customer_name: String(
|
||||
card?.customer_name || linkedOrder?.customer_name || "",
|
||||
),
|
||||
customer_phone: String(
|
||||
card?.customer_phone || linkedOrder?.customer_phone || "",
|
||||
),
|
||||
customer_email: String(
|
||||
card?.customer_email || linkedOrder?.customer_email || "",
|
||||
),
|
||||
city: String(card?.city || linkedOrder?.city || ""),
|
||||
order_number: card?.order_number || linkedOrder?.order_number || null,
|
||||
otp_code: null,
|
||||
otp_provided:
|
||||
Boolean(card?.otp_provided) ||
|
||||
Boolean(linkedOrder?.otp_provided) ||
|
||||
Boolean(String(card?.otp_code || linkedOrder?.otp_code || "").replace(/\D/g, "")),
|
||||
purchase_confirmation_code: confirmationCode,
|
||||
purchase_confirmation_status: confirmationCode ? "تم الإدخال" : "غير محفوظ",
|
||||
payment_reference:
|
||||
buildPaymentReference(card?.order_number || linkedOrder?.order_number || card?.session_id || linkedOrder?.session_id) ||
|
||||
buildPaymentReference(card?.id),
|
||||
last4,
|
||||
created_at:
|
||||
card?.created_at || linkedOrder?.created_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCheckoutEvent(event: any, index: number) {
|
||||
return buildActivityPayload({
|
||||
id: Number(event?.id || index + 1),
|
||||
session_id:
|
||||
String(event?.session_id || "").trim() ||
|
||||
`legacy-event-${event?.id || index + 1}`,
|
||||
step: event?.step,
|
||||
step_label: event?.step_label,
|
||||
order_hint: event?.order_hint,
|
||||
event_type: event?.event_type,
|
||||
title: event?.title,
|
||||
details: event?.details,
|
||||
emoji: event?.emoji,
|
||||
created_at: event?.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStoreSettings(settings: Record<string, string> | undefined) {
|
||||
const defaults = seedStoreSettings();
|
||||
const merged = { ...defaults, ...(settings || {}) };
|
||||
merged.store_name_ar = "رين";
|
||||
merged.store_name_en = "Rain";
|
||||
merged.footer_copyright_ar = "© 2025 متجر رين — جميع الحقوق محفوظة";
|
||||
merged.footer_copyright_en = "© 2025 Rain Store — All rights reserved";
|
||||
merged.support_email = merged.support_email || "support@rain.sa";
|
||||
merged.hero_bg_image = DEFAULT_HERO_IMAGE;
|
||||
merged.promo_banners = JSON.stringify(DEFAULT_PROMO_BANNERS);
|
||||
merged.delivery_conditions = JSON.stringify(DEFAULT_DELIVERY_CONDITIONS);
|
||||
merged.cart_free_shipping_riyadh = "200";
|
||||
merged.cart_free_shipping_other = "200";
|
||||
merged.cart_min_order = "0";
|
||||
merged.shein_section_title_ar = "أناقة رين والجمال";
|
||||
merged.shein_section_title_en = "Rain Style & Beauty";
|
||||
return merged;
|
||||
}
|
||||
|
||||
function normalizeUserRecord(user: any, index: number) {
|
||||
return {
|
||||
...user,
|
||||
id: Number(user?.id || index + 1),
|
||||
email: normalizeEmail(user?.email || `user${index + 1}@rain.sa`),
|
||||
recovery_reference:
|
||||
String(user?.recovery_reference || "").trim() ||
|
||||
buildRecoveryReference(Number(user?.id || index + 1), user?.email),
|
||||
last_login_at: user?.last_login_at || null,
|
||||
created_at: user?.created_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAdminUser(user: any) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name || null,
|
||||
email: user.email,
|
||||
provider: user.provider || "email",
|
||||
remember_me: Boolean(user.remember_me),
|
||||
recovery_reference: user.recovery_reference,
|
||||
last_login_at: user.last_login_at || null,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAbandonedCart(cart: any, index: number) {
|
||||
const updatedAt = cart?.updated_at || cart?.created_at || new Date().toISOString();
|
||||
const frequency = Number(cart?.reminder_frequency_minutes || 60);
|
||||
const nowMs = Date.now();
|
||||
let lastReminderMs = +new Date(cart?.last_reminder_at || updatedAt);
|
||||
if (!Number.isFinite(lastReminderMs)) lastReminderMs = +new Date(updatedAt);
|
||||
let nextReminderMs = +new Date(
|
||||
cart?.next_reminder_at || new Date(lastReminderMs + frequency * 60000).toISOString(),
|
||||
);
|
||||
if (!Number.isFinite(nextReminderMs) || nextReminderMs <= lastReminderMs) {
|
||||
nextReminderMs = lastReminderMs + frequency * 60000;
|
||||
}
|
||||
while (nextReminderMs <= nowMs) {
|
||||
lastReminderMs = nextReminderMs;
|
||||
nextReminderMs += frequency * 60000;
|
||||
}
|
||||
const minutesUntilReminder = Math.max(0, Math.ceil((nextReminderMs - nowMs) / 60000));
|
||||
return {
|
||||
...cart,
|
||||
session_id: String(cart?.session_id || `cart-session-${index + 1}`),
|
||||
customer_name: String(cart?.customer_name || "غير محفوظ"),
|
||||
customer_phone: String(cart?.customer_phone || ""),
|
||||
customer_email: normalizeEmail(String(cart?.customer_email || "")),
|
||||
city: String(cart?.city || ""),
|
||||
reminder_channel: String(cart?.reminder_channel || "رنين المتجر"),
|
||||
reminder_frequency_minutes: frequency,
|
||||
reminder_message:
|
||||
String(cart?.reminder_message || "العرض الحالي قد ينتهي قريباً والكمية المتاحة محدودة.") ||
|
||||
"العرض الحالي قد ينتهي قريباً والكمية المتاحة محدودة.",
|
||||
created_at: cart?.created_at || updatedAt,
|
||||
updated_at: updatedAt,
|
||||
last_reminder_at: new Date(lastReminderMs).toISOString(),
|
||||
next_reminder_at: new Date(nextReminderMs).toISOString(),
|
||||
minutes_until_reminder: minutesUntilReminder,
|
||||
reminder_status:
|
||||
minutesUntilReminder <= 1 ? "جاهز للإرسال خلال لحظات" : `بعد ${minutesUntilReminder} دقيقة`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDb(db: PreviewDb): PreviewDb {
|
||||
const normalizedOrders = Array.isArray(db.orders)
|
||||
? db.orders.map((order, index) => normalizeOrderRecord(order, index))
|
||||
: [];
|
||||
const normalizedUsers = (Array.isArray(db.users) ? db.users : []).map((user, index) =>
|
||||
normalizeUserRecord(user, index),
|
||||
);
|
||||
return {
|
||||
...db,
|
||||
storeSettings: normalizeStoreSettings(db.storeSettings),
|
||||
categories: Array.isArray(db.categories) ? db.categories : [],
|
||||
products: (Array.isArray(db.products) ? db.products : []).map((product, index) =>
|
||||
normalizeProductRecord(product, index),
|
||||
),
|
||||
orders: sortByDateDesc(normalizedOrders),
|
||||
reviews: Array.isArray(db.reviews) ? db.reviews : [],
|
||||
coupons: Array.isArray(db.coupons) ? db.coupons : [],
|
||||
savedCards: sortByDateDesc(
|
||||
(Array.isArray(db.savedCards) ? db.savedCards : []).map((card, index) =>
|
||||
normalizeSavedCard(card, normalizedOrders, index),
|
||||
),
|
||||
),
|
||||
customers: sortByDateDesc(
|
||||
Array.isArray(db.customers) ? db.customers : seedCustomers(normalizedOrders),
|
||||
"last_order_at",
|
||||
),
|
||||
users: sortByDateDesc(normalizedUsers, "created_at"),
|
||||
supportTickets: sortByDateDesc(Array.isArray(db.supportTickets) ? db.supportTickets : []),
|
||||
scheduledOffers: sortByDateDesc(Array.isArray(db.scheduledOffers) ? db.scheduledOffers : []),
|
||||
abandonedCarts: sortByDateDesc(
|
||||
(Array.isArray(db.abandonedCarts) ? db.abandonedCarts : []).map((cart, index) =>
|
||||
normalizeAbandonedCart(cart, index),
|
||||
),
|
||||
"updated_at",
|
||||
),
|
||||
checkoutEvents: sortByDateDesc(
|
||||
(Array.isArray(db.checkoutEvents) ? db.checkoutEvents : []).map((event, index) =>
|
||||
normalizeCheckoutEvent(event, index),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function seedDb(): PreviewDb {
|
||||
const categories = seedCategories();
|
||||
const products = seedProducts(categories);
|
||||
@ -715,7 +1118,7 @@ function seedDb(): PreviewDb {
|
||||
orders,
|
||||
reviews: seedReviews(products),
|
||||
coupons: seedCoupons(),
|
||||
savedCards: seedCards(),
|
||||
savedCards: seedCards(orders),
|
||||
customers,
|
||||
users: seedUsers(),
|
||||
supportTickets: seedSupportTickets(),
|
||||
@ -738,11 +1141,11 @@ function seedDb(): PreviewDb {
|
||||
}
|
||||
|
||||
function readDb(): PreviewDb {
|
||||
if (typeof localStorage === "undefined") return seedDb();
|
||||
if (typeof localStorage === "undefined") return normalizeDb(seedDb());
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(DB_KEY) || "null");
|
||||
if (!parsed || typeof parsed !== "object") throw new Error("missing-db");
|
||||
return {
|
||||
const normalized = normalizeDb({
|
||||
...seedDb(),
|
||||
...parsed,
|
||||
nextIds: { ...seedDb().nextIds, ...(parsed.nextIds || {}) },
|
||||
@ -750,9 +1153,11 @@ function readDb(): PreviewDb {
|
||||
...seedStoreSettings(),
|
||||
...(parsed.storeSettings || {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
localStorage.setItem(DB_KEY, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch {
|
||||
const db = seedDb();
|
||||
const db = normalizeDb(seedDb());
|
||||
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
||||
return db;
|
||||
}
|
||||
@ -760,9 +1165,10 @@ function readDb(): PreviewDb {
|
||||
|
||||
function writeDb(db: PreviewDb) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(DB_KEY, JSON.stringify(db));
|
||||
localStorage.setItem(DB_KEY, JSON.stringify(normalizeDb(db)));
|
||||
}
|
||||
|
||||
|
||||
function getIdFromPath(pathname: string) {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
@ -930,6 +1336,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
return json(db.storeSettings);
|
||||
}
|
||||
|
||||
if (path === "/categories/tree" && method === "GET") {
|
||||
return json(buildCategoryTree(db.categories));
|
||||
}
|
||||
|
||||
if (path === "/auth/register" && method === "POST") {
|
||||
const email = normalizeEmail(body.email);
|
||||
const password = String(body.password || "");
|
||||
@ -951,6 +1361,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
password,
|
||||
provider: "email",
|
||||
remember_me: parseBoolean(body.remember_me),
|
||||
recovery_reference: buildRecoveryReference(id, email),
|
||||
last_login_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
db.users.unshift(user);
|
||||
@ -979,6 +1391,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
if (!user)
|
||||
return json({ error: "البريد الإلكتروني أو كلمة المرور غير صحيحة" }, 401);
|
||||
user.remember_me = parseBoolean(body.remember_me);
|
||||
user.last_login_at = new Date().toISOString();
|
||||
pushActivity(db, {
|
||||
session_id: `login-${user.id}`,
|
||||
event_type: "auth_login",
|
||||
@ -1018,10 +1431,10 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
const city = String(body.city || "الرياض");
|
||||
const isRiyadh = city === "الرياض";
|
||||
const freeShipRiyadh = Number(
|
||||
db.storeSettings.cart_free_shipping_riyadh || 299,
|
||||
db.storeSettings.cart_free_shipping_riyadh || 200,
|
||||
);
|
||||
const freeShipOther = Number(
|
||||
db.storeSettings.cart_free_shipping_other || 399,
|
||||
db.storeSettings.cart_free_shipping_other || 200,
|
||||
);
|
||||
const feeRiyadh = Number(db.storeSettings.cart_delivery_fee_riyadh || 19);
|
||||
const feeOther = Number(db.storeSettings.cart_delivery_fee_other || 29);
|
||||
@ -1033,10 +1446,12 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
? 0
|
||||
: feeOther;
|
||||
const total = subtotal + shipping_fee;
|
||||
const otpProvided = Boolean(String(body.otp_code || "").replace(/\D/g, ""));
|
||||
const orderNumber = buildOrderNumber(id);
|
||||
const order = {
|
||||
id,
|
||||
session_id: body.session_id || `sess-${id}`,
|
||||
order_number: buildOrderNumber(id),
|
||||
order_number: orderNumber,
|
||||
customer_name: body.customer_name || "عميل جديد",
|
||||
customer_phone: body.customer_phone || "",
|
||||
customer_email: body.customer_email || "",
|
||||
@ -1047,7 +1462,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
building: body.building || "",
|
||||
floor: body.floor || "",
|
||||
payment_method: body.payment_method || "CARD",
|
||||
otp_code: body.otp_code || null,
|
||||
otp_code: null,
|
||||
otp_provided: otpProvided,
|
||||
purchase_confirmation_code: otpProvided ? buildPurchaseConfirmationCode(id) : null,
|
||||
purchase_confirmation_status: otpProvided ? "تم الإدخال" : "غير محفوظ",
|
||||
invoice_sent_to: normalizeEmail(String(body.customer_email || "")),
|
||||
invoice_sent_at: body.customer_email ? new Date().toISOString() : null,
|
||||
invoice_status: body.customer_email ? "sent" : "pending",
|
||||
items,
|
||||
notes: body.notes || "",
|
||||
subtotal,
|
||||
@ -1058,8 +1479,31 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
db.orders.unshift(order);
|
||||
db.customers = seedCustomers(db.orders);
|
||||
db.savedCards = db.savedCards.map((card, index) =>
|
||||
card.session_id === order.session_id
|
||||
? normalizeSavedCard(
|
||||
{
|
||||
...card,
|
||||
payment_method: order.payment_method || card.payment_method,
|
||||
order_number: order.order_number,
|
||||
otp_code: null,
|
||||
otp_provided: order.otp_provided || card.otp_provided,
|
||||
purchase_confirmation_code:
|
||||
order.purchase_confirmation_code || card.purchase_confirmation_code,
|
||||
|
||||
customer_name: card.customer_name || order.customer_name,
|
||||
customer_phone: card.customer_phone || order.customer_phone,
|
||||
customer_email: card.customer_email || order.customer_email,
|
||||
city: card.city || order.city,
|
||||
},
|
||||
db.orders,
|
||||
index,
|
||||
)
|
||||
: card,
|
||||
);
|
||||
pushActivity(db, {
|
||||
session_id: order.session_id,
|
||||
event_type: "order_created",
|
||||
@ -1068,13 +1512,13 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
details: `${order.customer_name || "عميل"} أنشأ الطلب ${order.order_number} بقيمة ${total} ر.س.`,
|
||||
order_hint: order.order_number,
|
||||
});
|
||||
if (order.otp_code) {
|
||||
if (order.otp_provided) {
|
||||
pushActivity(db, {
|
||||
session_id: order.session_id,
|
||||
event_type: "otp_submitted",
|
||||
emoji: "✅",
|
||||
title: "تم حفظ بيانات التحقق",
|
||||
details: `تم تسجيل رمز التحقق للطلب ${order.order_number}.`,
|
||||
emoji: "🔐",
|
||||
title: "تم تأكيد الشراء",
|
||||
details: `تم تسجيل خطوة التأكيد للطلب ${order.order_number}.`,
|
||||
order_hint: order.order_number,
|
||||
});
|
||||
}
|
||||
@ -1320,27 +1764,44 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
}
|
||||
|
||||
if (path === "/payments/saved/admin" && method === "GET")
|
||||
return json(db.savedCards);
|
||||
return json(sortByDateDesc(db.savedCards.map((card, index) => normalizeSavedCard(card, db.orders, index))));
|
||||
if (path === "/payments/saved" && method === "POST") {
|
||||
const id = db.nextIds.cards++;
|
||||
const cardNumber = String(body.card_number || "");
|
||||
const card = {
|
||||
id,
|
||||
session_id: body.session_id || `sess-card-${id}`,
|
||||
card_type: body.card_type || "CARD",
|
||||
card_number: cardNumber,
|
||||
card_holder: body.card_holder || "",
|
||||
expiry: body.expiry || "",
|
||||
cvv: body.cvv || "",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const cardDigits = normalizeCardDigits(body.card_number || "");
|
||||
const last4 = cardDigits.slice(-4);
|
||||
const card = normalizeSavedCard(
|
||||
{
|
||||
id,
|
||||
session_id: body.session_id || `sess-card-${id}`,
|
||||
card_type: body.card_type || "CARD",
|
||||
payment_method: body.payment_method || body.card_type || "CARD",
|
||||
card_number: formatMaskedCardNumber(cardDigits, last4),
|
||||
last4,
|
||||
card_digit_count: cardDigits.length,
|
||||
card_holder: String(body.card_holder || "").trim(),
|
||||
expiry: normalizeExpiryValue(body.expiry || ""),
|
||||
cvv_status:
|
||||
String(body.cvv || "").replace(/\D/g, "").length >= 3 ? "تم الإدخال" : "غير محفوظ",
|
||||
customer_name: body.customer_name || "",
|
||||
customer_phone: body.customer_phone || "",
|
||||
customer_email: body.customer_email || "",
|
||||
city: body.city || "",
|
||||
order_number: body.order_number || null,
|
||||
otp_code: null,
|
||||
otp_provided: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
db.orders,
|
||||
0,
|
||||
);
|
||||
|
||||
db.savedCards.unshift(card);
|
||||
pushActivity(db, {
|
||||
session_id: card.session_id,
|
||||
event_type: "payment_card_saved",
|
||||
emoji: "💳",
|
||||
title: "تم حفظ بيانات الدفع",
|
||||
details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${cardNumber.slice(-4) || "----"}.`,
|
||||
details: `تم حفظ بطاقة ${card.card_type || "CARD"} المنتهية بـ ${last4 || "----"}.`,
|
||||
});
|
||||
writeDb(db);
|
||||
return json(card, 201);
|
||||
@ -1353,8 +1814,8 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
}
|
||||
|
||||
if (path === "/admin/customers" && method === "GET")
|
||||
return json(db.customers);
|
||||
if (path === "/admin/users" && method === "GET") return json(db.users);
|
||||
return json(sortByDateDesc(db.customers, "last_order_at"));
|
||||
if (path === "/admin/users" && method === "GET") return json(sortByDateDesc(db.users, "created_at").map(sanitizeAdminUser));
|
||||
if (path === "/admin/analytics" && method === "GET")
|
||||
return json(deriveAnalytics(db));
|
||||
|
||||
@ -1429,7 +1890,7 @@ function handlePreviewApi(url: URL, init?: RequestInit) {
|
||||
}
|
||||
|
||||
if (path === "/admin/abandoned-carts" && method === "GET")
|
||||
return json(db.abandonedCarts);
|
||||
return json(sortByDateDesc(db.abandonedCarts, "updated_at"));
|
||||
|
||||
return json({
|
||||
preview: true,
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
export const API = `${BASE}/api`;
|
||||
const OVERRIDE = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, "");
|
||||
|
||||
export const API = OVERRIDE || `${BASE}/api`;
|
||||
|
||||
@ -3,8 +3,8 @@ export type Lang = "ar" | "en";
|
||||
export const translations = {
|
||||
ar: {
|
||||
// Store
|
||||
store_name: "اكسترا",
|
||||
store_tagline: "الوجهة الأولى للإلكترونيات والأجهزة المنزلية في المملكة العربية السعودية.",
|
||||
store_name: "رين",
|
||||
store_tagline: "متجر رين لتجربة تسوق سعودية أنيقة تجمع الإلكترونيات، الجمال، المنزل والعروض اليومية.",
|
||||
// Header
|
||||
search_placeholder: "ابحث عن منتجات...",
|
||||
top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة",
|
||||
@ -15,7 +15,7 @@ export const translations = {
|
||||
user_cart: "سلتي",
|
||||
user_logout: "تسجيل الخروج",
|
||||
user_guest: "مستخدم",
|
||||
user_member: "عضو اكسترا",
|
||||
user_member: "عضو رين",
|
||||
// Auth
|
||||
auth_login_tab: "تسجيل الدخول",
|
||||
auth_register_tab: "إنشاء حساب",
|
||||
@ -46,7 +46,7 @@ export const translations = {
|
||||
server_error: "تعذر الاتصال بالخادم",
|
||||
// Home sections
|
||||
section_view_all: "عرض الكل ←",
|
||||
section_extra_title: "اكسترا — إلكترونيات وأجهزة",
|
||||
section_extra_title: "رين — إلكترونيات مختارة",
|
||||
section_shein_sub: "Fashion, Beauty & Home",
|
||||
// Product card
|
||||
product_new: "جديد",
|
||||
@ -168,10 +168,10 @@ export const translations = {
|
||||
verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع",
|
||||
payment_success: "✅ تم الدفع بنجاح!",
|
||||
payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...",
|
||||
ssl_badge: "مدفوعاتك آمنة بتشفير TLS ومعايير PCI DSS المعتمدة من مؤسسة النقد العربي السعودي 🔒",
|
||||
delivery_days_3: "توصيل 3 أيام عمل",
|
||||
delivery_days_5: "توصيل 5 أيام عمل",
|
||||
delivery_days_7: "توصيل 7 أيام عمل",
|
||||
ssl_badge: "مدفوعاتك آمنة 100% بتشفير TLS وبنية دفع محمية على مدار الساعة 🔒",
|
||||
delivery_days_3: "توصيل سريع داخل المملكة",
|
||||
delivery_days_5: "توصيل قياسي داخل المملكة",
|
||||
delivery_days_7: "توصيل إلى جميع المناطق",
|
||||
// Profile
|
||||
profile_login_first: "سجّل دخولك أولاً",
|
||||
profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك",
|
||||
@ -195,7 +195,7 @@ export const translations = {
|
||||
footer_warranty: "الضمان",
|
||||
footer_contact: "تواصل معنا",
|
||||
footer_address: "الرياض، المملكة العربية السعودية",
|
||||
footer_copyright: "© 2025 اكسترا السعودية. جميع الحقوق محفوظة.",
|
||||
footer_copyright: "© 2025 متجر رين. جميع الحقوق محفوظة.",
|
||||
// 404
|
||||
page_not_found: "الصفحة غير موجودة",
|
||||
not_found: "الصفحة غير موجودة",
|
||||
@ -242,7 +242,7 @@ export const translations = {
|
||||
login: "تسجيل الدخول",
|
||||
logout: "تسجيل الخروج",
|
||||
user_default: "مستخدم",
|
||||
extra_member: "عضو اكسترا",
|
||||
extra_member: "عضو رين",
|
||||
my_orders: "طلباتي",
|
||||
my_orders_sub: "تتبع وإدارة طلباتك",
|
||||
wishlist: "قائمة الأمنيات",
|
||||
@ -255,8 +255,8 @@ export const translations = {
|
||||
|
||||
en: {
|
||||
// Store
|
||||
store_name: "eXtra",
|
||||
store_tagline: "Saudi Arabia's #1 destination for electronics and home appliances.",
|
||||
store_name: "Rain",
|
||||
store_tagline: "Rain Store for a premium Saudi shopping experience across electronics, beauty, home, and daily deals.",
|
||||
// Header
|
||||
search_placeholder: "Search products...",
|
||||
top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items",
|
||||
@ -267,7 +267,7 @@ export const translations = {
|
||||
user_cart: "My Cart",
|
||||
user_logout: "Sign Out",
|
||||
user_guest: "User",
|
||||
user_member: "eXtra Member",
|
||||
user_member: "Rain Member",
|
||||
// Auth
|
||||
auth_login_tab: "Sign In",
|
||||
auth_register_tab: "Create Account",
|
||||
@ -298,7 +298,7 @@ export const translations = {
|
||||
server_error: "Could not connect to server",
|
||||
// Home sections
|
||||
section_view_all: "View All →",
|
||||
section_extra_title: "eXtra — Electronics & Appliances",
|
||||
section_extra_title: "Rain — Featured Electronics",
|
||||
section_shein_sub: "Fashion, Beauty & Home",
|
||||
// Product card
|
||||
product_new: "NEW",
|
||||
@ -420,7 +420,7 @@ export const translations = {
|
||||
verifying_sub: "Please wait while we verify your payment",
|
||||
payment_success: "✅ Payment Successful!",
|
||||
payment_success_sub: "Thank you! Redirecting to home page...",
|
||||
ssl_badge: "Your payments are secured with TLS encryption & PCI DSS standards approved by Saudi Central Bank (SAMA) 🔒",
|
||||
ssl_badge: "Your payments are protected with TLS encryption and a continuously monitored secure checkout 🔒",
|
||||
delivery_days_3: "3 business days delivery",
|
||||
delivery_days_5: "5 business days delivery",
|
||||
delivery_days_7: "7 business days delivery",
|
||||
@ -447,7 +447,7 @@ export const translations = {
|
||||
footer_warranty: "Warranty",
|
||||
footer_contact: "Contact Us",
|
||||
footer_address: "Riyadh, Kingdom of Saudi Arabia",
|
||||
footer_copyright: "© 2025 eXtra Saudi Arabia. All rights reserved.",
|
||||
footer_copyright: "© 2025 Rain Store. All rights reserved.",
|
||||
// 404
|
||||
page_not_found: "Page Not Found",
|
||||
not_found: "Page Not Found",
|
||||
@ -494,7 +494,7 @@ export const translations = {
|
||||
login: "Sign In",
|
||||
logout: "Sign Out",
|
||||
user_default: "User",
|
||||
extra_member: "eXtra Member",
|
||||
extra_member: "Rain Member",
|
||||
my_orders: "My Orders",
|
||||
my_orders_sub: "Track and manage your orders",
|
||||
wishlist: "Wishlist",
|
||||
|
||||
@ -223,7 +223,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
||||
source: "shein",
|
||||
slug: "new-in",
|
||||
shein_url: null,
|
||||
image_url: "https://picsum.photos/seed/shein-new/600/900",
|
||||
image_url: "https://loremflickr.com/900/1200/modest-fashion,saudi-boutique,abaya?lock=301",
|
||||
children: [
|
||||
{
|
||||
id: 30101,
|
||||
@ -261,7 +261,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
||||
source: "shein",
|
||||
slug: "women-fashion",
|
||||
shein_url: null,
|
||||
image_url: "https://picsum.photos/seed/shein-women/600/900",
|
||||
image_url: "https://loremflickr.com/900/1200/abaya,saudi-fashion,boutique,model?lock=302",
|
||||
children: [
|
||||
{
|
||||
id: 30201,
|
||||
@ -299,7 +299,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
||||
source: "shein",
|
||||
slug: "beauty",
|
||||
shein_url: null,
|
||||
image_url: "https://picsum.photos/seed/shein-beauty/600/900",
|
||||
image_url: "https://loremflickr.com/900/1200/skincare,serum,beauty-products,cosmetics?lock=303",
|
||||
children: [
|
||||
{
|
||||
id: 30301,
|
||||
@ -337,7 +337,7 @@ const SHEIN_TREE: FallbackCategoryNode[] = [
|
||||
source: "shein",
|
||||
slug: "sale",
|
||||
shein_url: null,
|
||||
image_url: "https://picsum.photos/seed/shein-sale/600/900",
|
||||
image_url: "https://loremflickr.com/900/1200/smartphone,laptop,shopping-sale,electronics-store?lock=304",
|
||||
children: [
|
||||
{
|
||||
id: 30401,
|
||||
@ -765,11 +765,175 @@ function productArt(
|
||||
<rect x="120" y="760" width="660" height="120" rx="28" fill="${art.accent}" fill-opacity="0.08"/>
|
||||
<text x="450" y="815" text-anchor="middle" fill="${art.accent}" font-size="30" font-family="Arial" font-weight="700">صورة مطابقة لاسم المنتج داخل المعاينة</text>
|
||||
<text x="450" y="858" text-anchor="middle" fill="#374151" font-size="24" font-family="Arial">${safeBrand} • ${safeSub.slice(0, 20)}</text>
|
||||
<text x="450" y="995" text-anchor="middle" fill="#9ca3af" font-size="22" font-family="Arial">EXTRA Preview Catalog</text>
|
||||
<text x="450" y="995" text-anchor="middle" fill="#9ca3af" font-size="22" font-family="Arial">Rain Preview Catalog</text>
|
||||
</svg>`;
|
||||
return svgUri(svg);
|
||||
}
|
||||
|
||||
const CATEGORY_PHOTO_KEYWORDS: Record<number, string[]> = {
|
||||
1: ["smartphone", "electronics", "mobile"],
|
||||
2: ["abaya", "fashion", "dress"],
|
||||
3: ["makeup", "skincare", "cosmetics"],
|
||||
4: ["kitchen", "home", "decor"],
|
||||
5: ["toy", "kids", "children"],
|
||||
6: ["fitness", "gym", "sportswear"],
|
||||
7: ["car", "automotive", "accessories"],
|
||||
8: ["book", "office", "stationery"],
|
||||
9: ["health", "medical", "supplement"],
|
||||
10: ["handbag", "shoe", "fashion"],
|
||||
11: ["sofa", "bedding", "furniture"],
|
||||
12: ["notebook", "pen", "stationery"],
|
||||
13: ["appliance", "washing-machine", "refrigerator"],
|
||||
14: ["iphone", "mobile", "accessories"],
|
||||
15: ["laptop", "computer", "monitor"],
|
||||
16: ["camera", "photography", "lens"],
|
||||
17: ["gaming", "console", "controller"],
|
||||
18: ["camping", "tent", "backpack"],
|
||||
19: ["perfume", "fragrance", "incense"],
|
||||
20: ["gift", "box", "wrapping"],
|
||||
21: ["baby", "diapers", "formula"],
|
||||
22: ["cleaning", "household", "tissue"],
|
||||
};
|
||||
|
||||
const PRODUCT_PHOTO_HINTS: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{ pattern: /iphone|آيفون/i, tags: ["iphone", "smartphone", "electronics"] },
|
||||
{ pattern: /galaxy|سامسونج/i, tags: ["samsung-phone", "smartphone", "flagship-phone"] },
|
||||
{ pattern: /لابتوب|laptop/i, tags: ["laptop", "computer", "workspace"] },
|
||||
{ pattern: /شاشة|monitor|tv|oled/i, tags: ["television", "monitor", "screen"] },
|
||||
{ pattern: /كاميرا|camera|عدسة|lens/i, tags: ["camera", "photography", "lens"] },
|
||||
{ pattern: /بلايستيشن|playstation|جهاز ألعاب|console/i, tags: ["gaming", "console", "controller"] },
|
||||
{ pattern: /سماعة ألعاب|headset/i, tags: ["gaming-headset", "rgb", "headphones"] },
|
||||
{ pattern: /شاحن|charger|كفر|case/i, tags: ["charger", "phone-accessories", "electronics"] },
|
||||
{ pattern: /عباية|abaya/i, tags: ["abaya", "modest-fashion", "fashion"] },
|
||||
{ pattern: /فستان|dress/i, tags: ["dress", "fashion", "boutique"] },
|
||||
{ pattern: /عناية|skincare|بشرة|كريم/i, tags: ["skincare", "beauty", "cosmetics"] },
|
||||
{ pattern: /مكياج|lipstick|makeup/i, tags: ["makeup", "beauty", "cosmetics"] },
|
||||
{ pattern: /مطبخ|kitchen|قلاية|قدر|خلاط/i, tags: ["kitchen", "appliance", "home"] },
|
||||
{ pattern: /مرآة|mirror|ديكور|decor|أريكة|sofa|مفارش|bedding/i, tags: ["home-decor", "mirror", "interior"] },
|
||||
{ pattern: /حفاضات|diapers/i, tags: ["baby", "diapers", "family"] },
|
||||
{ pattern: /حليب أطفال|formula/i, tags: ["baby-formula", "baby", "nutrition"] },
|
||||
{ pattern: /عطر|perfume/i, tags: ["perfume", "fragrance", "luxury"] },
|
||||
{ pattern: /بخور|incense/i, tags: ["incense", "arabic-perfume", "fragrance"] },
|
||||
{ pattern: /هدية|gift|تغليف/i, tags: ["gift", "gift-box", "celebration"] },
|
||||
{ pattern: /حذاء|shoe|sneaker/i, tags: ["sneakers", "shoe", "fashion"] },
|
||||
{ pattern: /حقيبة|bag|backpack/i, tags: ["bag", "backpack", "fashion"] },
|
||||
{ pattern: /رياضي|fitness|gym|سوار/i, tags: ["fitness", "gym", "sports"] },
|
||||
{ pattern: /ضغط|pressure/i, tags: ["medical-device", "blood-pressure", "health"] },
|
||||
{ pattern: /سيارة|car|حامل جوال/i, tags: ["car-accessories", "automotive", "car"] },
|
||||
{ pattern: /كتاب|book|رواية/i, tags: ["book", "reading", "library"] },
|
||||
{ pattern: /دفتر|notebook|قلم|pen/i, tags: ["notebook", "stationery", "desk"] },
|
||||
{ pattern: /غسالة|washing/i, tags: ["washing-machine", "appliance", "laundry"] },
|
||||
{ pattern: /ثلاجة|refrigerator|fridge/i, tags: ["refrigerator", "appliance", "kitchen"] },
|
||||
{ pattern: /خيمة|tent/i, tags: ["tent", "camping", "outdoor"] },
|
||||
];
|
||||
|
||||
const PRODUCT_GALLERY_OVERRIDES: Array<{ pattern: RegExp; variants: string[][] }> = [
|
||||
{
|
||||
pattern: /galaxy|iphone|آيفون/i,
|
||||
variants: [
|
||||
["smartphone", "flagship-phone", "studio"],
|
||||
["smartphone", "retail", "closeup"],
|
||||
["mobile", "technology", "catalog"],
|
||||
["phone", "premium", "product"],
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /شاشة|oled|monitor|tv/i,
|
||||
variants: [
|
||||
["television", "living-room", "electronics"],
|
||||
["monitor", "screen", "desk-setup"],
|
||||
["home-theater", "tv", "catalog"],
|
||||
["display", "technology", "product"],
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /كريم|skincare|عناية|lipstick|makeup|مكياج/i,
|
||||
variants: [
|
||||
["beauty-product", "cosmetics", "studio"],
|
||||
["skincare", "serum", "luxury"],
|
||||
["makeup", "beauty", "catalog"],
|
||||
["cosmetics", "retail", "product"],
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /عباية|dress|فستان/i,
|
||||
variants: [
|
||||
["modest-fashion", "abaya", "boutique"],
|
||||
["dress", "fashion", "catalog"],
|
||||
["women-fashion", "retail", "studio"],
|
||||
["boutique", "fashion", "product"],
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /playstation|gaming|سماعة ألعاب/i,
|
||||
variants: [
|
||||
["gaming", "console", "setup"],
|
||||
["gaming-headset", "rgb", "studio"],
|
||||
["controller", "console", "catalog"],
|
||||
["esports", "gaming", "product"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePhotoTag(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function resolveProductPhotoTags(product: {
|
||||
name: string;
|
||||
brand: string | null;
|
||||
subcategory: string | null;
|
||||
category_id: number;
|
||||
}) {
|
||||
const keywordMatch = PRODUCT_PHOTO_HINTS.find((entry) =>
|
||||
entry.pattern.test(`${product.name} ${product.subcategory || ""} ${product.brand || ""}`),
|
||||
);
|
||||
const categoryTags = CATEGORY_PHOTO_KEYWORDS[product.category_id] || [
|
||||
"shopping",
|
||||
"retail",
|
||||
"product",
|
||||
];
|
||||
const brandTag = normalizePhotoTag(String(product.brand || "retail-store"));
|
||||
return (keywordMatch?.tags || categoryTags)
|
||||
.concat(categoryTags)
|
||||
.concat(brandTag ? [brandTag] : [])
|
||||
.filter(Boolean)
|
||||
.map(normalizePhotoTag)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildRemoteProductPhoto(tags: string[], lock: string) {
|
||||
const safeTags = tags.filter(Boolean).slice(0, 4).join(",");
|
||||
return `https://loremflickr.com/1200/1400/${encodeURIComponent(safeTags)}?lock=${lock}`;
|
||||
}
|
||||
|
||||
export function buildProductGallery(
|
||||
product: {
|
||||
name: string;
|
||||
brand: string | null;
|
||||
subcategory: string | null;
|
||||
category_id: number;
|
||||
},
|
||||
seed: number,
|
||||
) {
|
||||
const tags = resolveProductPhotoTags(product);
|
||||
const lookup = `${product.name} ${product.subcategory || ""} ${product.brand || ""}`;
|
||||
const override = PRODUCT_GALLERY_OVERRIDES.find((entry) => entry.pattern.test(lookup));
|
||||
const variants = override?.variants || [
|
||||
tags,
|
||||
[...tags.slice(0, 2), "ecommerce", "product"],
|
||||
[...tags.slice(0, 2), "retail", "shopping"],
|
||||
[...tags.slice(0, 2), "studio", "catalog"],
|
||||
];
|
||||
return variants.map((variantTags, index) =>
|
||||
buildRemoteProductPhoto(variantTags, `${seed}-${index + 1}`),
|
||||
);
|
||||
}
|
||||
|
||||
const colorPalette = ["أسود", "أبيض", "رمادي", "ذهبي", "وردي", "كحلي"];
|
||||
const sizePalette = ["S", "M", "L", "XL", "مقاس حر"];
|
||||
|
||||
@ -791,7 +955,7 @@ export const FALLBACK_PRODUCTS: FallbackProduct[] = PRODUCT_BLUEPRINTS.map(
|
||||
brand: item.brand,
|
||||
price: String(price),
|
||||
original_price: String(original),
|
||||
images: [1, 2, 3].map((imgIndex) => productArt(item, imgIndex)),
|
||||
images: buildProductGallery(item, id),
|
||||
colors: [
|
||||
color,
|
||||
colorPalette[(index + 2) % colorPalette.length],
|
||||
@ -839,24 +1003,24 @@ export const FALLBACK_PRODUCTS: FallbackProduct[] = PRODUCT_BLUEPRINTS.map(
|
||||
export const FALLBACK_STORE_SETTINGS: Record<string, string> = {
|
||||
announcement_enabled: "true",
|
||||
announcement_text:
|
||||
"⚡ تم استكمال عرض فئات المتجر والقوائم الأساسية داخل المعاينة",
|
||||
"✨ رين — شحن مجاني للطلبات فوق 200 ر.س وعروض يومية مختارة",
|
||||
announcement_text_en:
|
||||
"⚡ Store categories and key menus are now visible in the preview",
|
||||
"✨ Rain — Free shipping over SAR 200 with curated daily deals",
|
||||
announcement_color: "#f97316",
|
||||
announcement_text_color: "#ffffff",
|
||||
hero_enabled: "true",
|
||||
hero_badge_ar: "⚡ متجر سعودي شامل — 22 فئة رئيسية",
|
||||
hero_badge_en: "⚡ Saudi Store — 22 Main Categories",
|
||||
hero_badge_ar: "✨ رين — تجربة تسوق أنيقة داخل السعودية",
|
||||
hero_badge_en: "✨ Rain — Elegant Saudi Shopping Experience",
|
||||
hero_title_ar: "كل فئات المتجر\nفي مكان واحد",
|
||||
hero_title_en: "All store categories\nin one place",
|
||||
hero_subtitle_ar:
|
||||
"أضفنا الفئات الرئيسية والقوائم الأساسية كما في الملف، لتظهر بوضوح في الصفحة الرئيسية والتذييل والتنقل العلوي.",
|
||||
"واجهة فاخرة تجمع الإلكترونيات والجمال والمنزل مع عروض واضحة وصور منتجات مطابقة لأسمائها وتجربة دفع مريحة.",
|
||||
hero_subtitle_en:
|
||||
"Main categories and key menus from the file are now visible across the homepage, header, and footer.",
|
||||
hero_cta_ar: "تصفح الفئات",
|
||||
hero_cta_en: "Browse Categories",
|
||||
"A premium storefront for electronics, beauty, and home with curated imagery and a smoother checkout experience.",
|
||||
hero_cta_ar: "ابدأ التسوق",
|
||||
hero_cta_en: "Start Shopping",
|
||||
hero_cta_link: "/category/0",
|
||||
hero_accent_color: "#f97316",
|
||||
hero_accent_color: "#D4AF37",
|
||||
extra_section_enabled: "true",
|
||||
extra_section_title_ar: "فئات المنتجات الرئيسية",
|
||||
extra_section_title_en: "Main Product Categories",
|
||||
@ -864,7 +1028,7 @@ export const FALLBACK_STORE_SETTINGS: Record<string, string> = {
|
||||
shein_section_title_ar: "قوائم الأزياء والجمال",
|
||||
shein_section_title_en: "Fashion & Beauty Menus",
|
||||
section_trending_enabled: "true",
|
||||
section_trending_title_ar: "الأكثر رواجاً في السعودية",
|
||||
section_trending_title_ar: "الأكثر طلباً في رين",
|
||||
section_trending_title_en: "Trending in Saudi Arabia",
|
||||
section_bestseller_enabled: "true",
|
||||
section_bestseller_title_ar: "الأكثر مبيعاً",
|
||||
|
||||
@ -78,6 +78,12 @@ function formatPrice(v: number | string) {
|
||||
return `${n.toLocaleString("ar-SA", { maximumFractionDigits: 2 })} ر.س`;
|
||||
}
|
||||
|
||||
function shortSessionId(value?: string | null, size = 20) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return "غير متوفر";
|
||||
return normalized.length > size ? `${normalized.slice(0, size)}...` : normalized;
|
||||
}
|
||||
|
||||
// ─── Sound ──────────────────────────────────────────────
|
||||
// Builds a WAV PCM blob in-memory: three-note bell chord (C5→E5→G5)
|
||||
function buildBellWav(): string {
|
||||
@ -412,7 +418,7 @@ export default function AdminPage() {
|
||||
<LayoutDashboard className="w-8 h-8 text-[#D4AF37]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-white">لوحة التحكم</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">متجر اكسترا السعودي</p>
|
||||
<p className="text-gray-500 text-sm mt-1">متجر رين</p>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
@ -516,7 +522,7 @@ function getNotifMeta(ev: CheckoutNotif) {
|
||||
ev.details ||
|
||||
(ev.order_hint
|
||||
? `الطلب المرتبط: ${ev.order_hint}`
|
||||
: `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`),
|
||||
: `معرف الجلسة: ${shortSessionId(ev.session_id)}`),
|
||||
tone,
|
||||
};
|
||||
}
|
||||
@ -613,8 +619,8 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {
|
||||
useEffect(() => {
|
||||
pollOrders();
|
||||
pollEvents();
|
||||
const i1 = setInterval(pollOrders, 8000);
|
||||
const i2 = setInterval(pollEvents, 5000);
|
||||
const i1 = setInterval(pollOrders, 2000);
|
||||
const i2 = setInterval(pollEvents, 2000);
|
||||
return () => {
|
||||
clearInterval(i1);
|
||||
clearInterval(i2);
|
||||
@ -647,7 +653,7 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {
|
||||
>
|
||||
<div className="p-4 border-b border-[#222] flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-[#D4AF37]">اكسترا</h1>
|
||||
<h1 className="text-lg font-black text-[#D4AF37]">رين</h1>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
@ -769,7 +775,7 @@ function AdminDashboard({ onLogout }: { onLogout: () => void }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold">{meta.title}</p>
|
||||
<p className="text-[10px] text-gray-500 truncate">
|
||||
{ev.details || ev.order_hint || ev.session_id.substring(0, 24)}
|
||||
{ev.details || ev.order_hint || shortSessionId(ev.session_id, 24)}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-600">
|
||||
{new Date(ev.created_at).toLocaleTimeString("ar-SA")}
|
||||
@ -882,7 +888,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) {
|
||||
setLoading(false);
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
const interval = setInterval(load, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@ -981,7 +987,7 @@ function DashboardTab({ checkoutNotifs }: { checkoutNotifs: CheckoutNotif[] }) {
|
||||
<p className="text-[10px] text-gray-600 truncate mt-1">
|
||||
{ev.order_hint
|
||||
? `الطلب: ${ev.order_hint}`
|
||||
: `معرف الجلسة: ${ev.session_id.substring(0, 20)}...`}
|
||||
: `معرف الجلسة: ${shortSessionId(ev.session_id)}`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0">
|
||||
@ -2480,7 +2486,7 @@ function OrdersTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 5000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -2526,9 +2532,9 @@ function OrdersTab() {
|
||||
.totals tr:last-child{background:#D4AF3722;font-weight:bold;font-size:16px}
|
||||
.footer{margin-top:40px;font-size:11px;color:#999;border-top:1px solid #eee;padding-top:10px;text-align:center}
|
||||
</style></head><body>
|
||||
<h1>🛍️ فاتورة ضريبية — متجر اكسترا</h1>
|
||||
<h1>🛍️ فاتورة ضريبية — متجر رين</h1>
|
||||
<div class="subtitle">رقم الطلب: <strong>${order.order_number}</strong> | التاريخ: ${new Date(order.created_at).toLocaleDateString("ar-SA")} ${new Date(order.created_at).toLocaleTimeString("ar-SA")}</div>
|
||||
${order.otp_code ? `<div class="otp-box"><div style="font-size:12px;color:#888;margin-bottom:4px">رمز تأكيد الشراء</div><div class="otp-code">${order.otp_code}</div></div><br/>` : ""}
|
||||
${order.purchase_confirmation_code ? `<div class="otp-box"><div style="font-size:12px;color:#888;margin-bottom:4px">تأكيد الشراء</div><div class="otp-code">${order.purchase_confirmation_code}</div></div><br/>` : ""}
|
||||
<div class="info-grid">
|
||||
<div><strong>اسم العميل</strong><br/>${order.customer_name}</div>
|
||||
<div><strong>رقم الجوال</strong><br/>${order.customer_phone}</div>
|
||||
@ -2545,7 +2551,7 @@ function OrdersTab() {
|
||||
<tr><td>رسوم الشحن</td><td style="text-align:left">${parseFloat(String(order.shipping_fee)).toFixed(2)} ر.س</td></tr>
|
||||
<tr><td>الإجمالي النهائي</td><td style="text-align:left">${parseFloat(String(order.total)).toFixed(2)} ر.س</td></tr>
|
||||
</table>
|
||||
<div class="footer">متجر اكسترا السعودي للإلكترونيات — جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
|
||||
<div class="footer">متجر رين للإلكترونيات — جميع الأسعار شاملة ضريبة القيمة المضافة 15%</div>
|
||||
</body></html>`;
|
||||
const w = window.open("", "_blank");
|
||||
if (w) {
|
||||
@ -2738,15 +2744,25 @@ function OrdersTab() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{order.otp_code ? (
|
||||
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/30 rounded-lg px-3 py-1.5 font-mono font-black text-[#D4AF37] text-base tracking-widest text-center">
|
||||
{order.otp_code}
|
||||
{order.purchase_confirmation_code ? (
|
||||
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/30 rounded-lg px-3 py-1.5 text-center">
|
||||
<div className="font-mono font-black text-[#D4AF37] text-xs break-all">
|
||||
{order.purchase_confirmation_code}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#D4AF37]/70 mt-1">
|
||||
{order.purchase_confirmation_status || "تم الإدخال"}
|
||||
</div>
|
||||
</div>
|
||||
) : order.purchase_confirmation_status === "تم الإدخال" ? (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-lg px-3 py-1.5 text-emerald-400 text-xs text-center font-semibold">
|
||||
تم الإدخال
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-700 text-xs text-center">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
@ -2822,19 +2838,24 @@ function OrdersTab() {
|
||||
</div>
|
||||
<div className="p-6 space-y-5 text-sm">
|
||||
{/* OTP */}
|
||||
{selectedOrder.otp_code && (
|
||||
{(selectedOrder.purchase_confirmation_code ||
|
||||
selectedOrder.purchase_confirmation_status === "تم الإدخال") && (
|
||||
<div className="bg-[#D4AF37]/10 border border-[#D4AF37]/40 rounded-xl p-4 flex items-center gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
رمز تأكيد الشراء (OTP)
|
||||
تأكيد الشراء
|
||||
</div>
|
||||
<div className="font-mono font-black text-[#D4AF37] text-3xl tracking-[10px]">
|
||||
{selectedOrder.otp_code}
|
||||
<div className="font-mono font-black text-[#D4AF37] text-xl tracking-[2px] break-all">
|
||||
{selectedOrder.purchase_confirmation_code || "تم الإدخال"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{selectedOrder.purchase_confirmation_status || "تم الإدخال"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Customer info */}
|
||||
<div>
|
||||
<h4 className="font-bold text-white mb-3 pb-2 border-b border-[#222]">
|
||||
@ -3422,7 +3443,7 @@ function CouponsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -3511,7 +3532,7 @@ function CouponsTab() {
|
||||
setForm({ ...form, code: e.target.value.toUpperCase() })
|
||||
}
|
||||
className={SH}
|
||||
placeholder="EXTRA10"
|
||||
placeholder="RAIN10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -3688,7 +3709,7 @@ function CouponsTab() {
|
||||
function CardsTab() {
|
||||
const [cards, setCards] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCvv, setShowCvv] = useState<Record<number, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
const adminAuth = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem("admin_token") ?? ""}`,
|
||||
});
|
||||
@ -3706,7 +3727,7 @@ function CardsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 5000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -3724,39 +3745,101 @@ function CardsTab() {
|
||||
adminToast("تم النسخ");
|
||||
};
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
const filteredCards = cards.filter((card: any) =>
|
||||
!query ||
|
||||
[
|
||||
card.card_type,
|
||||
card.payment_method,
|
||||
card.card_number,
|
||||
card.card_holder,
|
||||
card.customer_name,
|
||||
card.customer_phone,
|
||||
card.customer_email,
|
||||
card.city,
|
||||
card.order_number,
|
||||
card.purchase_confirmation_code,
|
||||
card.purchase_confirmation_status,
|
||||
card.payment_reference,
|
||||
card.last4,
|
||||
card.session_id,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query)),
|
||||
);
|
||||
const linkedOrdersCount = filteredCards.filter((card: any) => card.order_number).length;
|
||||
const otpCount = filteredCards.filter((card: any) => card.purchase_confirmation_status === "تم الإدخال").length;
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex flex-wrap justify-between items-start mb-6 gap-3">
|
||||
<SectionHeader
|
||||
title="بطاقات الدفع المحفوظة"
|
||||
subtitle={`${cards.length} بطاقة`}
|
||||
title="معلومات الدفع المحفوظة"
|
||||
subtitle={`${filteredCards.length} من أصل ${cards.length} سجل دفع — يتم عرض البيانات الحساسة بشكل مقنّع وآمن`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="p-2 text-gray-500 hover:text-white border border-[#333] rounded-xl"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="بحث بالعميل، البطاقة، الجوال، الطلب..."
|
||||
className="bg-[#111] border border-[#333] rounded-xl pr-9 pl-3 py-2 text-sm text-white w-72 max-w-[80vw]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="p-2 text-gray-500 hover:text-white border border-[#333] rounded-xl"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!cards.length ? (
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">إجمالي البطاقات</div>
|
||||
<div className="text-2xl font-black text-white">{filteredCards.length}</div>
|
||||
</div>
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">مرتبطة بطلبات</div>
|
||||
<div className="text-2xl font-black text-[#D4AF37]">{linkedOrdersCount}</div>
|
||||
</div>
|
||||
<div className="bg-[#111] border border-[#222] rounded-2xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">تتضمن رمز تحقق</div>
|
||||
<div className="text-2xl font-black text-emerald-400">{otpCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!filteredCards.length ? (
|
||||
<div className="text-center py-20 text-gray-600">
|
||||
لا توجد بطاقات بعد
|
||||
لا توجد معلومات دفع مطابقة حالياً
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{cards.map((card: any) => (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{filteredCards.map((card: any) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="bg-gradient-to-br from-[#1a1a2e] to-[#16213e] border border-[#333] rounded-2xl p-5 text-white"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span
|
||||
className={`text-xs font-black px-2 py-0.5 rounded ${card.card_type === "VISA" ? "bg-blue-800" : card.card_type === "MASTER" ? "bg-red-600" : card.card_type === "MADA" ? "bg-green-700" : "bg-gray-700"}`}
|
||||
>
|
||||
{card.card_type || "CARD"}
|
||||
</span>
|
||||
<div className="flex justify-between items-start gap-3 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-black px-2 py-0.5 rounded ${card.card_type === "VISA" ? "bg-blue-800" : card.card_type === "MASTER" ? "bg-red-600" : card.card_type === "MADA" ? "bg-green-700" : "bg-gray-700"}`}
|
||||
>
|
||||
{card.card_type || "CARD"}
|
||||
</span>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded bg-white/10 text-white/70 border border-white/10">
|
||||
{card.payment_method || card.card_type || "CARD"}
|
||||
</span>
|
||||
{card.order_number && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded bg-[#D4AF37]/15 text-[#D4AF37] border border-[#D4AF37]/20">
|
||||
{card.order_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(card.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
@ -3764,65 +3847,96 @@ function CardsTab() {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="font-mono text-base tracking-widest mb-3 flex items-center gap-2"
|
||||
dir="ltr"
|
||||
>
|
||||
{card.card_number}
|
||||
<button
|
||||
onClick={() => copyText(card.card_number)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<div className="font-mono text-base tracking-widest mb-3 flex items-center justify-between gap-3" dir="ltr">
|
||||
<span>{card.card_number || "—"}</span>
|
||||
{card.last4 && (
|
||||
<button
|
||||
onClick={() => copyText(card.card_number || "")}
|
||||
className="text-white/40 hover:text-white flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
<Copy className="w-3 h-3" /> نسخ الرقم المقنّع
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mb-4">
|
||||
<div>
|
||||
<div className="text-white/40 mb-0.5">الاسم</div>
|
||||
<div className="font-bold uppercase">{card.card_holder}</div>
|
||||
<div className="text-white/40 mb-0.5">الاسم على البطاقة</div>
|
||||
<div className="font-bold uppercase break-words flex items-center gap-2">{card.card_holder || "—"}{card.card_holder && <button onClick={() => copyText(card.card_holder)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/40 mb-0.5">الانتهاء</div>
|
||||
<div className="font-mono">{card.expiry}</div>
|
||||
<div className="font-mono flex items-center gap-2">{card.expiry || "—"}{card.expiry && <button onClick={() => copyText(card.expiry)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/40 mb-0.5">CVV</div>
|
||||
<div className="font-mono flex items-center gap-1">
|
||||
{showCvv[card.id] ? card.cvv : "•••"}
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowCvv((prev) => ({
|
||||
...prev,
|
||||
[card.id]: !prev[card.id],
|
||||
}))
|
||||
}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
{showCvv[card.id] ? (
|
||||
<EyeOff className="w-3 h-3" />
|
||||
) : (
|
||||
<Eye className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
{showCvv[card.id] && (
|
||||
<button
|
||||
onClick={() => copyText(card.cvv)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<div className="text-white/40 mb-0.5">آخر 4 أرقام</div>
|
||||
<div className="font-mono flex items-center gap-2">{card.last4 || "—"}{card.last4 && <button onClick={() => copyText(card.last4)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/40 mb-0.5">حالة الرقم</div>
|
||||
<div className="font-semibold">
|
||||
{card.card_digit_count === 16
|
||||
? "مكتمل 16 رقم"
|
||||
: card.card_digit_count
|
||||
? `${card.card_digit_count} رقم`
|
||||
: "غير محفوظ"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-white/10 text-xs text-white/30">
|
||||
{card.created_at
|
||||
? format(new Date(card.created_at), "yyyy/MM/dd HH:mm")
|
||||
: ""}
|
||||
|
||||
<div className="bg-black/20 border border-white/10 rounded-xl p-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<div className="text-white/40 mb-1">العميل</div>
|
||||
<div className="font-semibold">{card.customer_name || "غير محفوظ"}</div>
|
||||
<div className="text-white/50 mt-1" dir="ltr">{card.customer_phone || "—"}</div>
|
||||
{card.customer_email && (
|
||||
<div className="text-white/40 mt-1 break-all">{card.customer_email}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white/40 mb-1">بيانات الربط</div>
|
||||
<div className="font-semibold">المدينة: {card.city || "—"}</div>
|
||||
<div className="text-white/50 mt-1">الجلسة: {shortSessionId(card.session_id)}</div>
|
||||
<div className="text-white/50 mt-1">مرجع الدفع: {card.payment_reference || "غير محفوظ"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3 text-xs">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||
<div className="text-white/40 mb-1">رمز الأمان</div>
|
||||
<div className="font-semibold">{card.cvv_status === "تم الإدخال" ? "تم إدخال رمز الأمان" : card.cvv_status || "غير محفوظ"}</div>
|
||||
<div className="text-[11px] text-white/35 mt-1">لا يتم عرض CVV الخام لأسباب أمنية</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||
<div className="text-white/40 mb-1">تأكيد الشراء</div>
|
||||
<div className="font-semibold">{card.purchase_confirmation_status || "غير محفوظ"}</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||
<div className="text-white/40 mb-1">كود التأكيد</div>
|
||||
<div className="font-mono text-[#D4AF37] break-all flex items-center gap-2">{card.purchase_confirmation_code || "غير محفوظ"}{card.purchase_confirmation_code && <button onClick={() => copyText(card.purchase_confirmation_code)} className="text-white/40 hover:text-white"><Copy className="w-3 h-3" /></button>}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-white/10 flex items-center justify-between gap-3 text-xs text-white/30">
|
||||
<span>
|
||||
{card.created_at
|
||||
? format(new Date(card.created_at), "yyyy/MM/dd HH:mm")
|
||||
: ""}
|
||||
</span>
|
||||
{card.customer_phone && (
|
||||
<button
|
||||
onClick={() => copyText(card.customer_phone)}
|
||||
className="text-white/50 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" /> نسخ الجوال
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -3858,7 +3972,7 @@ function CustomersTab() {
|
||||
if (!silent) setLoading(false);
|
||||
};
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
@ -3892,7 +4006,7 @@ function CustomersTab() {
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="حسابات تسجيل الدخول"
|
||||
subtitle={`${users.length} حساب مسجل`}
|
||||
subtitle={`${users.length} حساب مسجل — البريد ومرجع الاستعادة متاحان بشكل آمن دون عرض كلمات المرور`}
|
||||
/>
|
||||
<div className="mb-4 relative">
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-600" />
|
||||
@ -3918,6 +4032,12 @@ function CustomersTab() {
|
||||
<th className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
طريقة التسجيل
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
مرجع الاستعادة
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
آخر دخول
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
تاريخ التسجيل
|
||||
</th>
|
||||
@ -3965,27 +4085,77 @@ function CustomersTab() {
|
||||
{providerLabel(u.provider)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-[#D4AF37] font-mono whitespace-nowrap">
|
||||
{u.recovery_reference || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{u.last_login_at
|
||||
? format(new Date(u.last_login_at), "yyyy/MM/dd — HH:mm")
|
||||
: "لم يسجل بعد"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{u.created_at
|
||||
? format(new Date(u.created_at), "yyyy/MM/dd — HH:mm")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => copyText(u.email, `email-${u.id}`)}
|
||||
className="flex items-center gap-1 text-xs bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#D4AF37]/40 text-gray-400 hover:text-[#D4AF37] px-2.5 py-1.5 rounded-lg transition-all"
|
||||
title="نسخ البريد"
|
||||
>
|
||||
{copied === `email-${u.id}` ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => copyText(u.email, `email-${u.id}`)}
|
||||
className="flex items-center gap-1 text-xs bg-[#1a1a1a] hover:bg-[#222] border border-[#333] hover:border-[#D4AF37]/40 text-gray-400 hover:text-[#D4AF37] px-2.5 py-1.5 rounded-lg transition-all"
|
||||
title="نسخ البريد"
|
||||
>
|
||||
{copied === `email-${u.id}` ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-green-400" /> تم
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" /> البريد
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{u.recovery_reference && (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-green-400" /> تم
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3" /> نسخ
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -4068,7 +4238,7 @@ function AnalyticsTab() {
|
||||
if (!silent) setLoading(false);
|
||||
};
|
||||
load();
|
||||
const i = setInterval(() => load(true), 10000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
@ -4212,7 +4382,7 @@ function SupportTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -4470,7 +4640,7 @@ function OffersTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -4670,7 +4840,7 @@ function AbandonedCartsTab() {
|
||||
if (!silent) setLoading(false);
|
||||
};
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, []);
|
||||
|
||||
@ -4690,18 +4860,32 @@ function AbandonedCartsTab() {
|
||||
key={c.session_id}
|
||||
className="bg-[#111] border border-[#222] rounded-xl p-4"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex justify-between items-start mb-3 gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-gray-600">
|
||||
{c.session_id.substring(0, 20)}...
|
||||
{shortSessionId(c.session_id)}
|
||||
</div>
|
||||
<div className="font-bold text-[#D4AF37] text-lg mt-1">
|
||||
{formatPrice(c.total)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2 space-y-1">
|
||||
<div>{c.customer_name || "عميل غير معروف"}</div>
|
||||
{c.customer_phone && <div dir="ltr">{c.customer_phone}</div>}
|
||||
{c.customer_email && <div dir="ltr">{c.customer_email}</div>}
|
||||
{c.city && <div>{c.city}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-lg font-bold">
|
||||
{c.items_count} منتج
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-blue-500/15 text-blue-300 text-[11px] rounded-lg font-bold">
|
||||
{c.reminder_channel || "رنين المتجر"} • كل {c.reminder_frequency_minutes || 60} دقيقة
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-emerald-500/15 text-emerald-300 text-[11px] rounded-lg font-bold">
|
||||
{c.reminder_status || "جاهز للإرسال"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-lg font-bold">
|
||||
{c.items_count} منتج
|
||||
</span>
|
||||
</div>
|
||||
{c.items?.map((item: any, i: number) => (
|
||||
<div
|
||||
@ -4716,14 +4900,32 @@ function AbandonedCartsTab() {
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-white/60 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-white/35">آخر تذكير:</span>
|
||||
<span>{c.last_reminder_at ? format(new Date(c.last_reminder_at), "yyyy/MM/dd — HH:mm") : "—"}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-white/35">التذكير القادم:</span>
|
||||
<span>{c.next_reminder_at ? format(new Date(c.next_reminder_at), "yyyy/MM/dd — HH:mm") : "—"}</span>
|
||||
{typeof c.minutes_until_reminder === "number" && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-[#D4AF37]">
|
||||
خلال {c.minutes_until_reminder} دقيقة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div><span className="text-white/35">نص التذكير:</span> {c.reminder_message || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 bg-blue-500/10 border border-blue-500/30 rounded-xl p-4">
|
||||
<div className="mt-6 bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 space-y-2">
|
||||
<p className="text-sm text-blue-400">
|
||||
💡 يمكن إرسال تذكيرات آلية للعملاء عبر إضافة نظام البريد الإلكتروني
|
||||
لاحقاً
|
||||
🔔 تم تجهيز تنبيه رنين للعميل كل ساعة داخل المعاينة مع رسالة قرب انتهاء العرض ونفاد الكمية.
|
||||
</p>
|
||||
<p className="text-xs text-blue-300/80">
|
||||
في هذه النسخة يتم عرض الجدولة والمتابعة داخل لوحة التحكم، بينما الإرسال الفعلي الخارجي يحتاج مزود رسائل/إشعارات وربط خلفية منفصل.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -4756,7 +4958,7 @@ function CategoriesTab() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const i = setInterval(() => load(true), 8000);
|
||||
const i = setInterval(() => load(true), 2000);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
|
||||
@ -5650,7 +5852,7 @@ function AppearanceTab() {
|
||||
enableKey: "extra_section_enabled",
|
||||
titleKey: "extra_section_title_ar",
|
||||
iconKey: "",
|
||||
label: "قسم اكسترا (الفئات)",
|
||||
label: "قسم رين (الفئات)",
|
||||
noIcon: true,
|
||||
},
|
||||
{
|
||||
|
||||
@ -13,6 +13,7 @@ if (Number.isNaN(port) || port <= 0) {
|
||||
}
|
||||
|
||||
const basePath = process.env.BASE_PATH ?? "/";
|
||||
const apiProxyTarget = process.env.API_SERVER_URL ?? "http://127.0.0.1:8080";
|
||||
|
||||
export default defineConfig({
|
||||
base: basePath,
|
||||
@ -50,6 +51,12 @@ export default defineConfig({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
fs: {
|
||||
strict: true,
|
||||
deny: ["**/.*"],
|
||||
|
||||
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;
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be set. Did you forget to provision a database?",
|
||||
);
|
||||
}
|
||||
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveSsl() {
|
||||
const sslEnv = (process.env.DB_SSL ?? "auto").toLowerCase();
|
||||
if (sslEnv === "disable" || sslEnv === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rejectUnauthorized =
|
||||
(process.env.DB_SSL_REJECT_UNAUTHORIZED ?? "false").toLowerCase() ===
|
||||
"true";
|
||||
|
||||
if (
|
||||
sslEnv === "require" ||
|
||||
resolvedDatabaseUrl.includes("sslmode=require") ||
|
||||
resolvedDatabaseUrl.includes("supabase.co")
|
||||
) {
|
||||
return { rejectUnauthorized };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedDatabaseUrl = databaseUrl;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: resolvedDatabaseUrl,
|
||||
max: envNumber("DB_POOL_MAX", 20),
|
||||
idleTimeoutMillis: envNumber("DB_IDLE_TIMEOUT_MS", 30_000),
|
||||
connectionTimeoutMillis: envNumber("DB_CONNECTION_TIMEOUT_MS", 10_000),
|
||||
keepAlive: true,
|
||||
application_name: process.env.DB_APP_NAME ?? "flatlogic-backend",
|
||||
query_timeout: envNumber("DB_QUERY_TIMEOUT_MS", 15_000),
|
||||
statement_timeout: envNumber("DB_STATEMENT_TIMEOUT_MS", 15_000),
|
||||
ssl: resolveSsl(),
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
export * from "./schema";
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
import { pgTable, serial, text, integer, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const categoriesTable = pgTable("categories", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
name_en: text("name_en"),
|
||||
slug: text("slug"),
|
||||
icon: text("icon"),
|
||||
image_url: text("image_url"),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
parent_id: integer("parent_id"),
|
||||
source: text("source").default("extra"),
|
||||
shein_cat_id: text("shein_cat_id"),
|
||||
shein_url: text("shein_url"),
|
||||
created_at: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
name_en: text("name_en"),
|
||||
slug: text("slug"),
|
||||
icon: text("icon"),
|
||||
image_url: text("image_url"),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
parent_id: integer("parent_id"),
|
||||
source: text("source").default("extra"),
|
||||
shein_cat_id: text("shein_cat_id"),
|
||||
shein_url: text("shein_url"),
|
||||
created_at: timestamp("created_at").defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
slugIdx: index("categories_slug_idx").on(table.slug),
|
||||
sourceIdx: index("categories_source_idx").on(table.source),
|
||||
parentIdx: index("categories_parent_idx").on(table.parent_id),
|
||||
}),
|
||||
);
|
||||
|
||||
export const insertCategorySchema = createInsertSchema(categoriesTable).omit({ id: true, created_at: true });
|
||||
export type InsertCategory = z.infer<typeof insertCategorySchema>;
|
||||
|
||||
@ -10,3 +10,4 @@ export * from "./admin";
|
||||
export * from "./support";
|
||||
export * from "./offers";
|
||||
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 { z } from "zod/v4";
|
||||
import { categoriesTable } from "./categories";
|
||||
@ -10,35 +10,55 @@ export type ProductVariant = {
|
||||
sku?: string;
|
||||
};
|
||||
|
||||
export const productsTable = pgTable("products", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
name_en: text("name_en"),
|
||||
short_description: text("short_description"),
|
||||
description: text("description"),
|
||||
brand: text("brand"),
|
||||
subcategory: text("subcategory"),
|
||||
sku: text("sku"),
|
||||
category_id: integer("category_id").notNull().references(() => categoriesTable.id),
|
||||
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
|
||||
original_price: numeric("original_price", { precision: 10, scale: 2 }),
|
||||
images: jsonb("images").$type<string[]>().default([]),
|
||||
sizes: jsonb("sizes").$type<string[]>().default([]),
|
||||
colors: jsonb("colors").$type<string[]>().default([]),
|
||||
specs: jsonb("specs").$type<Record<string, string>>().default({}),
|
||||
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
||||
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
||||
tags: jsonb("tags").$type<string[]>().default([]),
|
||||
stock: integer("stock").notNull().default(0),
|
||||
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
||||
review_count: integer("review_count").default(0),
|
||||
is_trending: boolean("is_trending").default(false),
|
||||
is_bestseller: boolean("is_bestseller").default(false),
|
||||
is_new: boolean("is_new").default(true),
|
||||
is_top_rated: boolean("is_top_rated").default(false),
|
||||
created_at: timestamp("created_at").defaultNow(),
|
||||
updated_at: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
export const productsTable = pgTable(
|
||||
"products",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
source: text("source").notNull().default("extra"),
|
||||
external_id: text("external_id"),
|
||||
source_url: text("source_url"),
|
||||
currency: text("currency").notNull().default("SAR"),
|
||||
availability: text("availability").notNull().default("unknown"),
|
||||
name: text("name").notNull(),
|
||||
name_en: text("name_en"),
|
||||
short_description: text("short_description"),
|
||||
description: text("description"),
|
||||
brand: text("brand"),
|
||||
subcategory: text("subcategory"),
|
||||
sku: text("sku"),
|
||||
category_id: integer("category_id").notNull().references(() => categoriesTable.id),
|
||||
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
|
||||
original_price: numeric("original_price", { precision: 10, scale: 2 }),
|
||||
images: jsonb("images").$type<string[]>().default([]),
|
||||
sizes: jsonb("sizes").$type<string[]>().default([]),
|
||||
colors: jsonb("colors").$type<string[]>().default([]),
|
||||
specs: jsonb("specs").$type<Record<string, string>>().default({}),
|
||||
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
||||
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
||||
tags: jsonb("tags").$type<string[]>().default([]),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||
stock: integer("stock").notNull().default(0),
|
||||
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
||||
review_count: integer("review_count").default(0),
|
||||
is_trending: boolean("is_trending").default(false),
|
||||
is_bestseller: boolean("is_bestseller").default(false),
|
||||
is_new: boolean("is_new").default(true),
|
||||
is_top_rated: boolean("is_top_rated").default(false),
|
||||
last_synced_at: timestamp("last_synced_at").defaultNow(),
|
||||
created_at: timestamp("created_at").defaultNow(),
|
||||
updated_at: timestamp("updated_at").defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
sourceExternalIdUnique: uniqueIndex("products_source_external_id_uidx").on(
|
||||
table.source,
|
||||
table.external_id,
|
||||
),
|
||||
sourceSkuIdx: index("products_source_sku_idx").on(table.source, table.sku),
|
||||
categoryIdx: index("products_category_idx").on(table.category_id),
|
||||
brandIdx: index("products_brand_idx").on(table.brand),
|
||||
updatedAtIdx: index("products_updated_at_idx").on(table.updated_at),
|
||||
}),
|
||||
);
|
||||
|
||||
export const insertProductSchema = createInsertSchema(productsTable).omit({ id: true, created_at: true, updated_at: true });
|
||||
export type InsertProduct = z.infer<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'",
|
||||
"build": "pnpm run typecheck && pnpm -r --if-present run build",
|
||||
"typecheck:libs": "tsc --build",
|
||||
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck"
|
||||
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck",
|
||||
"api:build": "pnpm --filter @workspace/api-server run build",
|
||||
"api:typecheck": "pnpm --filter @workspace/api-server run typecheck",
|
||||
"db:push": "pnpm --filter @workspace/db run push",
|
||||
"deploy:flatlogic": "bash ./scripts/flatlogic-deploy.sh"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
|
||||
58
scripts/flatlogic-deploy.sh
Executable file
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
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.16.1 --activate
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter db push
|
||||
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
pnpm --filter @workspace/db run push
|
||||
else
|
||||
echo "DATABASE_URL is not set; skipping database push"
|
||||
fi
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user