Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:10 +03:30
co-authored by Cursor
commit d5cf76ee30
43 changed files with 7337 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
PORT=3001
POSTGRES_PORT=5433
POSTGRES_USER=ariyaway
POSTGRES_PASSWORD=ariyaway_secret
POSTGRES_DB=ariyaway
POSTGRES_CONTAINER=ariyaway-postgres
DATABASE_URL=postgresql://ariyaway:ariyaway_secret@127.0.0.1:5433/ariyaway
# Meshkee API — always remote (IranBay backend may be local)
MESHKEE_API_URL=https://api.meshkee.com/api/v1
# Prefer cell + password (access tokens expire quickly). Optional static token:
# MESHKEE_API_TOKEN=
MESHKEE_SUPERADMIN_CELL=
MESHKEE_SUPERADMIN_PASSWORD=
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
uploads/
.env
.env.*
!.env.example
*.log
.DS_Store
+61
View File
@@ -0,0 +1,61 @@
# Marketplace API
NestJS API for the IranBay marketplace gate.
## Architecture (Meshkee)
Two different Meshkee “business” concepts:
1. **IranBay platform tenant** (`iranbay.com`) — auth only. Customer accounts from
IranBay login/register are linked here via `business_customers`. Not a Meshkee storefront.
Bootstrap: `npm run bootstrap:iranbay` (needs Meshkee superadmin token).
2. **Producer shops** — each shop that registers on IranBay becomes a **separate** Meshkee
business + an IranBay marketplace listing (`POST /businesses/register-shop`).
Marketplace DB stores **only listing controls**:
`meshkee_business_id`, `slug`, `status`, `is_featured`, `sort_weight`.
Name, categories, and profile content live in **Meshkee**.
## Setup
```bash
cd backend
cp .env.example .env
docker compose up -d
npm install
npm run prisma:generate
npm run db:migrate
npm run start:dev
```
API: http://localhost:3001/api/v1
Required for shop self-register / Meshkee write-through:
```bash
MESHKEE_API_URL=https://api.meshkee.com/api/v1
MESHKEE_API_TOKEN= # Meshkee superadmin access token
```
Bootstrap platform tenant:
```bash
MESHKEE_API_TOKEN=… \
MESHKEE_SUPERADMIN_CELL=+98… \
MESHKEE_SUPERADMIN_PASSWORD=… \
npm run bootstrap:iranbay
```
## Routes (v1)
| Method | Path | Notes |
|--------|------|--------|
| `GET` | `/businesses` | Marketplace listings |
| `GET` | `/businesses/:slug` | Listing by slug |
| `GET` | `/businesses/by-id/:id` | Listing by id |
| `POST` | `/businesses/register-shop` | Auth user → new Meshkee shop + IranBay listing |
| `POST` | `/businesses` | Link existing Meshkee business (admin) |
| `PATCH` | `/businesses/:id` | Marketplace fields; name/slug/category → Meshkee |
| `DELETE` | `/businesses/:id` | Remove listing |
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MIGRATIONS_DIR="$ROOT_DIR/database/migrations"
CONTAINER="${POSTGRES_CONTAINER:-ariyaway-postgres}"
DB_USER="${POSTGRES_USER:-ariyaway}"
DB_NAME="${POSTGRES_DB:-ariyaway}"
"$ROOT_DIR/database/wait-for-postgres.sh"
run_migration() {
local file="$1"
echo "→ Running $(basename "$file")"
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file"
}
if [[ $# -gt 0 ]]; then
run_migration "$1"
else
for file in "$MIGRATIONS_DIR"/*.sql; do
[[ -f "$file" ]] || continue
run_migration "$file"
done
fi
echo "Done."
@@ -0,0 +1,127 @@
-- AriyaWay marketplace — categories + businesses
-- Content (products, blog, portfolio) stays in Meshkee.
-- This DB only tracks marketplace presence + taxonomy.
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- categories (marketplace taxonomy)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS categories (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
parent_id BIGINT,
slug VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
name_fa VARCHAR(255),
icon_url TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT categories_slug_unique UNIQUE (slug),
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
CONSTRAINT categories_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_categories_parent_sort
ON categories (parent_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_categories_is_active
ON categories (is_active)
WHERE is_active = TRUE;
DROP TRIGGER IF EXISTS categories_set_updated_at ON categories;
CREATE TRIGGER categories_set_updated_at
BEFORE UPDATE ON categories
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- businesses (marketplace listings linked to Meshkee)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS businesses (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
meshkee_business_id BIGINT NOT NULL,
slug VARCHAR(100) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
primary_category_id BIGINT,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
sort_weight INT NOT NULL DEFAULT 0,
website_url TEXT,
-- Cached display fields (filled when linking / syncing from Meshkee)
name VARCHAR(255) NOT NULL,
tagline VARCHAR(500),
description TEXT,
logo_url TEXT,
hero_url TEXT,
province VARCHAR(100),
country VARCHAR(100),
telephone VARCHAR(50),
since_year SMALLINT,
published_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT businesses_meshkee_business_id_unique UNIQUE (meshkee_business_id),
CONSTRAINT businesses_slug_unique UNIQUE (slug),
CONSTRAINT businesses_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
CONSTRAINT businesses_status_check CHECK (
status IN ('draft', 'pending_review', 'published', 'hidden', 'rejected')
),
CONSTRAINT businesses_primary_category_id_fkey
FOREIGN KEY (primary_category_id) REFERENCES categories (id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_businesses_status_featured
ON businesses (status, is_featured, sort_weight DESC);
CREATE INDEX IF NOT EXISTS idx_businesses_primary_category
ON businesses (primary_category_id);
CREATE INDEX IF NOT EXISTS idx_businesses_meshkee_id
ON businesses (meshkee_business_id);
DROP TRIGGER IF EXISTS businesses_set_updated_at ON businesses;
CREATE TRIGGER businesses_set_updated_at
BEFORE UPDATE ON businesses
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- business_categories (extra marketplace categories)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS business_categories (
business_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT business_categories_pkey PRIMARY KEY (business_id, category_id),
CONSTRAINT business_categories_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT business_categories_category_id_fkey
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_business_categories_category
ON business_categories (category_id);
-- ---------------------------------------------------------------------------
-- Seed top-level marketplace categories (matches website mega menu roots)
-- ---------------------------------------------------------------------------
INSERT INTO categories (slug, name, name_fa, sort_order)
VALUES
('industry', 'Industry', 'صنعت', 1),
('stores', 'Stores', 'فروشگاه‌ها', 2),
('wholesellers', 'Whole sellers', 'عمده‌فروشان', 3),
('services', 'Services', 'خدمات', 4),
('food', 'Food', 'مواد غذایی', 5)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1,320 @@
-- AriyaWay marketplace categories (Stores / Industries / Services) with search tags
ALTER TABLE categories ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_categories_tags ON categories USING GIN (tags);
UPDATE categories SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE;
-- Level 1
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
VALUES (NULL, 'Stores', 'فروشگاه‌ها', 'stores', 1, ARRAY['retail', 'shop', 'store', 'stores', 'فروشگاه', 'فروشگاه‌ها', 'مغازه']::text[], TRUE)
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
VALUES (NULL, 'Industries', 'صنایع', 'industries', 2, ARRAY['industrial', 'industries', 'industry', 'manufacturing', 'صنایع', 'صنعت', 'کارخانه']::text[], TRUE)
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
VALUES (NULL, 'Services', 'خدمات', 'services', 3, ARRAY['service', 'services', 'خدمات']::text[], TRUE)
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
-- Level 2
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
FROM (VALUES
('stores', 'Fashion & Apparel', 'مد و پوشاک', 'fashion-apparel', 'مد و پوشاک', 1, ARRAY['apparel', 'clothing', 'fashion', 'fashion & apparel', 'fashion apparel', 'مد', 'مد و پوشاک', 'پوشاک']::text[]),
('stores', 'Food & Grocery', 'مواد غذایی و سوپرمارکت', 'food-grocery', 'مواد غذایی و سوپرمارکت', 2, ARRAY['food', 'food & grocery', 'food grocery', 'grocery', 'خواربار', 'مواد غذایی', 'مواد غذایی و سوپرمارکت']::text[]),
('stores', 'Home & Living', 'خانه و زندگی', 'home-living-retail', 'خانه و زندگی', 3, ARRAY['home', 'home & living', 'home living retail', 'living', 'خانه', 'خانه و زندگی', 'لوازم خانگی']::text[]),
('stores', 'Electronics & Tech', 'الکترونیک و فناوری', 'electronics-tech-retail', 'الکترونیک و فناوری', 4, ARRAY['electronics', 'electronics & tech', 'electronics tech retail', 'tech', 'الکترونیک', 'الکترونیک و فناوری', 'دیجیتال']::text[]),
('stores', 'Beauty & Health Retail', 'آرایشی و بهداشتی', 'beauty-health-retail', 'آرایشی و بهداشتی', 5, ARRAY['beauty', 'beauty & health retail', 'beauty health retail', 'health retail', 'آرایشی', 'آرایشی و بهداشتی', 'بهداشتی']::text[]),
('industries', 'Manufacturing', 'تولید و ساخت', 'manufacturing', 'تولید و ساخت', 1, ARRAY['factory', 'manufacturing', 'تولید', 'تولید و ساخت', 'کارخانه']::text[]),
('industries', 'Construction & Building', 'ساختمان و عمران', 'construction-building', 'ساختمان و عمران', 2, ARRAY['building', 'construction', 'construction & building', 'construction building', 'ساختمان', 'ساختمان و عمران', 'عمران']::text[]),
('industries', 'Agriculture & Raw Materials', 'کشاورزی و مواد اولیه', 'agriculture-raw-materials', 'کشاورزی و مواد اولیه', 3, ARRAY['agriculture', 'agriculture & raw materials', 'agriculture raw materials', 'farming', 'کشاورزی', 'کشاورزی و مواد اولیه']::text[]),
('industries', 'Energy & Utilities', 'انرژی و تأسیسات', 'energy-utilities', 'انرژی و تأسیسات', 4, ARRAY['energy', 'energy & utilities', 'energy utilities', 'utilities', 'انرژی', 'انرژی و تأسیسات']::text[]),
('services', 'Professional Services', 'خدمات حرفه‌ای', 'professional-services', 'خدمات حرفه‌ای', 1, ARRAY['professional', 'professional services', 'حرفه ای', 'خدمات حرفه‌ای']::text[]),
('services', 'Technology Services', 'خدمات فناوری', 'technology-services', 'خدمات فناوری', 2, ARRAY['it service', 'tech service', 'technology services', 'خدمات فناوری', 'فناوری']::text[]),
('services', 'Personal & Local Services', 'خدمات محلی و شخصی', 'personal-local-services', 'خدمات محلی و شخصی', 3, ARRAY['local service', 'personal & local services', 'personal local services', 'personal service', 'خدمات محلی', 'خدمات محلی و شخصی']::text[]),
('services', 'Medical', 'پزشکی', 'medical', 'پزشکی', 4, ARRAY['doctor', 'healthcare', 'medical', 'درمان', 'پزشکی']::text[]),
('services', 'Automotive Services', 'خدمات خودرو', 'automotive-services', 'خدمات خودرو', 5, ARRAY['auto service', 'automotive services', 'car service', 'خدمات خودرو', 'خودرو']::text[])
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
JOIN categories p ON p.slug = v.parent_slug
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
-- Level 3
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
FROM (VALUES
('fashion-apparel', 'Clothing', 'لباس', 'clothing', 'لباس', 1, ARRAY['apparel', 'clothes', 'clothing', 'لباس']::text[]),
('fashion-apparel', 'Shoes & Bags', 'کفش و کیف', 'shoes-bags', 'کفش و کیف', 2, ARRAY['bags', 'footwear', 'shoes', 'shoes & bags', 'shoes bags', 'کفش', 'کفش و کیف', 'کیف']::text[]),
('fashion-apparel', 'Jewelry & Watches', 'جواهرات و ساعت', 'jewelry-watches', 'جواهرات و ساعت', 3, ARRAY['jewellery', 'jewelry', 'jewelry & watches', 'jewelry watches', 'watches', 'جواهر', 'جواهرات و ساعت', 'ساعت']::text[]),
('food-grocery', 'Supermarket & Grocery', 'سوپرمارکت و خواربار', 'supermarket-grocery', 'سوپرمارکت و خواربار', 1, ARRAY['grocery', 'supermarket', 'supermarket & grocery', 'supermarket grocery', 'سوپرمارکت', 'سوپرمارکت و خواربار', 'هایپر']::text[]),
('food-grocery', 'Specialty Food', 'مواد غذایی تخصصی', 'specialty-food', 'مواد غذایی تخصصی', 2, ARRAY['specialty food', 'مواد غذایی تخصصی', 'مواد غذایی خاص']::text[]),
('food-grocery', 'Beverages', 'نوشیدنی', 'beverages-retail', 'نوشیدنی', 3, ARRAY['beverage', 'beverages', 'beverages retail', 'drink', 'نوشیدنی']::text[]),
('home-living-retail', 'Furniture', 'مبلمان', 'furniture', 'مبلمان', 1, ARRAY['furniture', 'مبل', 'مبلمان']::text[]),
('home-living-retail', 'Home Decor', 'دکوراسیون', 'home-decor', 'دکوراسیون', 2, ARRAY['decor', 'decoration', 'home decor', 'دکور', 'دکوراسیون']::text[]),
('home-living-retail', 'Kitchen & Appliances', 'آشپزخانه و لوازم خانگی', 'kitchen-appliances', 'آشپزخانه و لوازم خانگی', 3, ARRAY['appliance', 'kitchen', 'kitchen & appliances', 'kitchen appliances', 'آشپزخانه', 'آشپزخانه و لوازم خانگی', 'لوازم خانگی']::text[]),
('electronics-tech-retail', 'Mobile & Accessories', 'موبایل و لوازم جانبی', 'mobile-accessories', 'موبایل و لوازم جانبی', 1, ARRAY['mobile', 'mobile & accessories', 'mobile accessories', 'phone', 'تلفن', 'موبایل', 'موبایل و لوازم جانبی']::text[]),
('electronics-tech-retail', 'Computers', 'کامپیوتر', 'computers', 'کامپیوتر', 2, ARRAY['computer', 'computers', 'pc', 'رایانه', 'کامپیوتر']::text[]),
('electronics-tech-retail', 'Audio & Visual', 'صوتی و تصویری', 'audio-visual', 'صوتی و تصویری', 3, ARRAY['audio', 'audio & visual', 'audio visual', 'av', 'visual', 'صوتی تصویری', 'صوتی و تصویری']::text[]),
('beauty-health-retail', 'Cosmetics & Personal Care', 'آرایشی و مراقبت شخصی', 'cosmetics-personal-care', 'آرایشی و مراقبت شخصی', 1, ARRAY['cosmetics', 'cosmetics & personal care', 'cosmetics personal care', 'personal care', 'آرایشی', 'آرایشی و مراقبت شخصی']::text[]),
('beauty-health-retail', 'Pharmacy & Wellness', 'داروخانه و سلامت', 'pharmacy-wellness-retail', 'داروخانه و سلامت', 2, ARRAY['pharmacy', 'pharmacy & wellness', 'pharmacy wellness retail', 'wellness', 'داروخانه', 'داروخانه و سلامت', 'سلامت']::text[]),
('manufacturing', 'Textile & Apparel', 'نساجی و پوشاک', 'textile-apparel-mfg', 'نساجی و پوشاک', 1, ARRAY['apparel mfg', 'textile', 'textile & apparel', 'textile apparel mfg', 'نساجی', 'نساجی و پوشاک', 'پوشاک']::text[]),
('manufacturing', 'Food Processing', 'صنایع غذایی', 'food-processing-mfg', 'صنایع غذایی', 2, ARRAY['food factory', 'food processing', 'food processing mfg', 'صنایع غذایی']::text[]),
('manufacturing', 'Metal & Machinery', 'فلز و ماشین‌آلات', 'metal-machinery-mfg', 'فلز و ماشین‌آلات', 3, ARRAY['machinery', 'metal', 'metal & machinery', 'metal machinery mfg', 'فلز', 'فلز و ماشین‌آلات', 'ماشین آلات']::text[]),
('manufacturing', 'Electrical & Cable', 'برق و کابل', 'electrical-cable-mfg', 'برق و کابل', 4, ARRAY['cable', 'electrical', 'electrical & cable', 'electrical cable mfg', 'wire', 'برق و کابل', 'سیم', 'کابل']::text[]),
('manufacturing', 'Automotive Parts', 'قطعات خودرو', 'automotive-parts-mfg', 'قطعات خودرو', 5, ARRAY['auto parts', 'automotive', 'automotive parts', 'automotive parts mfg', 'قطعات خودرو']::text[]),
('manufacturing', 'Chemicals & Materials', 'شیمیایی و مواد', 'chemicals-materials-mfg', 'شیمیایی و مواد', 6, ARRAY['chemical', 'chemicals & materials', 'chemicals materials mfg', 'materials', 'شیمیایی', 'شیمیایی و مواد']::text[]),
('manufacturing', 'Ceramics & Glass', 'سرامیک و شیشه', 'ceramics-glass-mfg', 'سرامیک و شیشه', 7, ARRAY['ceramic', 'ceramics & glass', 'ceramics glass mfg', 'glass', 'سرامیک', 'سرامیک و شیشه', 'شیشه']::text[]),
('manufacturing', 'Pharmaceutical & Health', 'دارویی و سلامت', 'pharma-health-mfg', 'دارویی و سلامت', 8, ARRAY['pharma', 'pharma health mfg', 'pharmaceutical', 'pharmaceutical & health', 'دارویی', 'دارویی و سلامت']::text[]),
('manufacturing', 'Other Factories', 'سایر کارخانه‌ها', 'other-factories', 'سایر کارخانه‌ها', 9, ARRAY['other factories', 'other factory', 'سایر کارخانه', 'سایر کارخانه‌ها']::text[]),
('construction-building', 'Contractors', 'پیمانکاران', 'contractors', 'پیمانکاران', 1, ARRAY['contractor', 'contractors', 'پیمانکار', 'پیمانکاران']::text[]),
('construction-building', 'Building Supplies', 'مصالح ساختمانی', 'building-supplies', 'مصالح ساختمانی', 2, ARRAY['building supplies', 'building supply', 'مصالح', 'مصالح ساختمانی']::text[]),
('agriculture-raw-materials', 'Farming', 'کشاورزی', 'farming', 'کشاورزی', 1, ARRAY['farm', 'farming', 'کشاورز', 'کشاورزی']::text[]),
('agriculture-raw-materials', 'Mining & Extraction', 'معدن و استخراج', 'mining-extraction', 'معدن و استخراج', 2, ARRAY['mining', 'mining & extraction', 'mining extraction', 'معدن', 'معدن و استخراج']::text[]),
('energy-utilities', 'Energy', 'انرژی', 'energy', 'انرژی', 1, ARRAY['energy', 'power', 'انرژی', 'برق']::text[]),
('energy-utilities', 'Industrial Services', 'خدمات صنعتی', 'industrial-services', 'خدمات صنعتی', 2, ARRAY['industrial service', 'industrial services', 'خدمات صنعتی']::text[]),
('professional-services', 'Business Consulting', 'مشاوره کسب‌وکار', 'business-consulting', 'مشاوره کسب‌وکار', 1, ARRAY['business consulting', 'consulting', 'مشاوره', 'مشاوره کسب‌وکار']::text[]),
('professional-services', 'Design & Creative', 'طراحی و خلاقیت', 'design-creative', 'طراحی و خلاقیت', 2, ARRAY['creative', 'design', 'design & creative', 'design creative', 'طراحی', 'طراحی و خلاقیت']::text[]),
('professional-services', 'Education & Training', 'آموزش', 'education-training', 'آموزش', 3, ARRAY['education', 'education & training', 'education training', 'training', 'آموزش']::text[]),
('technology-services', 'Software & IT', 'نرم‌افزار و فناوری اطلاعات', 'software-it', 'نرم‌افزار و فناوری اطلاعات', 1, ARRAY['it', 'software', 'software & it', 'software it', 'نرم افزار', 'نرم‌افزار و فناوری اطلاعات']::text[]),
('technology-services', 'Digital Marketing', 'بازاریابی دیجیتال', 'digital-marketing', 'بازاریابی دیجیتال', 2, ARRAY['digital marketing', 'بازاریابی دیجیتال', 'دیجیتال مارکتینگ']::text[]),
('personal-local-services', 'Beauty & Care', 'زیبایی و مراقبت', 'beauty-care-services', 'زیبایی و مراقبت', 1, ARRAY['beauty & care', 'beauty care services', 'beauty salon', 'آرایشگاه', 'زیبایی و مراقبت']::text[]),
('personal-local-services', 'Home Services', 'خدمات منزل', 'home-services', 'خدمات منزل', 2, ARRAY['home service', 'home services', 'خدمات منزل']::text[]),
('personal-local-services', 'Hospitality', 'پذیرایی و اقامت', 'hospitality', 'پذیرایی و اقامت', 3, ARRAY['hospitality', 'رستوران', 'هتل', 'پذیرایی و اقامت']::text[]),
('medical', 'Doctors & Specialists', 'پزشکان و متخصصان', 'doctors-specialists', 'پزشکان و متخصصان', 1, ARRAY['doctor', 'doctors & specialists', 'doctors specialists', 'specialist', 'متخصص', 'مطب', 'پزشک', 'پزشکان و متخصصان']::text[]),
('medical', 'Clinics & Centers', 'کلینیک‌ها و مراکز', 'clinics-centers', 'کلینیک‌ها و مراکز', 2, ARRAY['center', 'clinic', 'clinics & centers', 'clinics centers', 'مرکز', 'کلینیک', 'کلینیک‌ها و مراکز']::text[]),
('medical', 'Hospitals & Labs', 'بیمارستان و آزمایشگاه', 'hospitals-labs', 'بیمارستان و آزمایشگاه', 3, ARRAY['hospital', 'hospitals & labs', 'hospitals labs', 'lab', 'آزمایشگاه', 'بیمارستان', 'بیمارستان و آزمایشگاه']::text[]),
('medical', 'Care & Support', 'مراقبت و پشتیبانی', 'care-support', 'مراقبت و پشتیبانی', 4, ARRAY['care', 'care & support', 'care support', 'support', 'مراقبت', 'مراقبت و پشتیبانی']::text[]),
('automotive-services', 'Vehicle Sales', 'فروش خودرو', 'vehicle-sales', 'فروش خودرو', 1, ARRAY['car sales', 'vehicle sales', 'فروش خودرو']::text[]),
('automotive-services', 'Repair & Parts', 'تعمیر و قطعات', 'repair-parts', 'تعمیر و قطعات', 2, ARRAY['auto repair', 'repair & parts', 'repair parts', 'تعمیر خودرو', 'تعمیر و قطعات']::text[])
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
JOIN categories p ON p.slug = v.parent_slug
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
-- Level 4
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
FROM (VALUES
('clothing', 'Women''s Clothing', 'لباس زنانه', 'womens-clothing', 'لباس زنانه', 1, ARRAY['ladies', 'women', 'women''s clothing', 'womens clothing', 'بانوان', 'زنانه', 'لباس زنانه']::text[]),
('clothing', 'Men''s Clothing', 'لباس مردانه', 'mens-clothing', 'لباس مردانه', 2, ARRAY['gents', 'men', 'men''s clothing', 'mens clothing', 'لباس مردانه', 'مردانه']::text[]),
('clothing', 'Kids & Baby', 'کودک و نوزاد', 'kids-baby-clothing', 'کودک و نوزاد', 3, ARRAY['baby', 'children', 'kids', 'kids & baby', 'kids baby clothing', 'بچگانه', 'نوزاد', 'کودک', 'کودک و نوزاد']::text[]),
('clothing', 'Sportswear', 'لباس ورزشی', 'sportswear', 'لباس ورزشی', 4, ARRAY['activewear', 'sport', 'sportswear', 'لباس ورزشی', 'ورزشی']::text[]),
('shoes-bags', 'Shoes', 'کفش', 'shoes', 'کفش', 1, ARRAY['footwear', 'shoes', 'sneakers', 'کفش']::text[]),
('shoes-bags', 'Bags & Luggage', 'کیف و چمدان', 'bags-luggage', 'کیف و چمدان', 2, ARRAY['bag', 'bags & luggage', 'bags luggage', 'luggage', 'suitcase', 'چمدان', 'کیف', 'کیف و چمدان']::text[]),
('shoes-bags', 'Accessories', 'اکسسوری', 'fashion-accessories', 'اکسسوری', 3, ARRAY['accessories', 'accessory', 'belt', 'fashion accessories', 'scarf', 'اکسسوری']::text[]),
('jewelry-watches', 'Jewelry', 'جواهرات', 'jewelry', 'جواهرات', 1, ARRAY['jewellery', 'jewelry', 'necklace', 'ring', 'جواهر', 'جواهرات', 'طلاآلات']::text[]),
('jewelry-watches', 'Watches', 'ساعت', 'watches', 'ساعت', 2, ARRAY['watch', 'watches', 'ساعت', 'ساعت مچی']::text[]),
('jewelry-watches', 'Gold & Silver', 'طلا و نقره', 'gold-silver', 'طلا و نقره', 3, ARRAY['gold', 'gold & silver', 'gold silver', 'silver', 'طلا', 'طلا فروشی', 'طلا و نقره', 'نقره']::text[]),
('supermarket-grocery', 'Supermarket', 'سوپرمارکت', 'supermarket', 'سوپرمارکت', 1, ARRAY['grocery store', 'hypermarket', 'supermarket', 'سوپر', 'سوپرمارکت', 'هایپرمارکت']::text[]),
('supermarket-grocery', 'Convenience Store', 'بقالی', 'convenience-store', 'بقالی', 2, ARRAY['convenience store', 'corner store', 'minimarket', 'بقالی', 'مینی مارکت']::text[]),
('supermarket-grocery', 'Organic & Healthy', 'ارگانیک و سالم', 'organic-healthy', 'ارگانیک و سالم', 3, ARRAY['healthy', 'organic', 'organic & healthy', 'organic healthy', 'ارگانیک', 'ارگانیک و سالم', 'سالم']::text[]),
('specialty-food', 'Bakery & Pastry', 'نانوایی و شیرینی', 'bakery-pastry', 'نانوایی و شیرینی', 1, ARRAY['bakery', 'bakery & pastry', 'bakery pastry', 'pastry', 'شیرینی', 'قنادی', 'نانوایی', 'نانوایی و شیرینی']::text[]),
('specialty-food', 'Nuts & Dried Fruit', 'آجیل و خشکبار', 'nuts-dried-fruit', 'آجیل و خشکبار', 2, ARRAY['dried fruit', 'nuts', 'nuts & dried fruit', 'nuts dried fruit', 'آجیل', 'آجیل و خشکبار', 'خشکبار']::text[]),
('specialty-food', 'Sweets & Confectionery', 'شیرینی و آبنبات', 'sweets-confectionery', 'شیرینی و آبنبات', 3, ARRAY['candy', 'confectionery', 'sweets', 'sweets & confectionery', 'sweets confectionery', 'آبنبات', 'شیرینی', 'شیرینی و آبنبات']::text[]),
('specialty-food', 'Spices & Herbs', 'ادویه و گیاهان دارویی', 'spices-herbs', 'ادویه و گیاهان دارویی', 4, ARRAY['herb', 'saffron', 'spice', 'spices & herbs', 'spices herbs', 'ادویه', 'ادویه و گیاهان دارویی', 'زعفران']::text[]),
('beverages-retail', 'Coffee & Tea', 'قهوه و چای', 'coffee-tea', 'قهوه و چای', 1, ARRAY['coffee', 'coffee & tea', 'coffee tea', 'tea', 'قهوه', 'قهوه و چای', 'چای']::text[]),
('beverages-retail', 'Soft Drinks', 'نوشابه', 'soft-drinks', 'نوشابه', 2, ARRAY['soda', 'soft drink', 'soft drinks', 'نوشابه']::text[]),
('beverages-retail', 'Dairy & Juice', 'لبنیات و آبمیوه', 'dairy-juice', 'لبنیات و آبمیوه', 3, ARRAY['dairy', 'dairy & juice', 'dairy juice', 'juice', 'milk', 'آبمیوه', 'لبنیات', 'لبنیات و آبمیوه']::text[]),
('furniture', 'Home Furniture', 'مبلمان خانگی', 'home-furniture', 'مبلمان خانگی', 1, ARRAY['bedroom', 'home furniture', 'sofa', 'مبلمان خانگی', 'مبلمان منزل']::text[]),
('furniture', 'Office Furniture', 'مبلمان اداری', 'office-furniture', 'مبلمان اداری', 2, ARRAY['desk', 'office chair', 'office furniture', 'اداری', 'مبلمان اداری']::text[]),
('furniture', 'Outdoor Furniture', 'مبلمان فضای باز', 'outdoor-furniture', 'مبلمان فضای باز', 3, ARRAY['garden furniture', 'outdoor furniture', 'فضای باز', 'مبلمان فضای باز']::text[]),
('home-decor', 'Rugs & Carpets', 'فرش و قالی', 'rugs-carpets', 'فرش و قالی', 1, ARRAY['carpet', 'rug', 'rugs & carpets', 'rugs carpets', 'فرش', 'فرش و قالی', 'قالی', 'گلیم']::text[]),
('home-decor', 'Lighting', 'روشنایی', 'lighting', 'روشنایی', 2, ARRAY['lamp', 'light', 'lighting', 'روشنایی', 'لوستر', 'چراغ']::text[]),
('home-decor', 'Decor Accessories', 'لوازم دکوری', 'decor-accessories', 'لوازم دکوری', 3, ARRAY['decor', 'decor accessories', 'ornament', 'دکوری', 'لوازم دکوری']::text[]),
('kitchen-appliances', 'Kitchenware', 'ظروف آشپزخانه', 'kitchenware', 'ظروف آشپزخانه', 1, ARRAY['cookware', 'kitchen tools', 'kitchenware', 'ظروف', 'ظروف آشپزخانه']::text[]),
('kitchen-appliances', 'Small Appliances', 'لوازم کوچک خانگی', 'small-appliances', 'لوازم کوچک خانگی', 2, ARRAY['blender', 'small appliances', 'toaster', 'لوازم کوچک', 'لوازم کوچک خانگی']::text[]),
('kitchen-appliances', 'Large Appliances', 'لوازم بزرگ خانگی', 'large-appliances', 'لوازم بزرگ خانگی', 3, ARRAY['fridge', 'large appliances', 'washer', 'لوازم بزرگ خانگی', 'ماشین لباسشویی', 'یخچال']::text[]),
('mobile-accessories', 'Mobile Phones', 'گوشی موبایل', 'mobile-phones', 'گوشی موبایل', 1, ARRAY['mobile phones', 'phone shop', 'smartphone', 'فروشگاه موبایل', 'گوشی', 'گوشی موبایل']::text[]),
('mobile-accessories', 'Accessories', 'لوازم جانبی موبایل', 'mobile-accessories-store', 'لوازم جانبی موبایل', 2, ARRAY['accessories', 'case', 'charger', 'mobile accessories store', 'لوازم جانبی', 'لوازم جانبی موبایل']::text[]),
('mobile-accessories', 'Wearables', 'گجت پوشیدنی', 'wearables', 'گجت پوشیدنی', 3, ARRAY['smartwatch', 'wearables', 'ساعت هوشمند', 'گجت پوشیدنی']::text[]),
('computers', 'Laptops', 'لپ‌تاپ', 'laptops', 'لپ‌تاپ', 1, ARRAY['laptops', 'notebook', 'لپ تاپ', 'لپتاپ', 'لپ‌تاپ']::text[]),
('computers', 'Desktops & Parts', 'دسکتاپ و قطعات', 'desktops-parts', 'دسکتاپ و قطعات', 2, ARRAY['cpu', 'desktops & parts', 'desktops parts', 'pc parts', 'دسکتاپ و قطعات', 'قطعات کامپیوتر']::text[]),
('computers', 'Peripherals', 'لوازم جانبی کامپیوتر', 'peripherals', 'لوازم جانبی کامپیوتر', 3, ARRAY['keyboard', 'monitor', 'mouse', 'peripherals', 'لوازم جانبی کامپیوتر', 'مانیتور']::text[]),
('audio-visual', 'TVs', 'تلویزیون', 'tvs', 'تلویزیون', 1, ARRAY['television', 'tv store', 'tvs', 'تلویزیون']::text[]),
('audio-visual', 'Audio Systems', 'سیستم صوتی', 'audio-systems', 'سیستم صوتی', 2, ARRAY['audio systems', 'sound', 'speaker', 'اسپیکر', 'سیستم صوتی']::text[]),
('audio-visual', 'Cameras', 'دوربین', 'cameras', 'دوربین', 3, ARRAY['camera', 'cameras', 'photography', 'دوربین', 'دوربین عکاسی']::text[]),
('cosmetics-personal-care', 'Skincare', 'مراقبت پوست', 'skincare', 'مراقبت پوست', 1, ARRAY['cream', 'skin care', 'skincare', 'مراقبت پوست', 'پوست']::text[]),
('cosmetics-personal-care', 'Makeup', 'آرایش', 'makeup', 'آرایش', 2, ARRAY['cosmetic', 'make up', 'makeup', 'آرایش']::text[]),
('cosmetics-personal-care', 'Hair Care', 'مراقبت مو', 'hair-care', 'مراقبت مو', 3, ARRAY['hair', 'hair care', 'shampoo', 'مراقبت مو', 'مو']::text[]),
('pharmacy-wellness-retail', 'Pharmacy', 'داروخانه', 'pharmacy', 'داروخانه', 1, ARRAY['chemist', 'drug store', 'drugstore', 'pharmacy', 'pharmacy store', 'دارو', 'داروخانه', 'شبانه روزی']::text[]),
('pharmacy-wellness-retail', 'Supplements', 'مکمل‌ها', 'supplements', 'مکمل‌ها', 2, ARRAY['supplement', 'supplements', 'vitamin', 'مکمل', 'مکمل‌ها', 'ویتامین']::text[]),
('pharmacy-wellness-retail', 'Medical Supplies', 'لوازم پزشکی', 'medical-supplies-retail', 'لوازم پزشکی', 3, ARRAY['medical supplies', 'medical supplies retail', 'medical supply', 'لوازم پزشکی']::text[]),
('textile-apparel-mfg', 'Garment Factory', 'کارخانه پوشاک', 'garment-factory', 'کارخانه پوشاک', 1, ARRAY['clothing factory', 'garment factory', 'دوخت', 'کارخانه لباس', 'کارخانه پوشاک']::text[]),
('textile-apparel-mfg', 'Fabric & Textile Mill', 'کارخانه پارچه و نساجی', 'fabric-textile-mill', 'کارخانه پارچه و نساجی', 2, ARRAY['fabric & textile mill', 'fabric textile mill', 'textile mill', 'نساجی', 'پارچه', 'کارخانه پارچه و نساجی']::text[]),
('textile-apparel-mfg', 'Knitwear Factory', 'کارخانه تریکو', 'knitwear-factory', 'کارخانه تریکو', 3, ARRAY['knitwear', 'knitwear factory', 'تریکو', 'کارخانه تریکو']::text[]),
('textile-apparel-mfg', 'Leather Goods Factory', 'کارخانه چرم', 'leather-goods-factory', 'کارخانه چرم', 4, ARRAY['leather factory', 'leather goods factory', 'چرم', 'کارخانه چرم']::text[]),
('food-processing-mfg', 'Dairy Factory', 'کارخانه لبنیات', 'dairy-factory', 'کارخانه لبنیات', 1, ARRAY['dairy factory', 'dairy plant', 'لبنیات', 'کارخانه لبنیات']::text[]),
('food-processing-mfg', 'Meat Processing Plant', 'کارخانه فرآوری گوشت', 'meat-processing-plant', 'کارخانه فرآوری گوشت', 2, ARRAY['meat factory', 'meat processing plant', 'کارخانه فرآوری گوشت', 'گوشت']::text[]),
('food-processing-mfg', 'Nuts & Dry Goods Factory', 'کارخانه آجیل و خشکبار', 'nuts-dry-goods-factory', 'کارخانه آجیل و خشکبار', 3, ARRAY['nuts & dry goods factory', 'nuts dry goods factory', 'nuts factory', 'آجیل', 'کارخانه آجیل و خشکبار']::text[]),
('food-processing-mfg', 'Beverage Factory', 'کارخانه نوشیدنی', 'beverage-factory', 'کارخانه نوشیدنی', 4, ARRAY['beverage factory', 'drink factory', 'نوشیدنی', 'کارخانه نوشیدنی']::text[]),
('food-processing-mfg', 'Flour Mill', 'آسیاب آرد', 'flour-mill', 'آسیاب آرد', 5, ARRAY['flour', 'flour mill', 'mill', 'آرد', 'آسیاب آرد']::text[]),
('food-processing-mfg', 'Canning Factory', 'کارخانه کنسرو', 'canning-factory', 'کارخانه کنسرو', 6, ARRAY['cannery', 'canning factory', 'کارخانه کنسرو', 'کنسرو']::text[]),
('metal-machinery-mfg', 'Metal Fabrication Plant', 'کارخانه ساخت فلزی', 'metal-fabrication-plant', 'کارخانه ساخت فلزی', 1, ARRAY['fabrication', 'metal fabrication plant', 'ورق کاری', 'کارخانه ساخت فلزی']::text[]),
('metal-machinery-mfg', 'Machinery Factory', 'کارخانه ماشین‌آلات', 'machinery-factory', 'کارخانه ماشین‌آلات', 2, ARRAY['machine factory', 'machinery factory', 'ماشین سازی', 'کارخانه ماشین‌آلات']::text[]),
('metal-machinery-mfg', 'Tools Factory', 'کارخانه ابزار', 'tools-factory', 'کارخانه ابزار', 3, ARRAY['tool factory', 'tools factory', 'ابزار', 'کارخانه ابزار']::text[]),
('metal-machinery-mfg', 'Foundry', 'ریخته‌گری', 'foundry', 'ریخته‌گری', 4, ARRAY['casting', 'foundry', 'ریخته گری', 'ریخته‌گری']::text[]),
('electrical-cable-mfg', 'Cable Factory', 'کارخانه کابل', 'cable-factory', 'کارخانه کابل', 1, ARRAY['cable factory', 'cable manufacturer', 'سیم و کابل', 'کابل سازی', 'کارخانه کابل']::text[]),
('electrical-cable-mfg', 'Wire Factory', 'کارخانه سیم', 'wire-factory', 'کارخانه سیم', 2, ARRAY['wire factory', 'wire manufacturer', 'سیم سازی', 'کارخانه سیم']::text[]),
('electrical-cable-mfg', 'Electrical Equipment Factory', 'کارخانه تجهیزات برقی', 'electrical-equipment-factory', 'کارخانه تجهیزات برقی', 3, ARRAY['electrical equipment factory', 'electrical factory', 'تجهیزات برقی', 'کارخانه تجهیزات برقی']::text[]),
('electrical-cable-mfg', 'Transformer Factory', 'کارخانه ترانسفورماتور', 'transformer-factory', 'کارخانه ترانسفورماتور', 4, ARRAY['transformer', 'transformer factory', 'ترانسفورماتور', 'کارخانه ترانسفورماتور']::text[]),
('automotive-parts-mfg', 'Auto Parts Factory', 'کارخانه قطعات خودرو', 'auto-parts-factory', 'کارخانه قطعات خودرو', 1, ARRAY['auto parts factory', 'car parts factory', 'قطعه سازی خودرو', 'کارخانه قطعات خودرو']::text[]),
('automotive-parts-mfg', 'Tire Factory', 'کارخانه لاستیک', 'tire-factory', 'کارخانه لاستیک', 2, ARRAY['tire factory', 'tyre', 'لاستیک', 'کارخانه لاستیک']::text[]),
('automotive-parts-mfg', 'Battery Factory', 'کارخانه باتری', 'battery-factory', 'کارخانه باتری', 3, ARRAY['battery factory', 'car battery', 'باتری', 'کارخانه باتری']::text[]),
('automotive-parts-mfg', 'Automotive Electronics Factory', 'کارخانه الکترونیک خودرو', 'automotive-electronics-factory', 'کارخانه الکترونیک خودرو', 4, ARRAY['auto electronics', 'automotive electronics factory', 'الکترونیک خودرو', 'کارخانه الکترونیک خودرو']::text[]),
('chemicals-materials-mfg', 'Chemical Plant', 'کارخانه شیمیایی', 'chemical-plant', 'کارخانه شیمیایی', 1, ARRAY['chemical plant', 'chemicals factory', 'شیمیایی', 'کارخانه شیمیایی']::text[]),
('chemicals-materials-mfg', 'Plastics Factory', 'کارخانه پلاستیک', 'plastics-factory', 'کارخانه پلاستیک', 2, ARRAY['plastic factory', 'plastics factory', 'پلاستیک', 'کارخانه پلاستیک']::text[]),
('chemicals-materials-mfg', 'Paint Factory', 'کارخانه رنگ', 'paint-factory', 'کارخانه رنگ', 3, ARRAY['paint factory', 'paint manufacturer', 'رنگ سازی', 'کارخانه رنگ']::text[]),
('chemicals-materials-mfg', 'Rubber Factory', 'کارخانه لاستیک صنعتی', 'rubber-factory', 'کارخانه لاستیک صنعتی', 4, ARRAY['rubber', 'rubber factory', 'لاستیک صنعتی', 'کارخانه لاستیک صنعتی']::text[]),
('chemicals-materials-mfg', 'Building Materials Factory', 'کارخانه مصالح ساختمانی', 'building-materials-factory', 'کارخانه مصالح ساختمانی', 5, ARRAY['building materials', 'building materials factory', 'مصالح', 'کارخانه مصالح ساختمانی']::text[]),
('ceramics-glass-mfg', 'Ceramic Factory', 'کارخانه سرامیک', 'ceramic-factory', 'کارخانه سرامیک', 1, ARRAY['ceramic factory', 'ceramics plant', 'سرامیک سازی', 'کارخانه سرامیک']::text[]),
('ceramics-glass-mfg', 'Tile Factory', 'کارخانه کاشی', 'tile-factory', 'کارخانه کاشی', 2, ARRAY['tile factory', 'tile manufacturer', 'سرامیک کف', 'کارخانه کاشی', 'کاشی']::text[]),
('ceramics-glass-mfg', 'Porcelain Factory', 'کارخانه چینی', 'porcelain-factory', 'کارخانه چینی', 3, ARRAY['porcelain', 'porcelain factory', 'چینی', 'کارخانه چینی']::text[]),
('ceramics-glass-mfg', 'Glass Factory', 'کارخانه شیشه', 'glass-factory', 'کارخانه شیشه', 4, ARRAY['glass factory', 'glass plant', 'شیشه سازی', 'کارخانه شیشه']::text[]),
('pharma-health-mfg', 'Pharmaceutical Factory', 'کارخانه داروسازی', 'pharmaceutical-factory', 'کارخانه داروسازی', 1, ARRAY['drug factory', 'medicine factory', 'pharmaceutical factory', 'داروسازی', 'کارخانه دارو', 'کارخانه داروسازی']::text[]),
('pharma-health-mfg', 'Cosmetics Factory', 'کارخانه آرایشی', 'cosmetics-factory', 'کارخانه آرایشی', 2, ARRAY['cosmetic manufacturer', 'cosmetics factory', 'آرایشی بهداشتی', 'کارخانه آرایشی']::text[]),
('pharma-health-mfg', 'Medical Devices Factory', 'کارخانه تجهیزات پزشکی', 'medical-devices-factory', 'کارخانه تجهیزات پزشکی', 3, ARRAY['medical devices factory', 'medtech', 'تجهیزات پزشکی', 'کارخانه تجهیزات پزشکی']::text[]),
('pharma-health-mfg', 'Supplement Factory', 'کارخانه مکمل', 'supplement-factory', 'کارخانه مکمل', 4, ARRAY['supplement factory', 'vitamin factory', 'مکمل سازی', 'کارخانه مکمل']::text[]),
('other-factories', 'Packaging Factory', 'کارخانه بسته‌بندی', 'packaging-factory', 'کارخانه بسته‌بندی', 1, ARRAY['packaging', 'packaging factory', 'بسته بندی', 'کارخانه بسته‌بندی']::text[]),
('other-factories', 'Paper & Carton Factory', 'کارخانه کاغذ و کارتن', 'paper-carton-factory', 'کارخانه کاغذ و کارتن', 2, ARRAY['carton', 'paper & carton factory', 'paper carton factory', 'paper mill', 'کارتن', 'کارخانه کاغذ و کارتن', 'کاغذ']::text[]),
('other-factories', 'Furniture Factory', 'کارخانه مبلمان', 'furniture-factory', 'کارخانه مبلمان', 3, ARRAY['furniture factory', 'furniture manufacturer', 'مبلمان سازی', 'کارخانه مبلمان']::text[]),
('other-factories', 'Wood Products Factory', 'کارخانه محصولات چوبی', 'wood-products-factory', 'کارخانه محصولات چوبی', 4, ARRAY['wood factory', 'wood products factory', 'چوب', 'کارخانه محصولات چوبی']::text[]),
('other-factories', 'Printing Factory', 'چاپخانه صنعتی', 'printing-factory', 'چاپخانه صنعتی', 5, ARRAY['printing factory', 'printing plant', 'چاپ', 'چاپخانه صنعتی']::text[]),
('contractors', 'General Contractor', 'پیمانکار عمومی', 'general-contractor', 'پیمانکار عمومی', 1, ARRAY['gc', 'general contractor', 'پیمانکار عمومی', 'پیمانکاری']::text[]),
('contractors', 'Renovation', 'بازسازی', 'renovation', 'بازسازی', 2, ARRAY['remodel', 'renovation', 'بازسازی']::text[]),
('contractors', 'Interior Fit-out', 'طراحی داخلی اجرایی', 'interior-fit-out', 'طراحی داخلی اجرایی', 3, ARRAY['fit out', 'interior fit out', 'interior fit-out', 'دکوراسیون داخلی', 'طراحی داخلی اجرایی']::text[]),
('building-supplies', 'Cement Factory', 'کارخانه سیمان', 'cement-factory', 'کارخانه سیمان', 1, ARRAY['cement factory', 'cement plant', 'سیمان', 'کارخانه سیمان']::text[]),
('building-supplies', 'Steel Plant', 'کارخانه فولاد', 'steel-plant', 'کارخانه فولاد', 2, ARRAY['steel mill', 'steel plant', 'فولاد', 'کارخانه فولاد']::text[]),
('building-supplies', 'Electrical & Plumbing Supply', 'تأسیسات برقی و لوله‌کشی', 'electrical-plumbing-supply', 'تأسیسات برقی و لوله‌کشی', 3, ARRAY['electrical & plumbing supply', 'electrical plumbing supply', 'electrical supply', 'plumbing', 'تأسیسات', 'تأسیسات برقی و لوله‌کشی']::text[]),
('farming', 'Crops', 'زراعت', 'crops', 'زراعت', 1, ARRAY['crop', 'crops', 'farm', 'زراعت']::text[]),
('farming', 'Livestock', 'دامپروری', 'livestock', 'دامپروری', 2, ARRAY['cattle', 'livestock', 'poultry', 'دام', 'دامپروری', 'مرغداری']::text[]),
('farming', 'Greenhouse', 'گلخانه', 'greenhouse', 'گلخانه', 3, ARRAY['greenhouse', 'greenhouse farm', 'گلخانه']::text[]),
('mining-extraction', 'Minerals', 'مواد معدنی', 'minerals', 'مواد معدنی', 1, ARRAY['mineral', 'minerals', 'معدنی', 'مواد معدنی']::text[]),
('mining-extraction', 'Stone & Quarry', 'سنگ و معدن سنگ', 'stone-quarry', 'سنگ و معدن سنگ', 2, ARRAY['quarry', 'stone', 'stone & quarry', 'stone quarry', 'سنگ و معدن سنگ', 'سنگبری']::text[]),
('energy', 'Solar & Renewables', 'خورشیدی و تجدیدپذیر', 'solar-renewables', 'خورشیدی و تجدیدپذیر', 1, ARRAY['renewable', 'solar', 'solar & renewables', 'solar renewables', 'خورشیدی و تجدیدپذیر', 'پنل خورشیدی']::text[]),
('energy', 'Oil & Gas Related', 'نفت و گاز', 'oil-gas-related', 'نفت و گاز', 2, ARRAY['gas', 'oil', 'oil & gas related', 'oil gas related', 'نفت', 'نفت و گاز', 'گاز']::text[]),
('energy', 'Power Equipment', 'تجهیزات برق', 'power-equipment', 'تجهیزات برق', 3, ARRAY['power equipment', 'تجهیزات برق', 'تجهیزات قدرت']::text[]),
('industrial-services', 'Logistics Support', 'پشتیبانی لجستیک', 'logistics-support', 'پشتیبانی لجستیک', 1, ARRAY['logistics', 'logistics support', 'لجستیک', 'پشتیبانی لجستیک']::text[]),
('industrial-services', 'Industrial Maintenance', 'نگهداری صنعتی', 'industrial-maintenance', 'نگهداری صنعتی', 2, ARRAY['industrial maintenance', 'maintenance', 'نگهداری', 'نگهداری صنعتی']::text[]),
('business-consulting', 'Management Consulting', 'مشاوره مدیریت', 'management-consulting', 'مشاوره مدیریت', 1, ARRAY['management consultant', 'management consulting', 'مشاور مدیریت', 'مشاوره مدیریت']::text[]),
('business-consulting', 'Marketing & Branding', 'بازاریابی و برندینگ', 'marketing-branding', 'بازاریابی و برندینگ', 2, ARRAY['branding', 'marketing & branding', 'marketing branding', 'بازاریابی', 'بازاریابی و برندینگ', 'برندینگ']::text[]),
('business-consulting', 'Legal & Accounting', 'حقوقی و حسابداری', 'legal-accounting', 'حقوقی و حسابداری', 3, ARRAY['accountant', 'lawyer', 'legal & accounting', 'legal accounting', 'حسابدار', 'حقوقی و حسابداری', 'وکیل']::text[]),
('design-creative', 'Graphic Design', 'طراحی گرافیک', 'graphic-design', 'طراحی گرافیک', 1, ARRAY['graphic design', 'graphic designer', 'طراحی گرافیک', 'گرافیک']::text[]),
('design-creative', 'Architecture & Interior', 'معماری و داخلی', 'architecture-interior', 'معماری و داخلی', 2, ARRAY['architect', 'architecture & interior', 'architecture interior', 'interior design', 'طراح داخلی', 'معمار', 'معماری و داخلی']::text[]),
('design-creative', 'Photography & Video', 'عکاسی و فیلم', 'photography-video', 'عکاسی و فیلم', 3, ARRAY['photographer', 'photography & video', 'photography video', 'videographer', 'عکاس', 'عکاسی و فیلم', 'فیلمبردار']::text[]),
('education-training', 'Training Centers', 'مراکز آموزشی', 'training-centers', 'مراکز آموزشی', 1, ARRAY['training center', 'training centers', 'آموزشگاه', 'مراکز آموزشی']::text[]),
('education-training', 'Online Courses', 'دوره‌های آنلاین', 'online-courses', 'دوره‌های آنلاین', 2, ARRAY['elearning', 'online course', 'online courses', 'دوره آنلاین', 'دوره‌های آنلاین']::text[]),
('education-training', 'Language Schools', 'آموزشگاه زبان', 'language-schools', 'آموزشگاه زبان', 3, ARRAY['language school', 'language schools', 'آموزشگاه زبان', 'زبان']::text[]),
('software-it', 'Software Development', 'توسعه نرم‌افزار', 'software-development', 'توسعه نرم‌افزار', 1, ARRAY['dev shop', 'software company', 'software development', 'برنامه نویسی', 'توسعه نرم‌افزار']::text[]),
('software-it', 'SaaS & Platforms', 'نرم‌افزار ابری و پلتفرم', 'saas-platforms', 'نرم‌افزار ابری و پلتفرم', 2, ARRAY['platform', 'saas', 'saas & platforms', 'saas platforms', 'ابری', 'نرم‌افزار ابری و پلتفرم']::text[]),
('software-it', 'IT Support', 'پشتیبانی آی‌تی', 'it-support', 'پشتیبانی آی‌تی', 3, ARRAY['helpdesk', 'it support', 'پشتیبانی', 'پشتیبانی آی‌تی']::text[]),
('digital-marketing', 'SEO & Ads', 'سئو و تبلیغات', 'seo-ads', 'سئو و تبلیغات', 1, ARRAY['ads', 'google ads', 'seo', 'seo & ads', 'seo ads', 'سئو', 'سئو و تبلیغات']::text[]),
('digital-marketing', 'Social Media', 'شبکه‌های اجتماعی', 'social-media', 'شبکه‌های اجتماعی', 2, ARRAY['instagram marketing', 'social media', 'سوشال', 'شبکه‌های اجتماعی']::text[]),
('digital-marketing', 'Content Production', 'تولید محتوا', 'content-production', 'تولید محتوا', 3, ARRAY['content', 'content production', 'تولید محتوا', 'محتوا']::text[]),
('beauty-care-services', 'Hair Salon', 'آرایشگاه', 'hair-salon', 'آرایشگاه', 1, ARRAY['barber', 'hair salon', 'hairdresser', 'آرایشگاه', 'آرایشگاه زنانه', 'آرایشگاه مردانه']::text[]),
('beauty-care-services', 'Spa & Wellness', 'اسپا و سلامت', 'spa-wellness', 'اسپا و سلامت', 2, ARRAY['massage', 'spa', 'spa & wellness', 'spa wellness', 'اسپا', 'اسپا و سلامت']::text[]),
('beauty-care-services', 'Clinic Services', 'کلینیک زیبایی', 'clinic-services-beauty', 'کلینیک زیبایی', 3, ARRAY['beauty clinic', 'clinic services', 'clinic services beauty', 'کلینیک زیبایی']::text[]),
('home-services', 'Cleaning', 'نظافت', 'cleaning', 'نظافت', 1, ARRAY['cleaner', 'cleaning', 'شرکت نظافتی', 'نظافت', 'نظافتچی']::text[]),
('home-services', 'Repair & Maintenance', 'تعمیرات', 'repair-maintenance', 'تعمیرات', 2, ARRAY['handyman', 'repair & maintenance', 'repair maintenance', 'تعمیرات', 'تعمیرات منزل']::text[]),
('home-services', 'Moving & Logistics', 'اسباب‌کشی و حمل', 'moving-logistics', 'اسباب‌کشی و حمل', 3, ARRAY['moving', 'moving & logistics', 'moving logistics', 'relocation', 'اسباب کشی', 'اسباب‌کشی و حمل', 'باربری']::text[]),
('hospitality', 'Restaurants & Cafés', 'رستوران و کافه', 'restaurants-cafes', 'رستوران و کافه', 1, ARRAY['cafe', 'restaurant', 'restaurants & cafés', 'restaurants cafes', 'رستوران', 'رستوران و کافه', 'کافه']::text[]),
('hospitality', 'Catering', 'کترینگ', 'catering', 'کترینگ', 2, ARRAY['caterer', 'catering', 'کترینگ']::text[]),
('hospitality', 'Hotels & Stays', 'هتل و اقامتگاه', 'hotels-stays', 'هتل و اقامتگاه', 3, ARRAY['hotel', 'hotels & stays', 'hotels stays', 'اقامتگاه', 'هتل', 'هتل و اقامتگاه']::text[]),
('doctors-specialists', 'General Practitioner', 'پزشک عمومی', 'general-practitioner', 'پزشک عمومی', 1, ARRAY['family doctor', 'general practitioner', 'gp', 'دکتر عمومی', 'پزشک عمومی']::text[]),
('doctors-specialists', 'Internal Medicine', 'داخلی', 'internal-medicine', 'داخلی', 2, ARRAY['internal medicine', 'internist', 'داخلی']::text[]),
('doctors-specialists', 'Cardiology', 'قلب و عروق', 'cardiology', 'قلب و عروق', 3, ARRAY['cardiologist', 'cardiology', 'heart doctor', 'قلب', 'قلب و عروق', 'متخصص قلب']::text[]),
('doctors-specialists', 'ENT (Otolaryngology)', 'گوش و حلق و بینی', 'ent-otolaryngology', 'گوش و حلق و بینی', 4, ARRAY['ear nose throat', 'ent', 'ent (otolaryngology)', 'ent otolaryngology', 'متخصص گوش', 'گوش حلق بینی', 'گوش و حلق و بینی']::text[]),
('doctors-specialists', 'Dermatology', 'پوست', 'dermatology', 'پوست', 5, ARRAY['dermatologist', 'dermatology', 'skin doctor', 'متخصص پوست', 'پوست']::text[]),
('doctors-specialists', 'Ophthalmology', 'چشم', 'ophthalmology', 'چشم', 6, ARRAY['eye doctor', 'ophthalmologist', 'ophthalmology', 'چشم', 'چشم پزشک']::text[]),
('doctors-specialists', 'Orthopedics', 'ارتوپدی', 'orthopedics', 'ارتوپدی', 7, ARRAY['bone doctor', 'orthopedics', 'orthopedist', 'ارتوپد', 'ارتوپدی', 'استخوان']::text[]),
('doctors-specialists', 'Neurology', 'مغز و اعصاب', 'neurology', 'مغز و اعصاب', 8, ARRAY['neurologist', 'neurology', 'مغز اعصاب', 'مغز و اعصاب']::text[]),
('doctors-specialists', 'Neurosurgery', 'جراحی مغز و اعصاب', 'neurosurgery', 'جراحی مغز و اعصاب', 9, ARRAY['neurosurgeon', 'neurosurgery', 'جراح مغز', 'جراحی مغز و اعصاب']::text[]),
('doctors-specialists', 'General Surgery', 'جراحی عمومی', 'general-surgery', 'جراحی عمومی', 10, ARRAY['general surgery', 'surgeon', 'جراح', 'جراحی عمومی']::text[]),
('doctors-specialists', 'Plastic & Aesthetic Surgery', 'جراحی پلاستیک و زیبایی', 'plastic-aesthetic-surgery', 'جراحی پلاستیک و زیبایی', 11, ARRAY['cosmetic surgery', 'plastic & aesthetic surgery', 'plastic aesthetic surgery', 'plastic surgery', 'جراحی زیبایی', 'جراحی پلاستیک و زیبایی', 'پلاستیک']::text[]),
('doctors-specialists', 'Obstetrics & Gynecology', 'زنان و زایمان', 'obstetrics-gynecology', 'زنان و زایمان', 12, ARRAY['gynecologist', 'obgyn', 'obstetrics & gynecology', 'obstetrics gynecology', 'زایمان', 'زنان', 'زنان و زایمان']::text[]),
('doctors-specialists', 'Infertility & IVF', 'ناباروری و IVF', 'infertility-ivf', 'ناباروری و IVF', 13, ARRAY['fertility', 'infertility & ivf', 'infertility ivf', 'ivf', 'ناباروری', 'ناباروری و ivf', 'نازایی']::text[]),
('doctors-specialists', 'Pediatrics', 'اطفال', 'pediatrics', 'اطفال', 14, ARRAY['kids doctor', 'pediatrician', 'pediatrics', 'اطفال', 'کودکان']::text[]),
('doctors-specialists', 'Urology', 'اورولوژی', 'urology', 'اورولوژی', 15, ARRAY['urologist', 'urology', 'اورولوژی']::text[]),
('doctors-specialists', 'Gastroenterology', 'گوارش', 'gastroenterology', 'گوارش', 16, ARRAY['gastro', 'gastroenterology', 'گوارش']::text[]),
('doctors-specialists', 'Endocrinology', 'غدد', 'endocrinology', 'غدد', 17, ARRAY['endocrinologist', 'endocrinology', 'دیابت', 'غدد']::text[]),
('doctors-specialists', 'Pulmonology', 'ریه', 'pulmonology', 'ریه', 18, ARRAY['lung doctor', 'pulmonology', 'تنفسی', 'ریه']::text[]),
('doctors-specialists', 'Nephrology', 'کلیه', 'nephrology', 'کلیه', 19, ARRAY['nephrologist', 'nephrology', 'کلیه']::text[]),
('doctors-specialists', 'Rheumatology', 'روماتولوژی', 'rheumatology', 'روماتولوژی', 20, ARRAY['rheumatologist', 'rheumatology', 'روماتولوژی', 'روماتیسم']::text[]),
('doctors-specialists', 'Hematology & Oncology', 'خون و سرطان', 'hematology-oncology', 'خون و سرطان', 21, ARRAY['cancer', 'hematology & oncology', 'hematology oncology', 'oncologist', 'خون', 'خون و سرطان', 'سرطان']::text[]),
('doctors-specialists', 'Infectious Disease', 'عفونی', 'infectious-disease', 'عفونی', 22, ARRAY['infectious', 'infectious disease', 'عفونی']::text[]),
('doctors-specialists', 'Allergy & Immunology', 'آلرژی و ایمونولوژی', 'allergy-immunology', 'آلرژی و ایمونولوژی', 23, ARRAY['allergy', 'allergy & immunology', 'allergy immunology', 'آلرژی', 'آلرژی و ایمونولوژی']::text[]),
('doctors-specialists', 'Psychiatry', 'روانپزشکی', 'psychiatry', 'روانپزشکی', 24, ARRAY['psychiatrist', 'psychiatry', 'روانپزشک', 'روانپزشکی']::text[]),
('doctors-specialists', 'Psychology', 'روانشناسی', 'psychology', 'روانشناسی', 25, ARRAY['counselor', 'psychologist', 'psychology', 'روانشناس', 'روانشناسی']::text[]),
('doctors-specialists', 'Dentistry', 'دندانپزشکی', 'dentistry', 'دندانپزشکی', 26, ARRAY['dental', 'dentist', 'dentistry', 'دندان', 'دندانپزشک', 'دندانپزشکی']::text[]),
('doctors-specialists', 'Orthodontics', 'ارتودنسی', 'orthodontics', 'ارتودنسی', 27, ARRAY['orthodontics', 'orthodontist', 'ارتودنسی']::text[]),
('doctors-specialists', 'Oral & Maxillofacial Surgery', 'جراحی فک و صورت', 'oral-maxillofacial-surgery', 'جراحی فک و صورت', 28, ARRAY['oral & maxillofacial surgery', 'oral maxillofacial surgery', 'oral surgery', 'جراحی فک', 'جراحی فک و صورت']::text[]),
('doctors-specialists', 'Radiology', 'رادیولوژی', 'radiology', 'رادیولوژی', 29, ARRAY['radiologist', 'radiology', 'رادیولوژی', 'عکسبرداری']::text[]),
('doctors-specialists', 'Nuclear Medicine', 'پزشکی هسته‌ای', 'nuclear-medicine', 'پزشکی هسته‌ای', 30, ARRAY['nuclear medicine', 'پزشکی هسته ای', 'پزشکی هسته‌ای']::text[]),
('doctors-specialists', 'Anesthesiology', 'بیهوشی', 'anesthesiology', 'بیهوشی', 31, ARRAY['anesthesiologist', 'anesthesiology', 'بیهوشی']::text[]),
('doctors-specialists', 'Emergency Medicine', 'اورژانس', 'emergency-medicine', 'اورژانس', 32, ARRAY['emergency doctor', 'emergency medicine', 'اورژانس']::text[]),
('doctors-specialists', 'Physical Medicine & Rehabilitation', 'طب فیزیکی و توانبخشی', 'physical-medicine-rehab', 'طب فیزیکی و توانبخشی', 33, ARRAY['physical medicine & rehabilitation', 'physical medicine rehab', 'pmr', 'rehab', 'توانبخشی', 'طب فیزیکی', 'طب فیزیکی و توانبخشی']::text[]),
('doctors-specialists', 'Traditional Iranian Medicine', 'طب سنتی ایرانی', 'traditional-iranian-medicine', 'طب سنتی ایرانی', 34, ARRAY['persian medicine', 'traditional iranian medicine', 'traditional medicine', 'طب سنتی', 'طب سنتی ایرانی', 'گیاهی']::text[]),
('doctors-specialists', 'Nutrition & Dietetics', 'تغذیه', 'nutrition-dietetics', 'تغذیه', 35, ARRAY['dietitian', 'nutrition & dietetics', 'nutrition dietetics', 'nutritionist', 'تغذیه', 'رژیم']::text[]),
('doctors-specialists', 'Occupational Medicine', 'طب کار', 'occupational-medicine', 'طب کار', 36, ARRAY['occupational health', 'occupational medicine', 'طب کار']::text[]),
('clinics-centers', 'Specialty Clinics', 'کلینیک تخصصی', 'specialty-clinics', 'کلینیک تخصصی', 1, ARRAY['specialty clinic', 'specialty clinics', 'کلینیک تخصصی']::text[]),
('clinics-centers', 'Polyclinics', 'درمانگاه', 'polyclinics', 'درمانگاه', 2, ARRAY['polyclinic', 'polyclinics', 'درمانگاه']::text[]),
('clinics-centers', 'Dental Clinics', 'کلینیک دندانپزشکی', 'dental-clinics', 'کلینیک دندانپزشکی', 3, ARRAY['dental clinic', 'dental clinics', 'کلینیک دندان', 'کلینیک دندانپزشکی']::text[]),
('clinics-centers', 'Aesthetic Clinics', 'کلینیک زیبایی', 'aesthetic-clinics', 'کلینیک زیبایی', 4, ARRAY['aesthetic clinic', 'aesthetic clinics', 'کلینیک زیبایی']::text[]),
('clinics-centers', 'Infertility Centers', 'مراکز ناباروری', 'infertility-centers', 'مراکز ناباروری', 5, ARRAY['infertility centers', 'ivf center', 'مراکز ناباروری', 'مرکز ناباروری']::text[]),
('hospitals-labs', 'Hospitals', 'بیمارستان', 'hospitals', 'بیمارستان', 1, ARRAY['hospital', 'hospitals', 'بیمارستان']::text[]),
('hospitals-labs', 'Diagnostic Labs', 'آزمایشگاه تشخیصی', 'diagnostic-labs', 'آزمایشگاه تشخیصی', 2, ARRAY['diagnostic labs', 'lab', 'laboratory', 'آزمایشگاه', 'آزمایشگاه تشخیصی']::text[]),
('hospitals-labs', 'Imaging Centers', 'مراکز تصویربرداری', 'imaging-centers', 'مراکز تصویربرداری', 3, ARRAY['ct scan', 'imaging centers', 'mri', 'تصویربرداری', 'سونوگرافی', 'مراکز تصویربرداری']::text[]),
('care-support', 'Physiotherapy', 'فیزیوتراپی', 'physiotherapy', 'فیزیوتراپی', 1, ARRAY['physical therapy', 'physio', 'physiotherapy', 'فیزیوتراپی']::text[]),
('care-support', 'Nursing & Home Care', 'پرستاری و مراقبت در منزل', 'nursing-home-care', 'پرستاری و مراقبت در منزل', 2, ARRAY['home care', 'nurse', 'nursing & home care', 'nursing home care', 'مراقبت در منزل', 'پرستاری و مراقبت در منزل']::text[]),
('care-support', 'Midwifery', 'مامایی', 'midwifery', 'مامایی', 3, ARRAY['midwife', 'midwifery', 'ماما', 'مامایی']::text[]),
('care-support', 'Pharmacy Services', 'خدمات داروخانه', 'pharmacy-services', 'خدمات داروخانه', 4, ARRAY['drug store', 'drugstore', 'pharmacy service', 'pharmacy services', 'خدمات داروخانه', 'داروخانه']::text[]),
('vehicle-sales', 'Car Dealership', 'نمایندگی خودرو', 'car-dealership', 'نمایندگی خودرو', 1, ARRAY['car dealer', 'car dealership', 'dealership', 'نمایندگی', 'نمایندگی خودرو']::text[]),
('vehicle-sales', 'Motorcycle Dealer', 'فروش موتورسیکلت', 'motorcycle-dealer', 'فروش موتورسیکلت', 2, ARRAY['bike dealer', 'motorcycle dealer', 'فروش موتورسیکلت', 'موتور']::text[]),
('repair-parts', 'Auto Repair', 'تعمیرگاه خودرو', 'auto-repair', 'تعمیرگاه خودرو', 1, ARRAY['auto repair', 'garage', 'mechanic', 'تعمیرگاه', 'تعمیرگاه خودرو', 'مکانیکی']::text[]),
('repair-parts', 'Auto Parts', 'لوازم یدکی', 'auto-parts', 'لوازم یدکی', 2, ARRAY['auto parts', 'spare parts', 'قطعات', 'لوازم یدکی', 'یدکی']::text[]),
('repair-parts', 'Car Wash & Detailing', 'کارواش و دیتیلینگ', 'car-wash-detailing', 'کارواش و دیتیلینگ', 3, ARRAY['car wash', 'car wash & detailing', 'car wash detailing', 'detailing', 'کارواش', 'کارواش و دیتیلینگ']::text[])
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
JOIN categories p ON p.slug = v.parent_slug
ON CONFLICT (slug) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
-- Seeded 237 categories (L1=3, L2=14, L3=43, L4=177)
@@ -0,0 +1,21 @@
-- Add Other catch-all to AriyaWay marketplace categories
ALTER TABLE categories ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
VALUES (
NULL,
'Other',
'سایر',
'other',
99,
ARRAY['other', 'misc', 'miscellaneous', 'uncategorized', 'general', 'سایر', 'متفرقه', 'عمومی']::text[],
TRUE
)
ON CONFLICT (slug) DO UPDATE SET
parent_id = NULL,
name = EXCLUDED.name,
name_fa = EXCLUDED.name_fa,
sort_order = EXCLUDED.sort_order,
tags = EXCLUDED.tags,
is_active = TRUE,
updated_at = NOW();
@@ -0,0 +1,14 @@
-- Slim marketplace businesses: Meshkee owns profile content.
-- AriyaWay keeps listing fields + display name only.
ALTER TABLE businesses
DROP COLUMN IF EXISTS website_url,
DROP COLUMN IF EXISTS tagline,
DROP COLUMN IF EXISTS description,
DROP COLUMN IF EXISTS logo_url,
DROP COLUMN IF EXISTS hero_url,
DROP COLUMN IF EXISTS province,
DROP COLUMN IF EXISTS country,
DROP COLUMN IF EXISTS telephone,
DROP COLUMN IF EXISTS since_year,
DROP COLUMN IF EXISTS synced_at;
@@ -0,0 +1,12 @@
-- Minimum marketplace listing: Meshkee owns name, categories, and profile.
-- AriyaWay/IranBay keeps only listing controls.
ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_primary_category_id_fkey;
DROP TABLE IF EXISTS business_categories;
ALTER TABLE businesses
DROP COLUMN IF EXISTS primary_category_id,
DROP COLUMN IF EXISTS name;
DROP TABLE IF EXISTS categories;
@@ -0,0 +1,10 @@
-- Homepage media config for the public website (slider, banners, categories, about).
CREATE TABLE IF NOT EXISTS website_settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE website_settings IS
'Key/value JSON settings for marketplace website content.';
+31
View File
@@ -0,0 +1,31 @@
-- Website blog posts managed from the superadmin dashboard.
CREATE TABLE IF NOT EXISTS website_blog_categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS website_blog_categories_name_unique
ON website_blog_categories (name);
CREATE TABLE IF NOT EXISTS website_blog_posts (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
abstract TEXT,
main_text_html TEXT,
image_url TEXT,
category_id BIGINT NOT NULL REFERENCES website_blog_categories (id) ON DELETE RESTRICT,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_website_blog_posts_created_at
ON website_blog_posts (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_website_blog_posts_category_id
ON website_blog_posts (category_id);
COMMENT ON TABLE website_blog_posts IS
'IranBay website blog posts edited from the superadmin dashboard.';
@@ -0,0 +1,21 @@
-- Blog categories can nest under a parent.
ALTER TABLE website_blog_categories
ADD COLUMN IF NOT EXISTS parent_id BIGINT;
DO $$
BEGIN
ALTER TABLE website_blog_categories
ADD CONSTRAINT website_blog_categories_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES website_blog_categories (id) ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DROP INDEX IF EXISTS website_blog_categories_name_unique;
CREATE INDEX IF NOT EXISTS idx_website_blog_categories_parent_id
ON website_blog_categories (parent_id);
CREATE UNIQUE INDEX IF NOT EXISTS website_blog_categories_parent_name_unique
ON website_blog_categories (COALESCE(parent_id, 0), lower(name));
@@ -0,0 +1,5 @@
-- Blog categories store the same details shown on the business category form.
ALTER TABLE website_blog_categories
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(120),
ADD COLUMN IF NOT EXISTS description TEXT;
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="${POSTGRES_CONTAINER:-ariyaway-postgres}"
DB_USER="${POSTGRES_USER:-ariyaway}"
DB_NAME="${POSTGRES_DB:-ariyaway}"
MAX_ATTEMPTS="${POSTGRES_WAIT_ATTEMPTS:-30}"
for ((i = 1; i <= MAX_ATTEMPTS; i++)); do
if docker exec "$CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
echo "Postgres is ready."
exit 0
fi
echo "Waiting for Postgres ($i/$MAX_ATTEMPTS)…"
sleep 1
done
echo "Postgres did not become ready in time." >&2
exit 1
+25
View File
@@ -0,0 +1,25 @@
services:
postgres:
image: postgres:16-alpine
container_name: ariyaway-postgres
restart: unless-stopped
ports:
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-ariyaway}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ariyaway_secret}
POSTGRES_DB: ${POSTGRES_DB:-ariyaway}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-ariyaway} -d ${POSTGRES_DB:-ariyaway}",
]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+4060
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "marketplace-api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"prisma:generate": "prisma generate",
"db:migrate": "./database/migrate.sh",
"bootstrap:iranbay": "node scripts/bootstrap-iranbay-platform.mjs"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@types/express": "^5.0.0",
"@types/multer": "^2.3.0",
"@types/node": "^22.0.0",
"prisma": "^6.0.0",
"typescript": "^5.7.0"
}
}
+67
View File
@@ -0,0 +1,67 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Marketplace listing only. Name, categories, and profile live in Meshkee.
model Business {
id BigInt @id @default(autoincrement())
meshkeeBusinessId BigInt @unique(map: "businesses_meshkee_business_id_unique") @map("meshkee_business_id")
slug String @unique(map: "businesses_slug_unique") @db.VarChar(100)
status String @default("draft") @db.VarChar(32)
isFeatured Boolean @default(false) @map("is_featured")
sortWeight Int @default(0) @map("sort_weight")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([status, isFeatured, sortWeight(sort: Desc)], map: "idx_businesses_status_featured")
@@map("businesses")
}
/// JSON settings for the public website (homepage media, etc.).
model WebsiteSetting {
key String @id
value Json
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@map("website_settings")
}
/// Categories for the IranBay website blog (superadmin).
model WebsiteBlogCategory {
id BigInt @id @default(autoincrement())
name String @db.VarChar(120)
nameFa String? @map("name_fa") @db.VarChar(120)
description String? @db.Text
parentId BigInt? @map("parent_id")
parent WebsiteBlogCategory? @relation("WebsiteBlogCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children WebsiteBlogCategory[] @relation("WebsiteBlogCategoryTree")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
posts WebsiteBlogPost[]
@@index([parentId], map: "idx_website_blog_categories_parent_id")
@@map("website_blog_categories")
}
/// Posts for the IranBay website blog (superadmin).
model WebsiteBlogPost {
id BigInt @id @default(autoincrement())
title String @db.VarChar(200)
abstract String? @db.Text
mainTextHtml String? @map("main_text_html") @db.Text
imageUrl String? @map("image_url") @db.Text
categoryId BigInt @map("category_id")
category WebsiteBlogCategory @relation(fields: [categoryId], references: [id], onDelete: Restrict)
tags String[] @default([])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([createdAt(sort: Desc)], map: "idx_website_blog_posts_created_at")
@@index([categoryId], map: "idx_website_blog_posts_category_id")
@@map("website_blog_posts")
}
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Bootstrap the IranBay platform tenant in Meshkee.
*
* This creates ONE Meshkee business used only for IranBay auth
* (customer accounts via domain iranbay.com).
*
* Producer shops that register on IranBay are SEPARATE Meshkee businesses
* (created later via admin / register-shop) — do not reuse this tenant.
*
* Usage:
* MESHKEE_API_URL=https://api.meshkee.com/api/v1 \
* MESHKEE_API_TOKEN='Bearer …' \ # superadmin access token
* node scripts/bootstrap-iranbay-platform.mjs
*
* Or login instead of token:
* MESHKEE_SUPERADMIN_CELL=+98… MESHKEE_SUPERADMIN_PASSWORD=… node …
*/
const API =
(process.env.MESHKEE_API_URL || "https://api.meshkee.com/api/v1").replace(
/\/$/,
"",
);
const HOST = (
process.env.IRANBAY_PLATFORM_DOMAIN || "iranbay.com"
).toLowerCase();
const SLUG = process.env.IRANBAY_PLATFORM_SLUG || "iranbay";
async function request(path, { method = "GET", token, body } = {}) {
const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = token.startsWith("Bearer ")
? token
: `Bearer ${token}`;
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const message =
typeof payload?.message === "string"
? payload.message
: Array.isArray(payload?.message)
? payload.message.join(", ")
: `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
async function resolveToken() {
const raw = process.env.MESHKEE_API_TOKEN?.trim();
if (raw) return raw.startsWith("Bearer ") ? raw : `Bearer ${raw}`;
const cell = process.env.MESHKEE_SUPERADMIN_CELL?.trim();
const password = process.env.MESHKEE_SUPERADMIN_PASSWORD;
if (!cell || !password) {
throw new Error(
"Set MESHKEE_API_TOKEN or MESHKEE_SUPERADMIN_CELL + MESHKEE_SUPERADMIN_PASSWORD",
);
}
const data = await request("/auth/login", {
method: "POST",
body: { cellNumber: cell, password },
});
if (!data.accessToken) throw new Error("Login did not return accessToken");
return `Bearer ${data.accessToken}`;
}
async function domainExists() {
const res = await fetch(`${API}/tenants/${HOST}/categories`);
return res.status !== 404;
}
async function main() {
console.log(`Meshkee API: ${API}`);
console.log(`Platform domain: ${HOST}`);
if (await domainExists()) {
console.log(`✓ Domain ${HOST} already resolves — nothing to do.`);
return;
}
const token = await resolveToken();
const categories = await request("/business-categories", { token });
const items = categories.items ?? [];
const other =
items.find((c) => String(c.slug) === "other") ??
items[0];
if (!other) {
throw new Error("No Meshkee business categories found (need at least one).");
}
console.log(`Creating Meshkee business slug=${SLUG}…`);
const ownerCell =
process.env.MESHKEE_SUPERADMIN_CELL?.trim() ||
process.env.IRANBAY_PLATFORM_OWNER_CELL?.trim();
const ownerPassword =
process.env.MESHKEE_SUPERADMIN_PASSWORD ||
process.env.IRANBAY_PLATFORM_OWNER_PASSWORD;
if (!ownerCell || !ownerPassword) {
throw new Error(
"Need owner cell/password to create the platform business (use MESHKEE_SUPERADMIN_* or IRANBAY_PLATFORM_OWNER_*).",
);
}
let business;
try {
business = await request("/businesses", {
method: "POST",
token,
body: {
name: "IranBay",
nameFa: "ایران‌بی",
slug: SLUG,
about:
"IranBay marketplace platform identity tenant (auth only — not a Meshkee storefront).",
categoryIds: [Number(other.id)],
ownerFirstName: "IranBay",
ownerLastName: "Platform",
ownerFirstNameEn: "IranBay",
ownerLastNameEn: "Platform",
ownerCellNumber: ownerCell,
ownerPassword,
},
});
} catch (err) {
if (String(err.message).toLowerCase().includes("slug")) {
console.log("Business slug may already exist — searching…");
const search = await request(
`/businesses/search?q=${encodeURIComponent(SLUG)}&limit=5`,
{ token },
);
business = (search.items ?? []).find(
(item) => String(item.slug) === SLUG,
);
if (!business) throw err;
} else {
throw err;
}
}
const businessId = String(business.id);
console.log(`✓ Meshkee business id=${businessId}`);
console.log(`Attaching domain ${HOST} (no storefront / no DNS)…`);
try {
await request(`/businesses/${businessId}/domains`, {
method: "POST",
token,
body: {
host: HOST,
isPrimary: true,
updateDns: false,
},
});
console.log(`✓ Domain ${HOST} linked`);
} catch (err) {
if (String(err.message).toLowerCase().includes("already")) {
console.log(`✓ Domain already attached (${err.message})`);
} else {
throw err;
}
}
if (!(await domainExists())) {
throw new Error(
`Domain attached but tenants/${HOST} still 404 — check Meshkee domain isActive.`,
);
}
console.log("");
console.log("Done. Set in dashboard/website:");
console.log(` NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN=${HOST}`);
console.log("");
console.log(
"Remember: producer shops register as SEPARATE Meshkee businesses + IranBay marketplace listings.",
);
}
main().catch((err) => {
console.error("Bootstrap failed:", err.message);
process.exit(1);
});
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { BusinessesModule } from './businesses/businesses.module';
import { WebsiteModule } from './website/website.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
BusinessesModule,
WebsiteModule,
],
})
export class AppModule {}
+73
View File
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Param,
Patch,
Post,
Query,
UnauthorizedException,
} from '@nestjs/common';
import { BusinessesService } from './businesses.service';
import {
CreateBusinessDto,
ListBusinessesDto,
RegisterShopDto,
UpdateBusinessDto,
} from './dto/business.dto';
@Controller('businesses')
export class BusinessesController {
constructor(private readonly service: BusinessesService) {}
@Get()
list(@Query() query: ListBusinessesDto) {
return this.service.list(query);
}
@Get('by-id/:id')
getById(@Param('id') id: string) {
return this.service.getById(id);
}
/** Self-serve: create Meshkee shop + IranBay listing (not the iranbay.com auth tenant). */
@Post('register-shop')
registerShop(
@Body() dto: RegisterShopDto,
@Headers('authorization') authorization?: string,
) {
if (!authorization?.trim()) {
throw new UnauthorizedException('Sign in required');
}
return this.service.registerShop(dto, authorization);
}
@Post()
create(
@Body() dto: CreateBusinessDto,
@Headers('authorization') authorization?: string,
) {
return this.service.create(dto, authorization);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateBusinessDto,
@Headers('authorization') authorization?: string,
) {
return this.service.update(id, dto, authorization);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Get(':slug')
getBySlug(@Param('slug') slug: string) {
return this.service.getBySlug(slug);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MeshkeeModule } from '../meshkee/meshkee.module';
import { BusinessesController } from './businesses.controller';
import { BusinessesService } from './businesses.service';
@Module({
imports: [MeshkeeModule],
controllers: [BusinessesController],
providers: [BusinessesService],
exports: [BusinessesService],
})
export class BusinessesModule {}
+285
View File
@@ -0,0 +1,285 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { MeshkeeClient } from '../meshkee/meshkee.client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateBusinessDto,
ListBusinessesDto,
RegisterShopDto,
UpdateBusinessDto,
} from './dto/business.dto';
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
@Injectable()
export class BusinessesService {
constructor(
private readonly prisma: PrismaService,
private readonly meshkee: MeshkeeClient,
) {}
async list(query: ListBusinessesDto) {
const page = query.page ?? 1;
const limit = Math.min(query.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.BusinessWhereInput = {};
if (query.status && query.status !== 'all') {
where.status = query.status;
} else if (!query.status) {
where.status = 'published';
}
if (query.featured === true) {
where.isFeatured = true;
}
if (query.q) {
where.slug = { contains: query.q, mode: 'insensitive' };
}
const [items, total] = await Promise.all([
this.prisma.business.findMany({
where,
orderBy: [
{ isFeatured: 'desc' },
{ sortWeight: 'desc' },
{ publishedAt: 'desc' },
{ id: 'desc' },
],
skip,
take: limit,
}),
this.prisma.business.count({ where }),
]);
return { items, page, limit, total };
}
async getBySlug(slug: string) {
const business = await this.prisma.business.findUnique({ where: { slug } });
if (!business) {
throw new NotFoundException(`Business "${slug}" not found`);
}
return business;
}
async getById(id: string) {
const business = await this.prisma.business.findUnique({
where: { id: BigInt(id) },
});
if (!business) {
throw new NotFoundException(`Business ${id} not found`);
}
return business;
}
/**
* Self-serve shop signup:
* 1) Meshkee business owned by the logged-in IranBay user (NOT the iranbay.com tenant)
* 2) IranBay marketplace listing (pending_review)
*/
async registerShop(dto: RegisterShopDto, authorization?: string) {
if (!authorization?.trim()) {
throw new UnauthorizedException('Sign in required');
}
const user = await this.meshkee.getMe(authorization);
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('Business name is required');
}
const slug = (dto.slug?.trim() || slugify(name)).slice(0, 100);
if (!slug) {
throw new BadRequestException('Could not build a slug from the name');
}
let categoryIds = dto.meshkeeCategoryIds?.length
? this.parseMeshkeeCategoryIds(dto.meshkeeCategoryIds)
: [];
if (!categoryIds.length) {
const categories = await this.meshkee.listBusinessCategories();
const other =
categories.find((item) => String(item.slug) === 'other') ??
categories[0];
if (!other) {
throw new BadRequestException('No Meshkee categories available');
}
categoryIds = [Number(other.id)];
}
const firstName =
user.firstNameEn?.trim() || user.firstName?.trim() || 'Owner';
const lastName =
user.lastNameEn?.trim() || user.lastName?.trim() || firstName;
const created = await this.meshkee.createBusiness({
name,
nameFa: name,
slug,
about: dto.about?.trim() || undefined,
categoryIds,
ownerFirstName: firstName,
ownerLastName: lastName.length >= 2 ? lastName : firstName,
ownerFirstNameEn: firstName,
ownerLastNameEn: lastName.length >= 2 ? lastName : firstName,
ownerCellNumber: user.cellNumber,
ownerPassword: dto.ownerPassword,
});
try {
const listing = await this.prisma.business.create({
data: {
meshkeeBusinessId: BigInt(created.id),
slug: created.slug || slug,
status: 'pending_review',
isFeatured: false,
sortWeight: 0,
publishedAt: null,
},
});
return {
listing,
meshkeeBusinessId: created.id,
message:
'Shop created in Meshkee and submitted to IranBay for review.',
};
} catch (error) {
this.rethrowUnique(error);
throw error;
}
}
async create(dto: CreateBusinessDto, authorization?: string) {
const status = dto.status ?? 'draft';
const now = new Date();
const meshkeePatch: {
name?: string;
slug?: string;
categoryIds?: number[];
} = { slug: dto.slug };
if (dto.name?.trim()) meshkeePatch.name = dto.name.trim();
if (dto.meshkeeCategoryIds?.length) {
meshkeePatch.categoryIds = this.parseMeshkeeCategoryIds(
dto.meshkeeCategoryIds,
);
}
await this.meshkee.updateBusiness(
dto.meshkeeBusinessId,
meshkeePatch,
authorization,
);
try {
return await this.prisma.business.create({
data: {
meshkeeBusinessId: BigInt(dto.meshkeeBusinessId),
slug: dto.slug,
status,
isFeatured: dto.isFeatured ?? false,
sortWeight: dto.sortWeight ?? 0,
publishedAt: status === 'published' ? now : null,
},
});
} catch (error) {
this.rethrowUnique(error);
throw error;
}
}
async update(id: string, dto: UpdateBusinessDto, authorization?: string) {
const existing = await this.getById(id);
const data: Prisma.BusinessUpdateInput = {
slug: dto.slug,
status: dto.status,
isFeatured: dto.isFeatured,
sortWeight: dto.sortWeight,
};
if (dto.status === 'published' && existing.status !== 'published') {
data.publishedAt = new Date();
}
const meshkeePatch: {
name?: string;
slug?: string;
categoryIds?: number[];
} = {};
if (dto.name !== undefined) meshkeePatch.name = dto.name;
if (dto.slug !== undefined) meshkeePatch.slug = dto.slug;
if (dto.meshkeeCategoryIds !== undefined) {
meshkeePatch.categoryIds = this.parseMeshkeeCategoryIds(
dto.meshkeeCategoryIds,
);
}
if (Object.keys(meshkeePatch).length) {
await this.meshkee.updateBusiness(
String(existing.meshkeeBusinessId),
meshkeePatch,
authorization,
);
}
try {
return await this.prisma.business.update({
where: { id: BigInt(id) },
data,
});
} catch (error) {
this.rethrowUnique(error);
throw error;
}
}
async remove(id: string) {
await this.getById(id);
await this.prisma.business.delete({ where: { id: BigInt(id) } });
return { ok: true };
}
private parseMeshkeeCategoryIds(ids: string[]): number[] {
const parsed = [...new Set(ids)].map((id) => {
const n = Number(id);
if (!Number.isInteger(n) || n <= 0) {
throw new BadRequestException(`Invalid Meshkee category id: ${id}`);
}
return n;
});
if (!parsed.length) {
throw new BadRequestException('meshkeeCategoryIds cannot be empty');
}
return parsed;
}
private rethrowUnique(error: unknown): void {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
const target = Array.isArray(error.meta?.target)
? error.meta.target.join(', ')
: 'field';
throw new BadRequestException(`Business ${target} already exists`);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export const BUSINESS_STATUSES = [
'draft',
'pending_review',
'published',
'hidden',
'rejected',
] as const;
export type BusinessStatus = (typeof BUSINESS_STATUSES)[number];
export class CreateBusinessDto {
@IsString()
meshkeeBusinessId!: string;
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
@MaxLength(100)
slug!: string;
/** Written to Meshkee only (not stored in marketplace DB). */
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
/** Meshkee business_category ids — written to Meshkee only. */
@IsOptional()
@IsArray()
@IsString({ each: true })
meshkeeCategoryIds?: string[];
@IsOptional()
@IsIn(BUSINESS_STATUSES)
status?: BusinessStatus;
@IsOptional()
@IsBoolean()
isFeatured?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
sortWeight?: number;
}
/** Public shop signup: creates a NEW Meshkee business + IranBay marketplace listing. */
export class RegisterShopDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
@MaxLength(100)
slug?: string;
@IsOptional()
@IsString()
@MaxLength(5000)
about?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
meshkeeCategoryIds?: string[];
/**
* Account password confirmation — Meshkee create-owner updates password for
* existing cells; must match the registrant’s current password.
*/
@IsString()
@MinLength(8)
ownerPassword!: string;
}
export class UpdateBusinessDto {
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
@MaxLength(100)
slug?: string;
/** Written to Meshkee only. */
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
/** Meshkee business_category ids — written to Meshkee only. */
@IsOptional()
@IsArray()
@IsString({ each: true })
meshkeeCategoryIds?: string[];
@IsOptional()
@IsIn(BUSINESS_STATUSES)
status?: BusinessStatus;
@IsOptional()
@IsBoolean()
isFeatured?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
sortWeight?: number;
}
export class ListBusinessesDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn([...BUSINESS_STATUSES, 'all'])
status?: BusinessStatus | 'all';
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
featured?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number;
}
@@ -0,0 +1,39 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
function serialize(value: unknown): unknown {
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(serialize);
}
if (value && typeof value === 'object') {
// Skip streams / class instances Nest may pass through @Res handlers.
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) {
return value;
}
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
out[key] = serialize(entry);
}
return out;
}
return value;
}
@Injectable()
export class BigIntSerializerInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(map((data) => serialize(data)));
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
app.enableCors();
const port = Number(process.env.PORT ?? 3001);
await app.listen(port);
console.log(`Marketplace API listening on http://localhost:${port}/api/v1`);
}
bootstrap();
+449
View File
@@ -0,0 +1,449 @@
import {
BadGatewayException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type MeshkeeBusinessUpdate = {
name?: string;
slug?: string;
nameFa?: string;
about?: string;
categoryIds?: number[];
};
export type MeshkeeCreateBusinessInput = {
name: string;
nameFa: string;
slug?: string;
about?: string;
categoryIds: number[];
ownerFirstName: string;
ownerLastName: string;
ownerFirstNameEn: string;
ownerLastNameEn: string;
ownerCellNumber: string;
ownerPassword: string;
};
export type MeshkeeAuthUser = {
id: string;
cellNumber: string;
email: string | null;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
dashboard: string;
};
type CachedToken = { token: string; expiresAt: number };
@Injectable()
export class MeshkeeClient {
private readonly logger = new Logger(MeshkeeClient.name);
private cachedToken: CachedToken | null = null;
constructor(private readonly config: ConfigService) {}
private apiBase(): string {
return (
this.config.get<string>('MESHKEE_API_URL')?.replace(/\/$/, '') ||
'http://localhost:3010/api/v1'
);
}
private tokenExpiry(token: string) {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1] ?? '', 'base64url').toString(),
) as { exp?: number };
if (typeof payload.exp === 'number') return payload.exp * 1000;
} catch {
/* ignore malformed tokens */
}
return 0;
}
private usableConfiguredToken() {
const token = this.config.get<string>('MESHKEE_API_TOKEN')?.trim();
if (!token) return null;
const expiresAt = this.tokenExpiry(token);
if (expiresAt && expiresAt < Date.now() + 15_000) return null;
return token;
}
private async loginServiceToken(): Promise<string | null> {
const cellNumber = this.config.get<string>('MESHKEE_SUPERADMIN_CELL')?.trim();
const password = this.config
.get<string>('MESHKEE_SUPERADMIN_PASSWORD')
?.trim();
if (!cellNumber || !password) return null;
const response = await fetch(`${this.apiBase()}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cellNumber, password }),
});
if (!response.ok) {
this.logger.warn(
`Meshkee service login failed (${response.status})`,
);
return null;
}
const payload = (await response.json().catch(() => null)) as {
accessToken?: string;
} | null;
const token = payload?.accessToken?.trim();
if (!token) return null;
this.cachedToken = {
token,
expiresAt: this.tokenExpiry(token) || Date.now() + 10 * 60 * 1000,
};
return token;
}
private async resolveServiceToken(): Promise<string | null> {
const now = Date.now();
if (this.cachedToken && this.cachedToken.expiresAt > now + 15_000) {
return this.cachedToken.token;
}
const configured = this.usableConfiguredToken();
if (configured) return configured;
return this.loginServiceToken();
}
private async authHeader(authorization?: string): Promise<string | null> {
if (authorization?.trim()) return authorization.trim();
const token = await this.resolveServiceToken();
return token ? `Bearer ${token}` : null;
}
private async serviceAuth(): Promise<string> {
const auth = await this.authHeader();
if (!auth) {
throw new UnauthorizedException(
'Meshkee auth missing. Set MESHKEE_API_TOKEN or MESHKEE_SUPERADMIN_CELL + MESHKEE_SUPERADMIN_PASSWORD.',
);
}
return auth;
}
private async parseError(response: Response, fallback: string) {
const text = await response.text().catch(() => '');
try {
const json = JSON.parse(text) as { message?: string | string[] };
if (Array.isArray(json.message)) return json.message.join(', ');
if (typeof json.message === 'string') return json.message;
} catch {
/* ignore */
}
return text.slice(0, 300) || fallback;
}
async getMe(authorization: string): Promise<MeshkeeAuthUser> {
const response = await fetch(`${this.apiBase()}/auth/me`, {
headers: { Authorization: authorization },
});
if (!response.ok) {
throw new UnauthorizedException('Invalid or expired Meshkee session');
}
const payload = (await response.json()) as { user: MeshkeeAuthUser };
return payload.user;
}
async listBusinessCategories(authorization?: string) {
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
const response = await fetch(`${this.apiBase()}/business-categories`, {
headers: { Authorization: auth },
});
if (!response.ok) {
throw new BadGatewayException(
await this.parseError(response, 'Failed to list Meshkee categories'),
);
}
const payload = (await response.json()) as {
items?: Array<{
id: string | number;
parentId?: string | number | null;
parent_id?: string | number | null;
name?: string;
nameFa?: string | null;
name_fa?: string | null;
slug?: string;
}>;
};
return (payload.items ?? []).map((item) => ({
id: String(item.id),
parentId:
item.parentId == null && item.parent_id == null
? null
: String(item.parentId ?? item.parent_id),
name: String(item.name ?? ''),
nameFa:
item.nameFa == null && item.name_fa == null
? null
: String(item.nameFa ?? item.name_fa),
slug: String(item.slug ?? ''),
}));
}
async createBusiness(
body: MeshkeeCreateBusinessInput,
authorization?: string,
) {
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
const response = await fetch(`${this.apiBase()}/businesses`, {
method: 'POST',
headers: {
Authorization: auth,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new BadGatewayException(
await this.parseError(response, 'Failed to create Meshkee business'),
);
}
const data = (await response.json()) as {
id: string | number;
slug?: string;
name?: string;
};
return { ...data, id: String(data.id) };
}
async addDomain(businessId: string, host: string, authorization?: string) {
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
const response = await fetch(
`${this.apiBase()}/businesses/${businessId}/domains`,
{
method: 'POST',
headers: {
Authorization: auth,
'Content-Type': 'application/json',
},
body: JSON.stringify({
host,
isPrimary: true,
updateDns: false,
}),
},
);
if (!response.ok) {
throw new BadGatewayException(
await this.parseError(response, 'Failed to attach Meshkee domain'),
);
}
return response.json();
}
async getBusiness(
meshkeeBusinessId: string,
authorization?: string,
): Promise<{
id: string;
name: string;
nameFa: string | null;
slug: string;
logoUrl: string | null;
bannerUrl: string | null;
categories: Array<{
id: string;
name: string;
slug: string;
parentId: string | null;
}>;
}> {
const auth =
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
const response = await fetch(
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
{ headers: { Authorization: auth } },
);
if (!response.ok) {
throw new BadGatewayException(
await this.parseError(response, 'Failed to load Meshkee business'),
);
}
const data = (await response.json()) as {
id: string | number;
name?: string;
nameFa?: string | null;
slug?: string;
logoUrl?: string | null;
bannerUrl?: string | null;
categories?: Array<{
id: string | number;
name?: string;
slug?: string;
parentId?: string | number | null;
parent_id?: string | number | null;
}>;
};
return {
id: String(data.id),
name: data.name ?? '',
nameFa: data.nameFa ?? null,
slug: data.slug ?? '',
logoUrl: data.logoUrl ?? null,
bannerUrl: data.bannerUrl ?? null,
categories: (data.categories ?? []).map((category) => ({
id: String(category.id),
name: category.name ?? '',
slug: category.slug ?? '',
parentId:
category.parentId == null && category.parent_id == null
? null
: String(category.parentId ?? category.parent_id),
})),
};
}
async getBusinessProfile(
meshkeeBusinessId: string,
authorization?: string,
): Promise<{
nameEn: string;
nameFa: string;
title: string;
about: string;
logoUrl: string | null;
bannerUrl: string | null;
categoryIds: string[];
}> {
const auth =
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
const response = await fetch(
`${this.apiBase()}/businesses/${meshkeeBusinessId}/profile`,
{ headers: { Authorization: auth } },
);
if (!response.ok) {
throw new BadGatewayException(
await this.parseError(
response,
'Failed to load Meshkee business profile',
),
);
}
const data = (await response.json()) as {
profile?: {
nameEn?: string;
nameFa?: string;
title?: string;
about?: string;
logoUrl?: string | null;
bannerUrl?: string | null;
categoryIds?: Array<string | number>;
};
};
const profile = data.profile ?? {};
return {
nameEn: profile.nameEn ?? '',
nameFa: profile.nameFa ?? '',
title: profile.title ?? '',
about: profile.about ?? '',
logoUrl: profile.logoUrl ?? null,
bannerUrl: profile.bannerUrl ?? null,
categoryIds: (profile.categoryIds ?? []).map(String),
};
}
async getProductCount(
meshkeeBusinessId: string,
authorization?: string,
): Promise<number> {
const auth =
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
const params = new URLSearchParams({ page: '1', pageSize: '1' });
const response = await fetch(
`${this.apiBase()}/businesses/${meshkeeBusinessId}/products?${params}`,
{ headers: { Authorization: auth } },
);
if (!response.ok) {
this.logger.warn(
`Failed to load product count for ${meshkeeBusinessId} (${response.status})`,
);
return 0;
}
const data = (await response.json().catch(() => null)) as {
total?: number;
items?: unknown[];
} | null;
if (typeof data?.total === 'number') return data.total;
return Array.isArray(data?.items) ? data.items.length : 0;
}
/** Write profile / category fields to Meshkee (source of truth for content). */
async updateBusiness(
meshkeeBusinessId: string,
body: MeshkeeBusinessUpdate,
authorization?: string,
): Promise<void> {
let auth = await this.authHeader(authorization);
if (!auth) {
this.logger.warn(
`Skipping Meshkee update for business ${meshkeeBusinessId}: no auth token`,
);
return;
}
const payload: Record<string, unknown> = {};
if (body.name !== undefined) payload.name = body.name;
if (body.slug !== undefined) payload.slug = body.slug;
if (body.nameFa !== undefined) payload.nameFa = body.nameFa;
if (body.about !== undefined) payload.about = body.about;
if (body.categoryIds !== undefined) payload.categoryIds = body.categoryIds;
if (Object.keys(payload).length === 0) return;
let response = await fetch(
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
{
method: 'PATCH',
headers: {
Authorization: auth,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);
// Incoming user JWT may be for a different Meshkee host; retry with service login.
if (response.status === 401) {
this.cachedToken = null;
const serviceToken = await this.loginServiceToken();
if (serviceToken) {
auth = `Bearer ${serviceToken}`;
response = await fetch(
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
{
method: 'PATCH',
headers: {
Authorization: auth,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);
}
}
if (!response.ok) {
const text = await response.text().catch(() => '');
this.logger.error(
`Meshkee update failed (${response.status}): ${text.slice(0, 300)}`,
);
throw new BadGatewayException(
`Failed to update Meshkee business (${response.status})`,
);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MeshkeeClient } from './meshkee.client';
@Module({
providers: [MeshkeeClient],
exports: [MeshkeeClient],
})
export class MeshkeeModule {}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class SpecialCarouselDto {
@IsString()
id!: string;
@IsString()
title!: string;
@IsOptional()
@IsString()
subtext?: string;
@IsString()
slug!: string;
@IsArray()
@IsString({ each: true })
businessIds!: string[];
}
export class UpdateSpecialBusinessesDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SpecialCarouselDto)
carousels!: SpecialCarouselDto[];
}
+61
View File
@@ -0,0 +1,61 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class CreateWebsiteBlogCategoryDto {
@IsString()
@IsNotEmpty()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
@MaxLength(120)
nameFa?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
}
export class CreateWebsiteBlogPostDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title!: string;
@IsString()
@IsNotEmpty()
categoryId!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
abstract?: string;
@IsOptional()
@IsString()
mainTextHtml?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
imageUrl?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@MaxLength(80, { each: true })
tags?: string[];
}
+70
View File
@@ -0,0 +1,70 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
export class WebsiteMediaItemDto {
@IsString()
id!: string;
@IsString()
imageUrl!: string;
@IsString()
linkUrl!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
subtitle?: string;
@IsOptional()
@IsString()
cta?: string;
}
export class UpdateWebsiteHomeMediaDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => WebsiteMediaItemDto)
slider!: WebsiteMediaItemDto[];
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(2)
@ValidateNested({ each: true })
@Type(() => WebsiteMediaItemDto)
standingBanners!: WebsiteMediaItemDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => WebsiteMediaItemDto)
categories!: WebsiteMediaItemDto[];
@IsArray()
@ArrayMinSize(4)
@ArrayMaxSize(4)
@ValidateNested({ each: true })
@Type(() => WebsiteMediaItemDto)
menuImages!: WebsiteMediaItemDto[];
@ValidateNested()
@Type(() => WebsiteMediaItemDto)
aboutUs!: WebsiteMediaItemDto;
@IsArray()
@ArrayMinSize(5)
@ArrayMaxSize(5)
@ValidateNested({ each: true })
@Type(() => WebsiteMediaItemDto)
banners!: WebsiteMediaItemDto[];
}
+57
View File
@@ -0,0 +1,57 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import {
CreateWebsiteBlogCategoryDto,
CreateWebsiteBlogPostDto,
} from './dto/website-blog.dto';
import { WebsiteBlogService } from './website-blog.service';
@Controller('website/blog')
export class WebsiteBlogController {
constructor(private readonly blog: WebsiteBlogService) {}
@Get('categories')
listCategories() {
return this.blog.listCategories();
}
@Post('categories')
createCategory(@Body() body: CreateWebsiteBlogCategoryDto) {
return this.blog.createCategory(body);
}
@Patch('categories/:id')
updateCategory(
@Param('id') id: string,
@Body() body: CreateWebsiteBlogCategoryDto,
) {
return this.blog.updateCategory(id, body);
}
@Delete('categories/:id')
deleteCategory(@Param('id') id: string) {
return this.blog.deleteCategory(id);
}
@Get('posts')
listPosts() {
return this.blog.listPosts();
}
@Post('posts')
createPost(@Body() body: CreateWebsiteBlogPostDto) {
return this.blog.createPost(body);
}
@Delete('posts/:id')
deletePost(@Param('id') id: string) {
return this.blog.deletePost(id);
}
}
+296
View File
@@ -0,0 +1,296 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateWebsiteBlogCategoryDto,
CreateWebsiteBlogPostDto,
} from './dto/website-blog.dto';
@Injectable()
export class WebsiteBlogService {
constructor(private readonly prisma: PrismaService) {}
async listCategories() {
const items = await this.prisma.websiteBlogCategory.findMany({
orderBy: { name: 'asc' },
});
return { items };
}
async createCategory(dto: CreateWebsiteBlogCategoryDto) {
const name = dto.name.trim();
if (!name) {
throw new BadRequestException('Enter a category name.');
}
const parentId = dto.parentId?.trim()
? this.parseId(dto.parentId.trim())
: null;
if (parentId) {
const parent = await this.prisma.websiteBlogCategory.findUnique({
where: { id: parentId },
});
if (!parent) {
throw new NotFoundException('Parent category not found.');
}
}
const existing = await this.prisma.websiteBlogCategory.findFirst({
where: {
parentId,
name: { equals: name, mode: 'insensitive' },
},
});
if (existing) {
throw new BadRequestException('This category already exists here.');
}
try {
return await this.prisma.websiteBlogCategory.create({
data: {
name,
nameFa: dto.nameFa?.trim() || null,
description: dto.description?.trim() || null,
parentId: parentId ?? null,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException('This category already exists here.');
}
throw error;
}
}
async updateCategory(id: string, dto: CreateWebsiteBlogCategoryDto) {
const categoryId = this.parseId(id);
const current = await this.prisma.websiteBlogCategory.findUnique({
where: { id: categoryId },
});
if (!current) {
throw new NotFoundException('Category not found.');
}
const name = dto.name.trim();
if (!name) {
throw new BadRequestException('Enter a category name.');
}
const parentId = dto.parentId?.trim()
? this.parseId(dto.parentId.trim())
: null;
if (parentId === categoryId) {
throw new BadRequestException('A category cannot be its own parent.');
}
const rows = await this.prisma.websiteBlogCategory.findMany({
select: { id: true, parentId: true },
});
if (parentId) {
const descendants = this.descendantIds(categoryId, rows);
if (descendants.has(parentId.toString())) {
throw new BadRequestException(
'Choose a parent outside this category.',
);
}
const parent = rows.find((row) => row.id === parentId);
if (!parent) {
throw new NotFoundException('Parent category not found.');
}
}
const existing = await this.prisma.websiteBlogCategory.findFirst({
where: {
parentId,
name: { equals: name, mode: 'insensitive' },
NOT: { id: categoryId },
},
});
if (existing) {
throw new BadRequestException('This category already exists here.');
}
return this.prisma.websiteBlogCategory.update({
where: { id: categoryId },
data: {
name,
nameFa: dto.nameFa?.trim() || null,
description: dto.description?.trim() || null,
parentId,
},
});
}
async deleteCategory(id: string) {
const rootId = this.parseId(id);
const rows = await this.prisma.websiteBlogCategory.findMany({
select: { id: true, parentId: true },
});
if (!rows.some((row) => row.id === rootId)) {
throw new NotFoundException('Category not found.');
}
const subtree = this.subtreePostOrder(rootId, rows);
const posts = await this.prisma.websiteBlogPost.count({
where: { categoryId: { in: subtree } },
});
if (posts > 0) {
throw new BadRequestException(
'Remove posts in this category before deleting it.',
);
}
for (const categoryId of subtree) {
await this.prisma.websiteBlogCategory.delete({ where: { id: categoryId } });
}
return { ok: true };
}
async listPosts() {
const posts = await this.prisma.websiteBlogPost.findMany({
orderBy: { createdAt: 'desc' },
include: { category: true },
});
return {
total: posts.length,
items: posts.map((post) => ({
id: post.id,
title: post.title,
abstract: post.abstract,
imageUrl: post.imageUrl,
categoryId: post.categoryId,
categoryName: post.category.name,
tags: post.tags,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
})),
};
}
async createPost(dto: CreateWebsiteBlogPostDto) {
const categoryId = this.parseId(dto.categoryId);
const category = await this.prisma.websiteBlogCategory.findUnique({
where: { id: categoryId },
});
if (!category) {
throw new NotFoundException('Category not found.');
}
const post = await this.prisma.websiteBlogPost.create({
data: {
title: dto.title.trim(),
abstract: dto.abstract?.trim() || null,
mainTextHtml: dto.mainTextHtml?.trim() || null,
imageUrl: dto.imageUrl?.trim() || null,
categoryId,
tags: this.normalizeTags(dto.tags),
},
include: { category: true },
});
return {
id: post.id,
title: post.title,
abstract: post.abstract,
mainTextHtml: post.mainTextHtml,
imageUrl: post.imageUrl,
categoryId: post.categoryId,
categoryName: post.category.name,
tags: post.tags,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
};
}
async deletePost(id: string) {
const postId = this.parseId(id);
try {
await this.prisma.websiteBlogPost.delete({ where: { id: postId } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException('Blog post not found.');
}
throw error;
}
return { ok: true };
}
private descendantIds(
rootId: bigint,
rows: { id: bigint; parentId: bigint | null }[],
) {
const childrenByParent = new Map<string, bigint[]>();
for (const row of rows) {
if (row.parentId == null) continue;
const key = row.parentId.toString();
const list = childrenByParent.get(key) ?? [];
list.push(row.id);
childrenByParent.set(key, list);
}
const result = new Set<string>();
const queue = [...(childrenByParent.get(rootId.toString()) ?? [])];
while (queue.length) {
const current = queue.shift()!;
const key = current.toString();
if (result.has(key)) continue;
result.add(key);
queue.push(...(childrenByParent.get(key) ?? []));
}
return result;
}
private subtreePostOrder(
rootId: bigint,
rows: { id: bigint; parentId: bigint | null }[],
) {
const childrenByParent = new Map<string, bigint[]>();
for (const row of rows) {
if (row.parentId == null) continue;
const key = row.parentId.toString();
const list = childrenByParent.get(key) ?? [];
list.push(row.id);
childrenByParent.set(key, list);
}
const order: bigint[] = [];
const walk = (current: bigint) => {
for (const child of childrenByParent.get(current.toString()) ?? []) {
walk(child);
}
order.push(current);
};
walk(rootId);
return order;
}
private parseId(value: string) {
if (!/^\d+$/.test(value)) {
throw new BadRequestException('Invalid id.');
}
return BigInt(value);
}
private normalizeTags(tags: string[] | undefined) {
const seen = new Set<string>();
const next: string[] = [];
for (const tag of tags ?? []) {
const trimmed = tag.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
next.push(trimmed);
}
return next;
}
}
+70
View File
@@ -0,0 +1,70 @@
export type SpecialCarousel = {
id: string;
title: string;
/** Short supporting line under the carousel title */
subtext: string;
/** Website section anchor, e.g. producers */
slug: string;
/** Marketplace listing ids */
businessIds: string[];
};
export type SpecialBusinessesConfig = {
carousels: SpecialCarousel[];
};
/** Public website card resolved from marketplace + Meshkee. */
export type WebsiteCarouselBusiness = {
id: string;
slug: string;
name: string;
tagline: string;
about: string;
logoUrl: string | null;
bannerUrl: string | null;
categoryName: string | null;
productCount: number;
publishedAt: string | null;
};
export type WebsiteCarousel = {
id: string;
title: string;
subtext: string;
slug: string;
businesses: WebsiteCarouselBusiness[];
};
export type WebsiteCarouselsResponse = {
carousels: WebsiteCarousel[];
};
export const SPECIAL_BUSINESSES_KEY = 'special_businesses';
export function defaultSpecialBusinesses(): SpecialBusinessesConfig {
return {
carousels: [
{
id: 'carousel-producers',
title: 'Featured producers',
subtext: '',
slug: 'producers',
businessIds: [],
},
{
id: 'carousel-stores',
title: 'Featured Stores',
subtext: '',
slug: 'vision',
businessIds: [],
},
{
id: 'carousel-services',
title: 'Featured Service providers',
subtext: '',
slug: 'services',
businessIds: [],
},
],
};
}
+76
View File
@@ -0,0 +1,76 @@
import {
BadRequestException,
Body,
Controller,
Get,
NotFoundException,
Param,
Put,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { existsSync } from 'fs';
import { basename, join } from 'path';
import { UpdateSpecialBusinessesDto } from './dto/special-businesses.dto';
import { UpdateWebsiteHomeMediaDto } from './dto/website-home-media.dto';
import { WebsiteService } from './website.service';
@Controller('website')
export class WebsiteController {
constructor(private readonly website: WebsiteService) {}
@Get('home-media')
getHomeMedia() {
return this.website.getHomeMedia();
}
@Put('home-media')
saveHomeMedia(@Body() body: UpdateWebsiteHomeMediaDto) {
return this.website.saveHomeMedia(body);
}
@Get('special-businesses')
getSpecialBusinesses() {
return this.website.getSpecialBusinesses();
}
/** Public homepage carousels with resolved business cards. */
@Get('carousels')
getCarousels() {
return this.website.getPublicCarousels();
}
@Put('special-businesses')
saveSpecialBusinesses(@Body() body: UpdateSpecialBusinessesDto) {
return this.website.saveSpecialBusinesses(body);
}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
upload(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.website.saveUpload(file);
}
@Get('files/:filename')
serveFile(@Param('filename') filename: string, @Res() res: Response) {
const safe = basename(filename);
const path = join(process.cwd(), 'uploads', 'website', safe);
if (!existsSync(path)) {
throw new NotFoundException('File not found');
}
// Bypass Nest interceptors — streaming a ReadStream through JSON
// serializers overflows the call stack.
res.sendFile(path);
}
}
+122
View File
@@ -0,0 +1,122 @@
export type WebsiteMediaItem = {
id: string;
imageUrl: string;
linkUrl: string;
title?: string;
subtitle?: string;
cta?: string;
};
export type WebsiteHomeMedia = {
slider: WebsiteMediaItem[];
standingBanners: WebsiteMediaItem[];
categories: WebsiteMediaItem[];
/** Featured photos for the categories mega menu (one per root, plus a spare). */
menuImages: WebsiteMediaItem[];
aboutUs: WebsiteMediaItem;
banners: WebsiteMediaItem[];
};
export const HOME_MEDIA_KEY = 'home_media';
function item(
id: string,
imageUrl: string,
linkUrl: string,
extra?: Partial<WebsiteMediaItem>,
): WebsiteMediaItem {
return { id, imageUrl, linkUrl, ...extra };
}
/** Defaults match current static website assets. */
export function defaultHomeMedia(): WebsiteHomeMedia {
return {
slider: [
item('slider-1', '/images/hero/furniture.png', '/businesses', {
title: 'Iranian modern art',
cta: 'browse furniture producers',
}),
],
standingBanners: [
item('standing-1', '/images/hero/ice-cream.png', '/businesses', {
title: 'food industry',
cta: 'browse producers',
}),
item('standing-2', '/images/hero/rug.png', '/businesses', {
title: 'original & pure art',
cta: 'browse producers',
}),
],
categories: [
item('cat-1', '/images/categories/industry.png', '/businesses', {
title: 'industry',
subtitle: 'Some description about the title',
}),
item('cat-2', '/images/categories/food.png', '/businesses', {
title: 'Food',
subtitle: 'Some description about the title',
}),
item('cat-3', '/images/categories/fruits.png', '/businesses', {
title: 'Fruits',
subtitle: 'Some description about the title',
}),
item('cat-4', '/images/categories/clothes.png', '/businesses', {
title: 'Clothes',
subtitle: 'Some description about the title',
}),
item('cat-5', '/images/categories/chair.png', '/businesses', {
title: 'Home Furniture',
subtitle: 'Some description about the title',
}),
item('cat-6', '/images/categories/shoes.png', '/businesses', {
title: 'Shoes',
subtitle: 'Some description about the title',
}),
item('cat-7', '/images/categories/washer.png', '/businesses', {
title: 'Home appliances',
subtitle: 'Some description about the title',
}),
item('cat-8', '/images/categories/services.png', '/businesses', {
title: 'Services',
subtitle: 'Some description about the title',
}),
],
menuImages: [
item('menu-1', '/images/categories/clothes.png', '/businesses?category=stores', {
title: 'Stores',
}),
item('menu-2', '/images/categories/industry.png', '/businesses?category=industries', {
title: 'Industries',
}),
item('menu-3', '/images/categories/services.png', '/businesses?category=services', {
title: 'Services',
}),
item('menu-4', '/images/categories/shoes.png', '/businesses', {
title: 'Menu',
}),
],
aboutUs: item('about-1', '/images/banners/ship.png', '/about'),
banners: [
item('banner-1', '/images/banners/tv.png', '/businesses', {
title: 'Iranian modern art',
cta: 'browse furniture producers',
}),
item('banner-2', '/images/banners/pistachio.png', '/businesses', {
title: 'food industry',
cta: 'browse producers',
}),
item('banner-3', '/images/banners/shampoo.png', '/businesses', {
title: 'food industry',
cta: 'browse producers',
}),
item('banner-4', '/images/banners/saffron.png', '/businesses', {
title: 'original & pure art',
cta: 'browse producers',
}),
item('banner-5', '/images/banners/plane.png', '/register', {
title: "let's export",
cta: 'begin',
}),
],
};
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MeshkeeModule } from '../meshkee/meshkee.module';
import { WebsiteBlogController } from './website-blog.controller';
import { WebsiteBlogService } from './website-blog.service';
import { WebsiteController } from './website.controller';
import { WebsiteService } from './website.service';
@Module({
imports: [MeshkeeModule],
controllers: [WebsiteController, WebsiteBlogController],
providers: [WebsiteService, WebsiteBlogService],
})
export class WebsiteModule {}
+350
View File
@@ -0,0 +1,350 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { extname, join } from 'path';
import { MeshkeeClient } from '../meshkee/meshkee.client';
import { PrismaService } from '../prisma/prisma.service';
import {
HOME_MEDIA_KEY,
defaultHomeMedia,
type WebsiteHomeMedia,
type WebsiteMediaItem,
} from './website.defaults';
import type { UpdateWebsiteHomeMediaDto } from './dto/website-home-media.dto';
import {
SPECIAL_BUSINESSES_KEY,
defaultSpecialBusinesses,
type SpecialBusinessesConfig,
type SpecialCarousel,
type WebsiteCarouselBusiness,
type WebsiteCarouselsResponse,
} from './website-special.defaults';
import type { UpdateSpecialBusinessesDto } from './dto/special-businesses.dto';
function isMostlyPersian(text: string) {
const persian = (text.match(/[\u0600-\u06FF]/g) || []).length;
const latin = (text.match(/[A-Za-z0-9]/g) || []).length;
return persian > 0 && persian >= latin;
}
/** Prefer English / Latin copy for the public website. */
function englishText(...candidates: Array<string | null | undefined>) {
for (const candidate of candidates) {
const value = String(candidate ?? '').trim();
if (value && !isMostlyPersian(value)) return value;
}
return '';
}
function categoryAncestors(
categoryId: string | undefined,
byId: Map<
string,
{ id: string; parentId: string | null; name: string; nameFa: string | null }
>,
): string[] {
if (!categoryId) return [];
const names: string[] = [];
const seen = new Set<string>();
let current = byId.get(categoryId);
while (current && !seen.has(current.id)) {
seen.add(current.id);
const label = englishText(current.name, current.nameFa);
if (label) names.unshift(label);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return names;
}
function lastTwoCategoryLevels(trail: string[]) {
if (trail.length === 0) return null;
return trail.slice(-2).join(' / ');
}
@Injectable()
export class WebsiteService {
private readonly logger = new Logger(WebsiteService.name);
constructor(
private readonly prisma: PrismaService,
private readonly meshkee: MeshkeeClient,
) {}
async getHomeMedia(): Promise<WebsiteHomeMedia> {
const row = await this.prisma.websiteSetting.findUnique({
where: { key: HOME_MEDIA_KEY },
});
if (!row?.value) return defaultHomeMedia();
return this.normalizeHomeMedia(row.value);
}
async saveHomeMedia(
dto: UpdateWebsiteHomeMediaDto,
): Promise<WebsiteHomeMedia> {
const value = this.normalizeHomeMedia(dto);
await this.prisma.websiteSetting.upsert({
where: { key: HOME_MEDIA_KEY },
create: {
key: HOME_MEDIA_KEY,
value: value as unknown as Prisma.InputJsonValue,
},
update: {
value: value as unknown as Prisma.InputJsonValue,
},
});
return value;
}
async getSpecialBusinesses(): Promise<SpecialBusinessesConfig> {
const row = await this.prisma.websiteSetting.findUnique({
where: { key: SPECIAL_BUSINESSES_KEY },
});
if (!row?.value) return defaultSpecialBusinesses();
return this.normalizeSpecialBusinesses(row.value);
}
async saveSpecialBusinesses(
dto: UpdateSpecialBusinessesDto,
): Promise<SpecialBusinessesConfig> {
const value = this.normalizeSpecialBusinesses(dto);
await this.prisma.websiteSetting.upsert({
where: { key: SPECIAL_BUSINESSES_KEY },
create: {
key: SPECIAL_BUSINESSES_KEY,
value: value as unknown as Prisma.InputJsonValue,
},
update: {
value: value as unknown as Prisma.InputJsonValue,
},
});
return value;
}
async getPublicCarousels(): Promise<WebsiteCarouselsResponse> {
const config = await this.getSpecialBusinesses();
const orderedIds = [
...new Set(config.carousels.flatMap((carousel) => carousel.businessIds)),
];
if (!orderedIds.length) {
return {
carousels: config.carousels.map((carousel) => ({
id: carousel.id,
title: carousel.title,
subtext: carousel.subtext,
slug: carousel.slug,
businesses: [],
})),
};
}
const listings = await this.prisma.business.findMany({
where: {
id: { in: orderedIds.map((id) => BigInt(id)) },
status: 'published',
},
});
const listingById = new Map(
listings.map((item) => [item.id.toString(), item]),
);
let categoryById = new Map<
string,
{ id: string; parentId: string | null; name: string; nameFa: string | null }
>();
try {
const allCategories = await this.meshkee.listBusinessCategories();
categoryById = new Map(
allCategories.map((category) => [category.id, category]),
);
} catch (error) {
this.logger.warn(
`Failed to load Meshkee categories for carousels: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const cards = new Map<string, WebsiteCarouselBusiness>();
await Promise.all(
orderedIds.map(async (id) => {
const listing = listingById.get(id);
if (!listing) return;
const meshkeeId = listing.meshkeeBusinessId.toString();
let name = listing.slug;
let tagline = '';
let about = '';
let logoUrl: string | null = null;
let bannerUrl: string | null = null;
let categoryName: string | null = null;
let productCount = 0;
try {
const [meshkee, profile, products] = await Promise.all([
this.meshkee.getBusiness(meshkeeId),
this.meshkee.getBusinessProfile(meshkeeId),
this.meshkee.getProductCount(meshkeeId),
]);
name =
englishText(profile.nameEn, meshkee.name, listing.slug) ||
listing.slug;
tagline = englishText(profile.title);
// Keep English about only — Farsi is dropped, empty stays empty
// (website falls back to English placeholder copy).
about = englishText(profile.about);
logoUrl = profile.logoUrl ?? meshkee.logoUrl ?? null;
bannerUrl = profile.bannerUrl ?? meshkee.bannerUrl ?? null;
productCount = products;
const primaryCategoryId =
profile.categoryIds[0] ?? meshkee.categories[0]?.id;
const trail = categoryAncestors(primaryCategoryId, categoryById);
categoryName =
lastTwoCategoryLevels(trail) ||
englishText(meshkee.categories[0]?.name) ||
null;
} catch (error) {
this.logger.warn(
`Failed to enrich carousel business ${id}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
cards.set(id, {
id,
slug: listing.slug,
name,
tagline,
about,
logoUrl,
bannerUrl,
categoryName,
productCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
});
}),
);
return {
carousels: config.carousels.map((carousel) => ({
id: carousel.id,
title: carousel.title,
subtext: carousel.subtext,
slug: carousel.slug,
businesses: carousel.businessIds
.map((businessId) => cards.get(businessId))
.filter((item): item is WebsiteCarouselBusiness => Boolean(item)),
})),
};
}
async saveUpload(file: Express.Multer.File): Promise<{ url: string }> {
const uploadsDir = join(process.cwd(), 'uploads', 'website');
await mkdir(uploadsDir, { recursive: true });
const safeExt = (extname(file.originalname) || '.jpg')
.toLowerCase()
.replace(/[^\w.]/g, '');
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${safeExt}`;
await writeFile(join(uploadsDir, filename), file.buffer);
return { url: `/api/v1/website/files/${filename}` };
}
private normalizeHomeMedia(raw: unknown): WebsiteHomeMedia {
const defaults = defaultHomeMedia();
const data = (raw ?? {}) as Partial<WebsiteHomeMedia>;
return {
slider: this.normalizeList(data.slider, defaults.slider, 1),
standingBanners: this.normalizeFixed(
data.standingBanners,
defaults.standingBanners,
2,
),
categories: this.normalizeList(data.categories, defaults.categories, 0),
menuImages: this.normalizeFixed(data.menuImages, defaults.menuImages, 4),
aboutUs: this.normalizeItem(data.aboutUs, defaults.aboutUs),
banners: this.normalizeFixed(data.banners, defaults.banners, 5),
};
}
private normalizeList(
items: WebsiteMediaItem[] | undefined,
fallback: WebsiteMediaItem[],
min: number,
): WebsiteMediaItem[] {
const list = (Array.isArray(items) ? items : [])
.map((item, index) =>
this.normalizeItem(item, fallback[index] ?? fallback[0]),
)
.filter((item) => item.imageUrl.trim());
if (list.length >= min) return list;
return fallback;
}
private normalizeFixed(
items: WebsiteMediaItem[] | undefined,
fallback: WebsiteMediaItem[],
count: number,
): WebsiteMediaItem[] {
const list = Array.isArray(items) ? items : [];
return Array.from({ length: count }, (_, index) =>
this.normalizeItem(list[index], fallback[index] ?? fallback[0]),
);
}
private normalizeItem(
item: Partial<WebsiteMediaItem> | undefined,
fallback: WebsiteMediaItem,
): WebsiteMediaItem {
return {
id: String(item?.id || fallback.id || randomUUID()),
imageUrl: String(item?.imageUrl ?? fallback.imageUrl ?? '').trim(),
linkUrl: String(item?.linkUrl ?? fallback.linkUrl ?? '').trim() || '/',
title: (item?.title ?? fallback.title)?.trim() || undefined,
subtitle: (item?.subtitle ?? fallback.subtitle)?.trim() || undefined,
cta: (item?.cta ?? fallback.cta)?.trim() || undefined,
};
}
private normalizeSpecialBusinesses(raw: unknown): SpecialBusinessesConfig {
const defaults = defaultSpecialBusinesses();
const data = (raw ?? {}) as Partial<SpecialBusinessesConfig>;
const list = Array.isArray(data.carousels) ? data.carousels : [];
const carousels = list
.map((item, index) =>
this.normalizeCarousel(item, defaults.carousels[index]),
)
.filter((item) => item.title.trim());
return {
carousels: carousels.length ? carousels : defaults.carousels,
};
}
private normalizeCarousel(
item: Partial<SpecialCarousel> | undefined,
fallback?: SpecialCarousel,
): SpecialCarousel {
const title = String(item?.title ?? fallback?.title ?? '').trim();
const slugBase =
String(item?.slug ?? fallback?.slug ?? title)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'carousel';
const ids = Array.isArray(item?.businessIds)
? item.businessIds.map(String).filter(Boolean)
: (fallback?.businessIds ?? []);
return {
id: String(item?.id || fallback?.id || randomUUID()),
title: title || 'Untitled carousel',
subtext: String(item?.subtext ?? fallback?.subtext ?? '').trim(),
slug: slugBase,
businessIds: [...new Set(ids)],
};
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}