diff --git a/angular.json b/angular.json
index 97e9fdf..34faa9e 100644
--- a/angular.json
+++ b/angular.json
@@ -28,6 +28,11 @@
{
"glob": "**/*",
"input": "public"
+ },
+ {
+ "glob": "**/*",
+ "input": "src/assets",
+ "output": "assets"
}
],
"styles": [
@@ -104,6 +109,7 @@
"buildTarget": "Dexarmarket:build:production"
},
"development": {
+ "proxyConfig": "proxy.conf.json",
"buildTarget": "Dexarmarket:build:development"
}
},
diff --git a/proxy.conf.json b/proxy.conf.json
new file mode 100644
index 0000000..bc8db23
--- /dev/null
+++ b/proxy.conf.json
@@ -0,0 +1,11 @@
+{
+ "/api": {
+ "target": "https://api.dexarmarket.ru:445",
+ "secure": false,
+ "changeOrigin": true,
+ "logLevel": "debug",
+ "pathRewrite": {
+ "^/api": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/assets/mock/bootstrap/bootstrap.json b/public/assets/mock/bootstrap/bootstrap.json
new file mode 100644
index 0000000..db0758d
--- /dev/null
+++ b/public/assets/mock/bootstrap/bootstrap.json
@@ -0,0 +1,325 @@
+{
+ "schemaVersion": "1.0.0",
+ "generatedAt": "2026-07-03T00:00:00Z",
+ "tenant": {
+ "id": "tenant-default-001",
+ "slug": "default",
+ "code": "DEFAULT",
+ "host": "default.local",
+ "name": "Marketplace",
+ "websiteBaseUrl": "https://marketplace.local",
+ "builderBaseUrl": "https://builder.marketplace.local",
+ "backofficeBaseUrl": "https://backoffice.marketplace.local",
+ "defaultLocale": "ru",
+ "supportedLocales": ["ru", "en", "hy"],
+ "defaultCurrency": "RUB",
+ "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
+ "timezone": "Europe/Moscow"
+ },
+ "branding": {
+ "brandName": "Marketplace",
+ "legalName": "Marketplace LLC",
+ "slogan": "Digital commerce marketplace",
+ "logoUrl": "/icons/icon-192x192.png",
+ "logoCompactUrl": "/icons/icon-192x192.png",
+ "faviconUrl": "/favicon.ico",
+ "appIconUrl": "/icons/icon-192x192.png",
+ "supportEmail": "support@marketplace.local",
+ "supportPhone": "+7-900-000-00-00"
+ },
+ "theme": {
+ "themeId": "default-light",
+ "mode": "light",
+ "palette": {
+ "primary": "#497671",
+ "secondary": "#a1b4b5",
+ "accent": "#a7ceca",
+ "success": "#10b981",
+ "warning": "#f59e0b",
+ "danger": "#ef4444",
+ "info": "#3b82f6",
+ "textPrimary": "#1e3c38",
+ "textSecondary": "#667a77",
+ "backgroundPrimary": "#ffffff",
+ "backgroundSecondary": "#f5f5f5",
+ "border": "#d3dad9"
+ },
+ "typography": {
+ "primaryFontFamily": "DM Sans, sans-serif",
+ "headingFontFamily": "DM Sans, sans-serif",
+ "baseFontSize": 16
+ },
+ "spacing": {
+ "unit": 4,
+ "scale": [0, 4, 8, 12, 16, 24, 32, 48]
+ },
+ "borderRadiusScale": {
+ "sm": "8px",
+ "md": "12px",
+ "lg": "16px",
+ "xl": "22px"
+ },
+ "shadows": {
+ "sm": "0 2px 8px rgba(0,0,0,0.1)",
+ "md": "0 4px 12px rgba(0,0,0,0.15)",
+ "lg": "0 12px 32px rgba(73,118,113,0.2)"
+ },
+ "iconSet": "default"
+ },
+ "company": {
+ "companyName": "Marketplace LLC",
+ "registrationNumber": "1027700000000",
+ "taxId": "7700000000",
+ "address": {
+ "country": "Russia",
+ "region": "Moscow",
+ "city": "Moscow",
+ "street": "Tverskaya 1",
+ "postalCode": "125009"
+ },
+ "contacts": {
+ "email": "support@marketplace.local",
+ "phone": "+7-900-000-00-00",
+ "telegram": "@marketplace_support",
+ "website": "https://marketplace.local"
+ }
+ },
+ "featureFlags": {
+ "wishlist": true,
+ "compare": true,
+ "reviews": true,
+ "blog": false,
+ "chat": false,
+ "analytics": true,
+ "notifications": true,
+ "coupons": true,
+ "loyalty": false,
+ "giftCards": false,
+ "invoices": true
+ },
+ "apiEndpoints": {
+ "bootstrap": {
+ "path": "/bootstrap",
+ "method": "GET",
+ "timeoutMs": 10000
+ },
+ "website": {},
+ "builder": {},
+ "backoffice": {}
+ },
+ "localization": {
+ "defaultLocale": "ru",
+ "supportedLocales": ["ru", "en", "hy"],
+ "currencyByLocale": {
+ "ru": "RUB",
+ "en": "USD",
+ "hy": "AMD"
+ },
+ "dictionaries": [
+ {
+ "locale": "ru",
+ "dictionaryUrl": "/assets/i18n/ru.json",
+ "version": "1.0.0"
+ },
+ {
+ "locale": "en",
+ "dictionaryUrl": "/assets/i18n/en.json",
+ "version": "1.0.0"
+ },
+ {
+ "locale": "hy",
+ "dictionaryUrl": "/assets/i18n/hy.json",
+ "version": "1.0.0"
+ }
+ ]
+ },
+ "seo": {
+ "default": {
+ "title": "Marketplace",
+ "description": "Digital commerce marketplace",
+ "robots": "index,follow"
+ },
+ "byPageKey": {
+ "home": {
+ "title": "Marketplace - Home",
+ "description": "Digital commerce marketplace",
+ "canonicalUrl": "https://marketplace.local/",
+ "robots": "index,follow"
+ }
+ }
+ },
+ "permissions": {
+ "definitions": [
+ {
+ "key": "builder.pages.edit",
+ "description": "Edit pages in builder"
+ },
+ {
+ "key": "backoffice.products.read",
+ "description": "Read products in backoffice"
+ }
+ ],
+ "roles": [
+ {
+ "role": "builder_admin",
+ "permissions": ["builder.pages.edit"]
+ },
+ {
+ "role": "backoffice_manager",
+ "permissions": ["backoffice.products.read"]
+ }
+ ]
+ },
+ "navigation": {
+ "header": [
+ {
+ "id": "nav-home",
+ "labelKey": "nav.home",
+ "route": "/",
+ "icon": "home",
+ "order": 1
+ },
+ {
+ "id": "nav-search",
+ "labelKey": "nav.search",
+ "route": "/search",
+ "icon": "search",
+ "order": 2
+ },
+ {
+ "id": "nav-cart",
+ "labelKey": "nav.cart",
+ "route": "/cart",
+ "icon": "cart",
+ "order": 3
+ }
+ ],
+ "footer": [
+ {
+ "id": "footer-about",
+ "labelKey": "nav.about",
+ "route": "/about",
+ "order": 1
+ },
+ {
+ "id": "footer-contacts",
+ "labelKey": "nav.contacts",
+ "route": "/contacts",
+ "order": 2
+ },
+ {
+ "id": "footer-privacy",
+ "labelKey": "nav.privacy",
+ "route": "/privacy-policy",
+ "order": 3
+ }
+ ]
+ },
+ "pages": [
+ {
+ "id": "page-home",
+ "key": "home",
+ "title": "Home",
+ "route": {
+ "path": "/",
+ "exact": true
+ },
+ "layout": "default-public",
+ "seoKey": "home",
+ "visible": true,
+ "sections": [
+ {
+ "id": "section-hero",
+ "type": "hero",
+ "order": 1,
+ "layout": {
+ "strategy": "hero",
+ "columns": 1,
+ "gap": "1.5rem",
+ "align": "stretch"
+ },
+ "visibility": {
+ "desktop": true,
+ "tablet": true,
+ "mobile": true
+ },
+ "visible": true,
+ "widgets": [
+ {
+ "id": "widget-hero-main",
+ "type": "hero",
+ "version": "1.0.0",
+ "visible": true,
+ "props": {
+ "title": "Welcome to Marketplace Platform",
+ "subtitle": "Configuration-driven multi-tenant commerce",
+ "ctaLabel": "Start Shopping"
+ }
+ }
+ ]
+ },
+ {
+ "id": "section-categories",
+ "type": "categories",
+ "order": 2,
+ "layout": {
+ "strategy": "grid",
+ "columns": 1,
+ "gap": "1.5rem",
+ "align": "stretch"
+ },
+ "visibility": {
+ "desktop": true,
+ "tablet": true,
+ "mobile": true
+ },
+ "visible": true,
+ "widgets": [
+ {
+ "id": "widget-categories-root",
+ "type": "categories",
+ "version": "1.0.0",
+ "visible": true,
+ "props": {
+ "title": "Categories",
+ "source": "root",
+ "emptyMessage": "No categories available"
+ }
+ }
+ ]
+ },
+ {
+ "id": "section-featured-products",
+ "type": "product-collection",
+ "order": 3,
+ "layout": {
+ "strategy": "carousel",
+ "columns": 1,
+ "gap": "1rem",
+ "align": "stretch"
+ },
+ "visibility": {
+ "desktop": true,
+ "tablet": true,
+ "mobile": true
+ },
+ "visible": true,
+ "widgets": [
+ {
+ "id": "widget-featured-products",
+ "type": "product-collection",
+ "version": "1.0.0",
+ "visible": true,
+ "props": {
+ "title": "Featured Products",
+ "source": "featured",
+ "count": 8,
+ "actionLabel": "Select"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/app/components/product-card/product-card.component.scss b/src/app/components/product-card/product-card.component.scss
index 8e0c6b4..7b95542 100644
--- a/src/app/components/product-card/product-card.component.scss
+++ b/src/app/components/product-card/product-card.component.scss
@@ -1,5 +1,6 @@
.product-card {
width: 100%;
+ height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
diff --git a/src/app/core/config/api-config.service.ts b/src/app/core/config/api-config.service.ts
index 40bdc6a..1bc3646 100644
--- a/src/app/core/config/api-config.service.ts
+++ b/src/app/core/config/api-config.service.ts
@@ -19,12 +19,13 @@ export class ApiConfigService {
if (this.tenantResolver.isLocalhost() && localhostUrl) {
url = localhostUrl;
- } else if (bootstrapUrl) {
- url = bootstrapUrl;
} else if (tenantMap?.[tenantKey]) {
url = tenantMap[tenantKey];
} else if (apiTemplate) {
url = apiTemplate.replace('{tenant}', tenantKey);
+ } else if (bootstrapUrl) {
+ // Bootstrap API override is opt-in and only for absolute URLs.
+ url = bootstrapUrl;
}
return this.normalizeBaseUrl(url);
@@ -58,24 +59,33 @@ export class ApiConfigService {
}
private resolveBootstrapApiBaseUrl(): string | null {
+ const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
+ if (!allowBootstrapApiOverride) {
+ return null;
+ }
+
const bootstrap = this.configService.getBootstrapSnapshot() as any;
if (!bootstrap) {
return null;
}
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl;
- if (typeof endpointBase === 'string' && endpointBase.trim().length > 0) {
+ if (typeof endpointBase === 'string' && this.isAbsoluteHttpUrl(endpointBase)) {
return endpointBase;
}
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
- if (typeof tenantBase === 'string' && tenantBase.trim().length > 0) {
+ if (typeof tenantBase === 'string' && this.isAbsoluteHttpUrl(tenantBase)) {
return tenantBase;
}
return null;
}
+ private isAbsoluteHttpUrl(url: string): boolean {
+ return /^https?:\/\//i.test(url.trim());
+ }
+
private normalizeBaseUrl(url: string): string {
if (!url || url === '/') {
return '/';
diff --git a/src/app/core/providers/runtime-provider-strategy.service.ts b/src/app/core/providers/runtime-provider-strategy.service.ts
index 1e4281d..4660f5e 100644
--- a/src/app/core/providers/runtime-provider-strategy.service.ts
+++ b/src/app/core/providers/runtime-provider-strategy.service.ts
@@ -5,11 +5,25 @@ export type RuntimeProviderMode = 'mock' | 'api' | 'remote-config';
@Injectable({ providedIn: 'root' })
export class RuntimeProviderStrategyService {
+ private isLocalhost(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ const host = window.location.hostname.toLowerCase();
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
+ }
+
getBootstrapProviderMode(): RuntimeProviderMode {
if (environment.useMockData) {
return 'mock';
}
+ // Local dev must use mocked bootstrap while API data can stay production-based.
+ if ((environment as any).useMockBootstrapOnLocal === true && this.isLocalhost()) {
+ return 'mock';
+ }
+
return 'api';
}
diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.scss b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss
index 47361da..6a7e59a 100644
--- a/src/app/features/website/catalog/components/product-grid/product-grid.component.scss
+++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.scss
@@ -7,6 +7,14 @@
.catalog-product-shell {
min-width: 0;
+ display: flex;
+ height: 100%;
+}
+
+.catalog-product-shell app-product-card {
+ display: block;
+ width: 100%;
+ height: 100%;
}
@media (max-width: 640px) {
diff --git a/src/app/widgets/ui/categories-widget.component.ts b/src/app/widgets/ui/categories-widget.component.ts
index 63c103e..ecf5bd2 100644
--- a/src/app/widgets/ui/categories-widget.component.ts
+++ b/src/app/widgets/ui/categories-widget.component.ts
@@ -1,8 +1,11 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core';
+import { Router } from '@angular/router';
import { SectionConfig } from '../../shared/models/config';
import { CatalogCategoryGridComponent } from '../../features/website/catalog/components/category-grid/category-grid.component';
import { CategoriesWidgetData } from '../contracts/widget-data.contract';
+import { Category } from '../../core/categories/models/category-domain.model';
+import { LanguageService } from '../../services/language.service';
@Component({
selector: 'app-categories-widget',
@@ -16,7 +19,10 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
}
@if (widgetData.categories.length) {
-