commit aacdd422b30bbf2d2fd55f5a58a6cd19a6977344 Author: Andrey Date: Wed Jun 17 18:14:16 2026 +0300 Initialize Edevs Hub repository diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3db21ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.env +.git +.venv +__pycache__ +*.pyc +node_modules +dist +coverage +playwright-report +test-results +design +docs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2927fb2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 + +[*.ps1] +end_of_line = crlf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a105fd2 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +COMPOSE_PROJECT_NAME=edevs_hub + +HUB_ENV=local +HUB_DEBUG=true +HUB_SECRET_KEY=change-me-only-for-local-development +HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost,pay.localhost,chat.localhost +HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5174,http://localhost:5175 + +POSTGRES_DB=edevs_hub +POSTGRES_USER=edevs_hub +POSTGRES_PASSWORD=edevs_hub +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 + +REDIS_URL=redis://redis:6379/0 + +INTERNAL_UI_PORT=5173 +CHECKOUT_PORT=5174 +WEB_CHAT_PORT=5175 +BACKEND_PORT=8010 + +VITE_API_BASE_URL=http://localhost:8010/api/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c30797 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +node_modules/ +dist/ +coverage/ +playwright-report/ +test-results/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..63cfddd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef2f643 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Edevs Hub + +Canonical implementation workspace for `hub.edevs.tech`. + +## Local start + +```powershell +Copy-Item .env.example .env +.\scripts\start.ps1 +``` + +The local compose stack contains: + +- Django ASGI backend and background worker; +- PostgreSQL; +- Redis; +- Internal Hub UI; +- Checkout UI; +- Web Chat UI; +- local Nginx reverse proxy. + +No production secrets are stored in the repository. + +Default local URLs: + +- Internal Hub UI: `http://localhost:5173` +- Checkout: `http://localhost:5174` +- Web Chat: `http://localhost:5175` +- Backend API: `http://localhost:8010/api/v1` diff --git a/apps/backend/.gitkeep b/apps/backend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 0000000..153894a --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt + +COPY apps/backend /app/apps/backend + +WORKDIR /app/apps/backend + +EXPOSE 8000 diff --git a/apps/backend/hub_backend/__init__.py b/apps/backend/hub_backend/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_backend/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_backend/asgi.py b/apps/backend/hub_backend/asgi.py new file mode 100644 index 0000000..48ee236 --- /dev/null +++ b/apps/backend/hub_backend/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") + +application = get_asgi_application() diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/hub_backend/settings.py new file mode 100644 index 0000000..eb049a4 --- /dev/null +++ b/apps/backend/hub_backend/settings.py @@ -0,0 +1,134 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def env_list(name: str, default: list[str]) -> list[str]: + value = os.environ.get(name) + if value is None: + return default + return [item.strip() for item in value.split(",") if item.strip()] + + +SECRET_KEY = os.environ.get("HUB_SECRET_KEY", "local-development-only") +DEBUG = env_bool("HUB_DEBUG") +ALLOWED_HOSTS = env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) +CSRF_TRUSTED_ORIGINS = env_list("HUB_CSRF_TRUSTED_ORIGINS", []) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "hub_platform.health", + "hub_platform.events", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "hub_platform.http.middleware.LocalCorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "hub_platform.events.middleware.CorrelationIdMiddleware", +] + +ROOT_URLCONF = "hub_backend.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "hub_backend.wsgi.application" +ASGI_APPLICATION = "hub_backend.asgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"), + "USER": os.environ.get("POSTGRES_USER", "edevs_hub"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "edevs_hub"), + "HOST": os.environ.get("POSTGRES_HOST", "postgres"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + "CONN_MAX_AGE": 60, + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.environ.get("REDIS_URL", "redis://redis:6379/0"), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "ru-ru" +TIME_ZONE = "Europe/Moscow" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], + "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], +} + +CORS_ALLOWED_ORIGINS = { + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "structured": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s correlation_id=%(correlation_id)s" + } + }, + "filters": { + "correlation_id": {"()": "hub_platform.events.logging.CorrelationIdLogFilter"} + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "structured", + "filters": ["correlation_id"], + } + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} diff --git a/apps/backend/hub_backend/urls.py b/apps/backend/hub_backend/urls.py new file mode 100644 index 0000000..983c55d --- /dev/null +++ b/apps/backend/hub_backend/urls.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from django.urls import include, path +from rest_framework.schemas import get_schema_view + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/v1/schema/", get_schema_view(title="Edevs Hub API", version="0.1.0"), name="openapi-schema"), + path("api/v1/health/", include("hub_platform.health.urls")), +] diff --git a/apps/backend/hub_backend/wsgi.py b/apps/backend/hub_backend/wsgi.py new file mode 100644 index 0000000..4000b8b --- /dev/null +++ b/apps/backend/hub_backend/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") + +application = get_wsgi_application() diff --git a/apps/backend/hub_platform/__init__.py b/apps/backend/hub_platform/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/events/__init__.py b/apps/backend/hub_platform/events/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/events/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/events/apps.py b/apps/backend/hub_platform/events/apps.py new file mode 100644 index 0000000..46835f3 --- /dev/null +++ b/apps/backend/hub_platform/events/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hub_platform.events" + verbose_name = "Domain events" diff --git a/apps/backend/hub_platform/events/context.py b/apps/backend/hub_platform/events/context.py new file mode 100644 index 0000000..7aa5565 --- /dev/null +++ b/apps/backend/hub_platform/events/context.py @@ -0,0 +1,14 @@ +from contextvars import ContextVar +from uuid import uuid4 + +correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="") + + +def ensure_correlation_id(value: str | None = None) -> str: + correlation_id = value or str(uuid4()) + correlation_id_var.set(correlation_id) + return correlation_id + + +def get_correlation_id() -> str: + return correlation_id_var.get() or ensure_correlation_id() diff --git a/apps/backend/hub_platform/events/logging.py b/apps/backend/hub_platform/events/logging.py new file mode 100644 index 0000000..5b33549 --- /dev/null +++ b/apps/backend/hub_platform/events/logging.py @@ -0,0 +1,9 @@ +import logging + +from hub_platform.events.context import get_correlation_id + + +class CorrelationIdLogFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.correlation_id = get_correlation_id() + return True diff --git a/apps/backend/hub_platform/events/management/__init__.py b/apps/backend/hub_platform/events/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/events/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/events/management/commands/__init__.py b/apps/backend/hub_platform/events/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/events/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/events/management/commands/emit_test_event.py b/apps/backend/hub_platform/events/management/commands/emit_test_event.py new file mode 100644 index 0000000..549a972 --- /dev/null +++ b/apps/backend/hub_platform/events/management/commands/emit_test_event.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand + +from hub_platform.events.services import DomainEvent, enqueue_event + + +class Command(BaseCommand): + help = "Enqueues a deterministic local test event for E01 checks." + + def handle(self, *args: object, **options: object) -> None: + event = enqueue_event( + DomainEvent( + aggregate_type="local_check", + aggregate_id="e01", + event_type="local_check.requested", + payload={"source": "emit_test_event"}, + ) + ) + self.stdout.write(str(event.id)) diff --git a/apps/backend/hub_platform/events/management/commands/run_worker.py b/apps/backend/hub_platform/events/management/commands/run_worker.py new file mode 100644 index 0000000..42857ee --- /dev/null +++ b/apps/backend/hub_platform/events/management/commands/run_worker.py @@ -0,0 +1,31 @@ +import logging +import time + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from hub_platform.events.models import OutboxStatus +from hub_platform.events.services import claim_next_outbox_event, mark_retry + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Runs the local domain event worker." + + def handle(self, *args: object, **options: object) -> None: + self.stdout.write("Hub worker started") + while True: + event = claim_next_outbox_event() + if event is None: + time.sleep(1) + continue + + try: + logger.info("Processing outbox event %s", event.id) + event.status = OutboxStatus.PROCESSED + event.processed_at = timezone.now() + event.save(update_fields=["status", "processed_at"]) + except Exception as exc: # pragma: no cover + logger.exception("Outbox event failed: %s", event.id) + mark_retry(event, str(exc)) diff --git a/apps/backend/hub_platform/events/middleware.py b/apps/backend/hub_platform/events/middleware.py new file mode 100644 index 0000000..3873d78 --- /dev/null +++ b/apps/backend/hub_platform/events/middleware.py @@ -0,0 +1,19 @@ +from collections.abc import Callable + +from django.http import HttpRequest, HttpResponse + +from hub_platform.events.context import ensure_correlation_id + + +class CorrelationIdMiddleware: + header_name = "HTTP_X_CORRELATION_ID" + response_header = "X-Correlation-Id" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + correlation_id = ensure_correlation_id(request.META.get(self.header_name)) + response = self.get_response(request) + response[self.response_header] = correlation_id + return response diff --git a/apps/backend/hub_platform/events/migrations/0001_initial.py b/apps/backend/hub_platform/events/migrations/0001_initial.py new file mode 100644 index 0000000..733f128 --- /dev/null +++ b/apps/backend/hub_platform/events/migrations/0001_initial.py @@ -0,0 +1,71 @@ +import uuid + +from django.db import migrations, models +from django.utils import timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="InboxEvent", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("source", models.CharField(max_length=128)), + ("external_event_id", models.CharField(max_length=256)), + ("payload_hash", models.CharField(max_length=128)), + ("received_at", models.DateTimeField(auto_now_add=True)), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="OutboxEvent", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("aggregate_type", models.CharField(max_length=128)), + ("aggregate_id", models.CharField(max_length=128)), + ("event_type", models.CharField(max_length=128)), + ("payload", models.JSONField(default=dict)), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("PROCESSING", "Processing"), + ("PROCESSED", "Processed"), + ("FAILED", "Failed"), + ("DEAD_LETTER", "Dead letter"), + ], + db_index=True, + default="PENDING", + max_length=32, + ), + ), + ("attempts", models.PositiveIntegerField(default=0)), + ("next_attempt_at", models.DateTimeField(db_index=True, default=timezone.now)), + ("correlation_id", models.CharField(blank=True, max_length=128)), + ("last_error", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ], + options={"ordering": ["created_at"]}, + ), + migrations.AddConstraint( + model_name="inboxevent", + constraint=models.UniqueConstraint( + fields=("source", "external_event_id"), + name="uniq_inbox_source_external_event_id", + ), + ), + migrations.AddIndex( + model_name="outboxevent", + index=models.Index(fields=["status", "next_attempt_at"], name="events_outb_status_881ef5_idx"), + ), + migrations.AddIndex( + model_name="outboxevent", + index=models.Index(fields=["aggregate_type", "aggregate_id"], name="events_outb_aggrega_a2b510_idx"), + ), + ] diff --git a/apps/backend/hub_platform/events/migrations/__init__.py b/apps/backend/hub_platform/events/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/events/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/events/models.py b/apps/backend/hub_platform/events/models.py new file mode 100644 index 0000000..7c21180 --- /dev/null +++ b/apps/backend/hub_platform/events/models.py @@ -0,0 +1,62 @@ +import uuid + +from django.db import models +from django.utils import timezone + + +class OutboxStatus(models.TextChoices): + PENDING = "PENDING", "Pending" + PROCESSING = "PROCESSING", "Processing" + PROCESSED = "PROCESSED", "Processed" + FAILED = "FAILED", "Failed" + DEAD_LETTER = "DEAD_LETTER", "Dead letter" + + +class OutboxEvent(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + aggregate_type = models.CharField(max_length=128) + aggregate_id = models.CharField(max_length=128) + event_type = models.CharField(max_length=128) + payload = models.JSONField(default=dict) + status = models.CharField( + max_length=32, + choices=OutboxStatus.choices, + default=OutboxStatus.PENDING, + db_index=True, + ) + attempts = models.PositiveIntegerField(default=0) + next_attempt_at = models.DateTimeField(default=timezone.now, db_index=True) + correlation_id = models.CharField(max_length=128, blank=True) + last_error = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["created_at"] + indexes = [ + models.Index(fields=["status", "next_attempt_at"]), + models.Index(fields=["aggregate_type", "aggregate_id"]), + ] + + def __str__(self) -> str: + return f"{self.event_type}:{self.id}" + + +class InboxEvent(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + source = models.CharField(max_length=128) + external_event_id = models.CharField(max_length=256) + payload_hash = models.CharField(max_length=128) + received_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["source", "external_event_id"], + name="uniq_inbox_source_external_event_id", + ) + ] + + def __str__(self) -> str: + return f"{self.source}:{self.external_event_id}" diff --git a/apps/backend/hub_platform/events/services.py b/apps/backend/hub_platform/events/services.py new file mode 100644 index 0000000..a93673b --- /dev/null +++ b/apps/backend/hub_platform/events/services.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from datetime import timedelta +from typing import Any + +from django.db import transaction +from django.utils import timezone + +from hub_platform.events.context import get_correlation_id +from hub_platform.events.models import OutboxEvent, OutboxStatus + + +@dataclass(frozen=True) +class DomainEvent: + aggregate_type: str + aggregate_id: str + event_type: str + payload: dict[str, Any] + + +def enqueue_event(event: DomainEvent) -> OutboxEvent: + return OutboxEvent.objects.create( + aggregate_type=event.aggregate_type, + aggregate_id=event.aggregate_id, + event_type=event.event_type, + payload=event.payload, + correlation_id=get_correlation_id(), + ) + + +def mark_retry(event: OutboxEvent, error: str, max_attempts: int = 5) -> None: + event.attempts += 1 + event.last_error = error + event.status = OutboxStatus.DEAD_LETTER if event.attempts >= max_attempts else OutboxStatus.FAILED + event.next_attempt_at = timezone.now() + timedelta(seconds=min(300, 2**event.attempts)) + event.save(update_fields=["attempts", "last_error", "status", "next_attempt_at"]) + + +def claim_next_outbox_event() -> OutboxEvent | None: + with transaction.atomic(): + event = ( + OutboxEvent.objects.select_for_update(skip_locked=True) + .filter( + status__in=[OutboxStatus.PENDING, OutboxStatus.FAILED], + next_attempt_at__lte=timezone.now(), + ) + .order_by("next_attempt_at", "created_at") + .first() + ) + if event is None: + return None + event.status = OutboxStatus.PROCESSING + event.save(update_fields=["status"]) + return event diff --git a/apps/backend/hub_platform/health/__init__.py b/apps/backend/hub_platform/health/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/health/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/health/apps.py b/apps/backend/hub_platform/health/apps.py new file mode 100644 index 0000000..febd549 --- /dev/null +++ b/apps/backend/hub_platform/health/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class HealthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hub_platform.health" + verbose_name = "Health" diff --git a/apps/backend/hub_platform/health/tests.py b/apps/backend/hub_platform/health/tests.py new file mode 100644 index 0000000..77b548b --- /dev/null +++ b/apps/backend/hub_platform/health/tests.py @@ -0,0 +1,9 @@ +from django.test import Client, TestCase + + +class HealthTests(TestCase): + def test_live_endpoint(self) -> None: + response = Client().get("/api/v1/health/live/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") diff --git a/apps/backend/hub_platform/health/urls.py b/apps/backend/hub_platform/health/urls.py new file mode 100644 index 0000000..fe23326 --- /dev/null +++ b/apps/backend/hub_platform/health/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from hub_platform.health.views import live, ready + +urlpatterns = [ + path("live/", live, name="health-live"), + path("ready/", ready, name="health-ready"), +] diff --git a/apps/backend/hub_platform/health/views.py b/apps/backend/hub_platform/health/views.py new file mode 100644 index 0000000..c919d8a --- /dev/null +++ b/apps/backend/hub_platform/health/views.py @@ -0,0 +1,21 @@ +from django.core.cache import cache +from django.db import connection +from django.http import JsonResponse + + +def live(request): + return JsonResponse({"status": "ok", "service": "hub-backend"}) + + +def ready(request): + checks = {"database": False, "redis": False} + + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + checks["database"] = cursor.fetchone()[0] == 1 + + cache.set("healthcheck", "ok", timeout=5) + checks["redis"] = cache.get("healthcheck") == "ok" + + status_code = 200 if all(checks.values()) else 503 + return JsonResponse({"status": "ok" if status_code == 200 else "degraded", "checks": checks}, status=status_code) diff --git a/apps/backend/hub_platform/http/__init__.py b/apps/backend/hub_platform/http/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/hub_platform/http/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/hub_platform/http/middleware.py b/apps/backend/hub_platform/http/middleware.py new file mode 100644 index 0000000..f4cb99a --- /dev/null +++ b/apps/backend/hub_platform/http/middleware.py @@ -0,0 +1,23 @@ +from collections.abc import Callable + +from django.conf import settings +from django.http import HttpRequest, HttpResponse + + +class LocalCorsMiddleware: + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + if request.method == "OPTIONS" and request.headers.get("Origin") in settings.CORS_ALLOWED_ORIGINS: + response = HttpResponse(status=204) + else: + response = self.get_response(request) + origin = request.headers.get("Origin") + if origin in settings.CORS_ALLOWED_ORIGINS: + response["Access-Control-Allow-Origin"] = origin + response["Access-Control-Allow-Credentials"] = "true" + response["Access-Control-Allow-Headers"] = "Content-Type, X-Correlation-Id, X-CSRFToken" + response["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response["Vary"] = "Origin" + return response diff --git a/apps/backend/manage.py b/apps/backend/manage.py new file mode 100644 index 0000000..59e4376 --- /dev/null +++ b/apps/backend/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt new file mode 100644 index 0000000..62444f3 --- /dev/null +++ b/apps/backend/requirements.txt @@ -0,0 +1,9 @@ +Django>=5.2,<5.3 +djangorestframework>=3.16,<3.17 +psycopg[binary]>=3.2,<3.3 +redis>=5.2,<5.3 +uvicorn[standard]>=0.34,<0.35 +gunicorn>=23.0,<24.0 +pytest>=8.4,<8.5 +pytest-django>=4.11,<4.12 +ruff>=0.12,<0.13 diff --git a/apps/checkout/.gitkeep b/apps/checkout/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/checkout/Dockerfile b/apps/checkout/Dockerfile new file mode 100644 index 0000000..63621cd --- /dev/null +++ b/apps/checkout/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json tsconfig.base.json ./ +COPY apps/checkout/package.json ./apps/checkout/package.json +COPY packages/ui/package.json ./packages/ui/package.json +COPY packages/contracts/package.json ./packages/contracts/package.json +COPY packages/shared/package.json ./packages/shared/package.json +RUN npm install + +COPY apps/checkout ./apps/checkout +COPY packages ./packages + +WORKDIR /app/apps/checkout +EXPOSE 5174 diff --git a/apps/checkout/index.html b/apps/checkout/index.html new file mode 100644 index 0000000..97f24e1 --- /dev/null +++ b/apps/checkout/index.html @@ -0,0 +1,12 @@ + + + + + + Оформление покупки | Edevs Hub + + +
+ + + diff --git a/apps/checkout/package.json b/apps/checkout/package.json new file mode 100644 index 0000000..ef9c911 --- /dev/null +++ b/apps/checkout/package.json @@ -0,0 +1,24 @@ +{ + "name": "@edevs/checkout", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@edevs/contracts": "0.1.0", + "@edevs/shared": "0.1.0", + "@edevs/ui": "0.1.0", + "antd": "^5.26.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0" + } +} diff --git a/apps/checkout/src/App.tsx b/apps/checkout/src/App.tsx new file mode 100644 index 0000000..39ba476 --- /dev/null +++ b/apps/checkout/src/App.tsx @@ -0,0 +1,16 @@ +import { ConfigProvider, Result } from "antd"; +import { edevsHubTheme } from "@edevs/ui"; + +export function App() { + return ( + +
+ +
+
+ ); +} diff --git a/apps/checkout/src/main.tsx b/apps/checkout/src/main.tsx new file mode 100644 index 0000000..181f392 --- /dev/null +++ b/apps/checkout/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/checkout/src/styles.css b/apps/checkout/src/styles.css new file mode 100644 index 0000000..fea7f74 --- /dev/null +++ b/apps/checkout/src/styles.css @@ -0,0 +1,14 @@ +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +.checkout-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: #f6f8fb; +} diff --git a/apps/checkout/tsconfig.json b/apps/checkout/tsconfig.json new file mode 100644 index 0000000..3d61827 --- /dev/null +++ b/apps/checkout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/apps/checkout/vite.config.ts b/apps/checkout/vite.config.ts new file mode 100644 index 0000000..a930ad5 --- /dev/null +++ b/apps/checkout/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + }, +}); diff --git a/apps/internal-ui/.gitkeep b/apps/internal-ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/internal-ui/Dockerfile b/apps/internal-ui/Dockerfile new file mode 100644 index 0000000..1754477 --- /dev/null +++ b/apps/internal-ui/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json tsconfig.base.json ./ +COPY apps/internal-ui/package.json ./apps/internal-ui/package.json +COPY packages/ui/package.json ./packages/ui/package.json +COPY packages/contracts/package.json ./packages/contracts/package.json +COPY packages/shared/package.json ./packages/shared/package.json +RUN npm install + +COPY apps/internal-ui ./apps/internal-ui +COPY packages ./packages + +WORKDIR /app/apps/internal-ui +EXPOSE 5173 diff --git a/apps/internal-ui/index.html b/apps/internal-ui/index.html new file mode 100644 index 0000000..430fa9a --- /dev/null +++ b/apps/internal-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Edevs Hub + + +
+ + + diff --git a/apps/internal-ui/package.json b/apps/internal-ui/package.json new file mode 100644 index 0000000..756a028 --- /dev/null +++ b/apps/internal-ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "@edevs/internal-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@ant-design/icons": "^6.0.0", + "@edevs/contracts": "0.1.0", + "@edevs/shared": "0.1.0", + "@edevs/ui": "0.1.0", + "antd": "^5.26.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0" + } +} diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx new file mode 100644 index 0000000..3503f5b --- /dev/null +++ b/apps/internal-ui/src/App.tsx @@ -0,0 +1,52 @@ +import { ApartmentOutlined, RobotOutlined, ShoppingOutlined, TeamOutlined } from "@ant-design/icons"; +import { ConfigProvider, Layout, Menu, Tag, Typography } from "antd"; +import { edevsHubTheme } from "@edevs/ui"; + +const { Header, Sider, Content } = Layout; + +export function App() { + return ( + + + +
Edevs Hub
+ , label: "Командный центр" }, + { type: "group", label: "Компания" }, + { key: "departments", icon: , label: "Отделы" }, + { key: "products", icon: , label: "Продукты" }, + { type: "group", label: "Платформа" }, + { key: "ai", icon: , label: "AI" }, + ]} + /> + + +
+ Edevs / Командный центр + Локальный контур +
+ +
+ Командный центр + + Первый рабочий каркас внутреннего Hub. Реальные домены подключаются на следующих этапах. + +
+
+
+
+ Продажи + NORMAL +
+

Данные отдела появятся после реализации доменных модулей продаж, коммуникаций и коммерческого ядра.

+
+
+
+
+ + + ); +} diff --git a/apps/internal-ui/src/main.tsx b/apps/internal-ui/src/main.tsx new file mode 100644 index 0000000..181f392 --- /dev/null +++ b/apps/internal-ui/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css new file mode 100644 index 0000000..2e9717e --- /dev/null +++ b/apps/internal-ui/src/styles.css @@ -0,0 +1,52 @@ +html, +body, +#root { + min-width: 1024px; + min-height: 100%; + margin: 0; +} + +.hub-shell { + min-height: 100vh; +} + +.hub-sidebar { + border-right: 1px solid #e5e7eb; +} + +.hub-logo { + height: 56px; + padding: 18px 24px; + font-weight: 700; +} + +.hub-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 24px; + border-bottom: 1px solid #e5e7eb; +} + +.hub-content { + padding: 24px; +} + +.page-header { + margin-bottom: 24px; +} + +.department-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 16px; +} + +.department-card { + min-height: 180px; + padding: 20px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} diff --git a/apps/internal-ui/tsconfig.json b/apps/internal-ui/tsconfig.json new file mode 100644 index 0000000..3d61827 --- /dev/null +++ b/apps/internal-ui/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/apps/internal-ui/vite.config.ts b/apps/internal-ui/vite.config.ts new file mode 100644 index 0000000..c9d4090 --- /dev/null +++ b/apps/internal-ui/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); diff --git a/apps/web-chat/.gitkeep b/apps/web-chat/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web-chat/Dockerfile b/apps/web-chat/Dockerfile new file mode 100644 index 0000000..3d7772f --- /dev/null +++ b/apps/web-chat/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json tsconfig.base.json ./ +COPY apps/web-chat/package.json ./apps/web-chat/package.json +COPY packages/ui/package.json ./packages/ui/package.json +COPY packages/contracts/package.json ./packages/contracts/package.json +COPY packages/shared/package.json ./packages/shared/package.json +RUN npm install + +COPY apps/web-chat ./apps/web-chat +COPY packages ./packages + +WORKDIR /app/apps/web-chat +EXPOSE 5175 diff --git a/apps/web-chat/index.html b/apps/web-chat/index.html new file mode 100644 index 0000000..c313969 --- /dev/null +++ b/apps/web-chat/index.html @@ -0,0 +1,12 @@ + + + + + + Web Chat | Edevs Hub + + +
+ + + diff --git a/apps/web-chat/package.json b/apps/web-chat/package.json new file mode 100644 index 0000000..d56a89e --- /dev/null +++ b/apps/web-chat/package.json @@ -0,0 +1,24 @@ +{ + "name": "@edevs/web-chat", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@edevs/contracts": "0.1.0", + "@edevs/shared": "0.1.0", + "@edevs/ui": "0.1.0", + "antd": "^5.26.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0" + } +} diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx new file mode 100644 index 0000000..e1ba812 --- /dev/null +++ b/apps/web-chat/src/App.tsx @@ -0,0 +1,18 @@ +import { ConfigProvider, FloatButton, Typography } from "antd"; +import { edevsHubTheme } from "@edevs/ui"; + +export function App() { + return ( + +
+
+ Web Chat + + Каркас публичного виджета готов. Детальная реализация UI ждёт прохождения DG-04. + +
+ +
+
+ ); +} diff --git a/apps/web-chat/src/main.tsx b/apps/web-chat/src/main.tsx new file mode 100644 index 0000000..181f392 --- /dev/null +++ b/apps/web-chat/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/web-chat/src/styles.css b/apps/web-chat/src/styles.css new file mode 100644 index 0000000..9444a86 --- /dev/null +++ b/apps/web-chat/src/styles.css @@ -0,0 +1,22 @@ +html, +body, +#root { + min-height: 100%; + margin: 0; +} + +.web-chat-demo { + min-height: 100vh; + padding: 24px; + background: #f6f8fb; +} + +.chat-panel { + width: min(420px, calc(100vw - 32px)); + margin-left: auto; + padding: 20px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 10px 30px rgb(15 23 42 / 10%); +} diff --git a/apps/web-chat/tsconfig.json b/apps/web-chat/tsconfig.json new file mode 100644 index 0000000..3d61827 --- /dev/null +++ b/apps/web-chat/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web-chat/vite.config.ts b/apps/web-chat/vite.config.ts new file mode 100644 index 0000000..df7f6c9 --- /dev/null +++ b/apps/web-chat/vite.config.ts @@ -0,0 +1,9 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5175, + }, +}); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..72a5b4a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,133 @@ +x-hub-env: &hub-env + - path: .env.example + - path: .env + required: false + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-edevs_hub} + POSTGRES_USER: ${POSTGRES_USER:-edevs_hub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-edevs_hub} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + backend: + build: + context: . + dockerfile: apps/backend/Dockerfile + env_file: *hub-env + command: > + sh -c "python manage.py migrate && + uvicorn hub_backend.asgi:application --host 0.0.0.0 --port 8000" + ports: + - "${BACKEND_PORT:-8010}:8000" + volumes: + - ./apps/backend:/app/apps/backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live/', timeout=3)", + ] + interval: 10s + timeout: 5s + retries: 10 + + worker: + build: + context: . + dockerfile: apps/backend/Dockerfile + env_file: *hub-env + command: python manage.py run_worker + volumes: + - ./apps/backend:/app/apps/backend + depends_on: + backend: + condition: service_healthy + + internal-ui: + build: + context: . + dockerfile: apps/internal-ui/Dockerfile + env_file: *hub-env + command: npm run dev -- --host 0.0.0.0 --port 5173 + ports: + - "5173:5173" + volumes: + - ./apps/internal-ui:/app/apps/internal-ui + - ./packages:/app/packages + - /app/node_modules + - /app/apps/internal-ui/node_modules + + checkout: + build: + context: . + dockerfile: apps/checkout/Dockerfile + env_file: *hub-env + command: npm run dev -- --host 0.0.0.0 --port 5174 + ports: + - "5174:5174" + volumes: + - ./apps/checkout:/app/apps/checkout + - ./packages:/app/packages + - /app/node_modules + - /app/apps/checkout/node_modules + + web-chat: + build: + context: . + dockerfile: apps/web-chat/Dockerfile + env_file: *hub-env + command: npm run dev -- --host 0.0.0.0 --port 5175 + ports: + - "5175:5175" + volumes: + - ./apps/web-chat:/app/apps/web-chat + - ./packages:/app/packages + - /app/node_modules + - /app/apps/web-chat/node_modules + + proxy: + image: nginx:1.27-alpine + ports: + - "80:80" + volumes: + - ./deploy/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + internal-ui: + condition: service_started + checkout: + condition: service_started + web-chat: + condition: service_started + +volumes: + postgres_data: diff --git a/deploy/.gitkeep b/deploy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf new file mode 100644 index 0000000..cc621d0 --- /dev/null +++ b/deploy/nginx/local.conf @@ -0,0 +1,48 @@ +server { + listen 80; + server_name hub.localhost localhost; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://internal-ui:5173; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name pay.localhost; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://checkout:5174; + proxy_set_header Host $host; + } +} + +server { + listen 80; + server_name chat.localhost; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://web-chat:5175; + proxy_set_header Host $host; + } +} diff --git a/design/checkout/.gitkeep b/design/checkout/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/design/internal/baseline/.thumbnail b/design/internal/baseline/.thumbnail new file mode 100644 index 0000000..1434a0b Binary files /dev/null and b/design/internal/baseline/.thumbnail differ diff --git a/design/internal/baseline/AI-агенты.dc.html b/design/internal/baseline/AI-агенты.dc.html new file mode 100644 index 0000000..4302543 --- /dev/null +++ b/design/internal/baseline/AI-агенты.dc.html @@ -0,0 +1,168 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/AI/AI-агенты
+
+ +
+
ИП
+
+
+ + + + +
+
+ + +
+
+ + +
+
+

AI-агенты

+

Sales-агенты продуктов · один агент на продукт

+
+
+ + +
+
Агентов активно
2 / 2
+
Диалогов на AI сейчас
35
+
Конверсия AI · сегодня
13,2%
+
Стоимость AI · сегодня
₽1 240
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
АГЕНТПРОДУКТМОДЕЛЬАКТИВНЫЙ RELEASEСТАТУСДИАЛОГИ СЕЙЧАСКОНВЕРСИЯ
+
+
+
{{ r.name }}
{{ r.id }}
+
+
{{ r.product }}{{ r.model }}{{ r.release }}Published{{ r.status }}{{ r.dialogs }}{{ r.conv }} +
+ Test chat + +
+ + + +
+
+
+
2 агента
+
В первой итерации — один sales-agent на продукт
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/AUTH · Вход.dc.html b/design/internal/baseline/AUTH · Вход.dc.html new file mode 100644 index 0000000..c4406f8 --- /dev/null +++ b/design/internal/baseline/AUTH · Вход.dc.html @@ -0,0 +1,88 @@ + + + + + + + + + + + + + +
+
+ + +
+
+

Edevs Hub

+

Вход во внутренний кабинет

+
+ + +
+ + +
+ + Неверный email или пароль. Проверьте данные и попробуйте снова. +
+
+ + +
+ + +
+ + +
+ + + +
+ + +
+ +

Доступ только для сотрудников Edevs · защищённое соединение

+
+
+
+ + + diff --git a/design/internal/baseline/MANIFEST.sha256 b/design/internal/baseline/MANIFEST.sha256 new file mode 100644 index 0000000..a354b8b --- /dev/null +++ b/design/internal/baseline/MANIFEST.sha256 @@ -0,0 +1,35 @@ +d549527b8f9c7a42c08719c881d98733cda85d708f67b4f149cb9fa87bd302ae #U0410#U0443#U0434#U0438#U0442.dc.html +65f1b00ce2b529640284ad25c8e4d59a4e517ef5c1a9f086bac4e439193e131a #U0418#U043d#U0442#U0435#U0433#U0440#U0430#U0446#U0438#U0438.dc.html +01a7727f0b15e3b1e8bd866c624a8a31bb10a2da5e857b4a3b84c53dc55dc888 #U0418#U0441#U043f#U043e#U043b#U044c#U0437#U043e#U0432#U0430#U043d#U0438#U0435 AI.dc.html +9c777f9d4a1a01921f6d3a22b727f3b59e29e6be25a409cd712e9ae048ed360a #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 #U0438#U043d#U0442#U0435#U0433#U0440#U0430#U0446#U0438#U0438.dc.html +5d3d52d5b20c6e0292b697f8fadf3f24f6439dd199895e1452500ba9be73c199 #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 #U043f#U0440#U043e#U0434#U0443#U043a#U0442#U0430.dc.html +7057493435e132ecea34abcf71551ba89449e7ee74df81f617f322d9f3e629a3 #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 #U0441#U043e#U0442#U0440#U0443#U0434#U043d#U0438#U043a#U0430.dc.html +b815c76e76d52db851e0314bfd764c08915aa1670b273eda60f283e4b9d192ad #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 AI-#U0430#U0433#U0435#U043d#U0442#U0430.dc.html +653f41c7ac5a9fcee37f843f4453ff178f744ae43fa0d534988f6a3d882afb3e #U041a#U043e#U043c#U0430#U043d#U0434#U043d#U044b#U0439 #U0446#U0435#U043d#U0442#U0440.dc.html +c6d096b5c645e24d104684373f026239f5c1b7e04d6abd1c8f11c38a9dde3dc6 #U041d#U0430#U0441#U0442#U0440#U043e#U0439#U043a#U0438.dc.html +a003db70efc372ded0ff600000b33d78cd0d59e0f0351cd5f1ce68291afcc711 #U041e#U0442#U0434#U0435#U043b#U044b.dc.html +3a68bfc31de264c1d54466f69f8d1ac38eede69432b1e7dfe55191937c34b355 #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U0414#U0438#U0430#U043b#U043e#U0433#U0438.dc.html +26ce567d808363a7858fd248d22fa9c0e3548ab1f71783704ee419fd26051155 #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 #U0437#U0430#U043a#U0430#U0437#U0430.dc.html +aae56acbc110e3bda8fbde2d3b9f817915e00837a524517c6721b0199d359560 #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U041a#U0430#U0440#U0442#U043e#U0447#U043a#U0430 #U043a#U043b#U0438#U0435#U043d#U0442#U0430.dc.html +abbbbc373be1ca2386fb1eda7790c77715e643ce06234258dde03a56145fee41 #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U041a#U043b#U0438#U0435#U043d#U0442#U044b.dc.html +e61ff1cdb6c4ab80463e5afd7fec41bdc5d407f17fbe64ed08aa49d830be349f #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U041e#U0431#U0437#U043e#U0440.dc.html +86ec3c171f78d2abd7625e3a039dda1e759566641a2329f113ee0be851c2df81 #U041f#U0440#U043e#U0434#U0430#U0436#U0438 #U00b7 #U041f#U0440#U043e#U0434#U0430#U0436#U0438.dc.html +b247932639fa3441bfaff801e733d26d6293cc93e40d67e52c3e7fa9d364c6a6 #U041f#U0440#U043e#U0434#U0443#U043a#U0442#U044b.dc.html +869ad944fdac1acbefd6e017fe59f20c43ac784deda063efd118271b72fece58 #U041f#U0440#U043e#U0444#U0438#U043b#U044c.dc.html +64c50854f460c471e52ab18af50c632a9dbbd4043442b252d8b3b13927141915 #U0421#U043e#U0442#U0440#U0443#U0434#U043d#U0438#U043a#U0438.dc.html +973deac2d4dc992a130e38cd1a792174ccfe0aedc2dc6e0a1628e747ff426d68 #U0422#U0435#U0441#U0442#U043e#U0432#U044b#U0439 #U0447#U0430#U0442.dc.html +94d0f43a0a80086e40ad9be57ca3e1f9460bbc1ac4655a15a8ebe7af8204d017 .thumbnail +6d89697f16bf17a1c2dc8757cd6ec987a34d7de6a16f0b8494d79c2d6148e062 AI-#U0430#U0433#U0435#U043d#U0442#U044b.dc.html +a6396ab0d26b278afa3bb6306e4667d43d2606f75306c9dd72aeed8eb6bccb8f AUTH #U00b7 #U0412#U0445#U043e#U0434.dc.html +80b1436c39c915a8eba25ada02e27d014e847a62b23d3fd635ba010395fbdeed ProductAIRelease.dc.html +cea9065b0892755a5ccb4bf7e1bf214bcd44520e0055f9c981e58bad0c58ecf4 screenshots/01-emp-security.png +e2c4d7a686d71f1d7464c1e121a6a49d813902becf08d3b6a44c77a1cb35e2cd screenshots/01-ov-lower.png +cea9065b0892755a5ccb4bf7e1bf214bcd44520e0055f9c981e58bad0c58ecf4 screenshots/02-emp-security.png +ae02004de7c6427ab3fdcc6b2124494013e2f2ba50b7c92545f81c96102d0bbc screenshots/02-ov-lower.png +d410897bf2fb7614c4cd49a2f5308348382b2cdd5253b2755dd3a16414c6183b screenshots/cc-01.png +e05ab592a28ee75975cfffe1b08def8679d333a29934cee4b79dcb32466a33db screenshots/cc-full.png +f68d5bccb5697155a7235ac38bfc99ce41e14f1f4b7f0f711ff0018f0cdb61bf screenshots/dialogs-waiting.png +cdb685c1642a8cb7f3cd0b80d6338b14c7ba8573cf2586ae56cd3d66747c893b screenshots/overview.png +505f94e08eeccbc87acc6fcc6a47dd9f51fcd74a8d28ab58eb7eb784f7b92efe support.js +212f0e10d99b36513d5eb424183f8d6e4d9ba6eae46495c9e0e27ce7a94b9f6b uploads/ADR-HUB-0013-Internal-UI-Design-System.md +e0067ea9a7cacbc71b0925e02f9277ef8e23c587f11cc23670009919b62acb7d uploads/SPEC-HUB-0004-Internal-Hub-UI.md diff --git a/design/internal/baseline/ProductAIRelease.dc.html b/design/internal/baseline/ProductAIRelease.dc.html new file mode 100644 index 0000000..86cc558 --- /dev/null +++ b/design/internal/baseline/ProductAIRelease.dc.html @@ -0,0 +1,286 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
AI/FirePage Sales/REL-FP-v5
+
+ +
+
ИП
+
+
+ + +
+
+
+
+

REL-FP-v5

{{ st.label }}
+
FirePage Sales · candidate создан сегодня 11:20 · автор Иван Петров
+
+
+
+ + Test chat + +
+
+ +
+
+ + +
+ {{ vb.icon }} +
{{ vb.title }}
{{ vb.note }}
+ +
+ + +
+ + +
+

Состав release

+ + +
+

Модель и параметры

+
+
Модель
gpt-4o-mini
+
Temperature
0.4
+
Max tokens
1024
+
Top-p
0.9
+
+
+ + +
+

Версии prompts

+ +
+
{{ p.name }}{{ p.ver }}
+ изменён + {{ p.updated }} +
+
+
+ + +
+

Обязательные знания

retrieval index · собран
+ +
+ + {{ k.title }} + {{ k.ver }} +
+
+
+ + +
+
+

Инструменты

+
+
create_checkout
+
get_offers
+
handoff_to_operator
+
+
+
+

Лимиты

+
Диалогов в день5 000
+
Бюджет в день₽5 000
+
Макс. сообщений / диалог40
+
+
+
+ + +
+ +
+

Проверки

{{ checksSummary }}
+ +
+ {{ c.icon }} +
{{ c.label }}
{{ c.detail }}
+
+
+
+ + +
+

Изменения

Относительно REL-FP-v4 (published)
+ +
+ {{ ch.sign }} +
{{ ch.text }}
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+

Опубликовать REL-FP-v5?

Атомарная публикация конфигурации FirePage Sales
+
+
Новые ответы во всех активных диалогах сразу будут использовать этот release. Текущий REL-FP-v4 перейдёт в архив. Действие необратимо в части уже отправленных ответов.
+
+
+ Понимаю, что активные диалоги переключатся на новый release +
+
+
+ + +
+
+
+
+
+
+ + + diff --git a/design/internal/baseline/screenshots/01-emp-security.png b/design/internal/baseline/screenshots/01-emp-security.png new file mode 100644 index 0000000..491fcf9 Binary files /dev/null and b/design/internal/baseline/screenshots/01-emp-security.png differ diff --git a/design/internal/baseline/screenshots/01-ov-lower.png b/design/internal/baseline/screenshots/01-ov-lower.png new file mode 100644 index 0000000..08fb056 Binary files /dev/null and b/design/internal/baseline/screenshots/01-ov-lower.png differ diff --git a/design/internal/baseline/screenshots/02-emp-security.png b/design/internal/baseline/screenshots/02-emp-security.png new file mode 100644 index 0000000..491fcf9 Binary files /dev/null and b/design/internal/baseline/screenshots/02-emp-security.png differ diff --git a/design/internal/baseline/screenshots/02-ov-lower.png b/design/internal/baseline/screenshots/02-ov-lower.png new file mode 100644 index 0000000..54a49d8 Binary files /dev/null and b/design/internal/baseline/screenshots/02-ov-lower.png differ diff --git a/design/internal/baseline/screenshots/cc-01.png b/design/internal/baseline/screenshots/cc-01.png new file mode 100644 index 0000000..9dce466 Binary files /dev/null and b/design/internal/baseline/screenshots/cc-01.png differ diff --git a/design/internal/baseline/screenshots/cc-full.png b/design/internal/baseline/screenshots/cc-full.png new file mode 100644 index 0000000..2d9b8e0 Binary files /dev/null and b/design/internal/baseline/screenshots/cc-full.png differ diff --git a/design/internal/baseline/screenshots/dialogs-waiting.png b/design/internal/baseline/screenshots/dialogs-waiting.png new file mode 100644 index 0000000..035ece2 Binary files /dev/null and b/design/internal/baseline/screenshots/dialogs-waiting.png differ diff --git a/design/internal/baseline/screenshots/overview.png b/design/internal/baseline/screenshots/overview.png new file mode 100644 index 0000000..15a077c Binary files /dev/null and b/design/internal/baseline/screenshots/overview.png differ diff --git a/design/internal/baseline/support.js b/design/internal/baseline/support.js new file mode 100644 index 0000000..9b71e07 --- /dev/null +++ b/design/internal/baseline/support.js @@ -0,0 +1,1513 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:rgba(0,0,0,.7);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + section, article, figure, table { break-inside: avoid; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.adoptParsed(rootName, parsed); + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + return h(Root, entry.propOverrides || null); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, isComponent, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (isComponent) { + if (key.includes("-")) key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, true, host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) props[k] = g(vals); + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const url = el.getAttribute("from") || el.getAttribute("src") || el.getAttribute("import") || ""; + const kind = /\.(jsx|tsx)(\?|#|$)/i.test(url) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, true, host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = url.includes("{{"); + if (url && !urlBindable) host.loadExternal(kind, url); + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "name" || k === "from" || k === "src" || k === "import") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const { propGetters, pseudoClasses } = collectProps(el, false, host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { key, "data-dc-tpl": tplId }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h("div", { className: "sc-logic-error" }, renderErr), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"; + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = BABEL_URL; + s.crossOrigin = "anonymous"; + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + function load(kind, url) { + if (cache.has(url)) return; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = kind === "jsx" ? ensureBabel() : Promise.resolve(); + ready.then(() => fetch(url)).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/helmet.ts + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url) => external.load(kind, url), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + fetch(url).then((res) => { + if (!res.ok) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/> failed:", + url, + "returned", + res.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res.text(); + }).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/>:", + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + "[dc-runtime] sibling fetch for <" + name + "/> threw:", + url, + e + ) + ); + } + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + +
+ + + + + +
+
+
Edevs/Настройки/Аудит
+
+ +
+
ИП
+
+
+ +
+
+ +
+
+

Аудит

+

Журнал действий пользователей и системы · {{ shownCount }} записей

+
+ +
+ + +
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ВРЕМЯАКТОРДЕЙСТВИЕОБЪЕКТРЕЗУЛЬТАТCORRELATION IDИСТОЧНИК
{{ r.time }}{{ r.actorMark }}{{ r.actor }}{{ r.action }}{{ r.object }}{{ r.result }}{{ r.cid }}{{ r.source }}
+
+ +
Записи не найдены
Измените фильтры или период.
+
+
+
Показано {{ shownCount }} из 1 204
+
Журнал доступен только для чтения
+
+
+ +
+
+
+ + + +
+
+
+
{{ d.action }}
{{ d.time }} · {{ d.cid }}
+ +
+
+
+
Актор
{{ d.actor }}
+
Результат
{{ d.result }}
+
Объект
{{ d.object }}
+
Источник / IP
{{ d.source }}
+
+ +
СТРУКТУРИРОВАННЫЕ ИЗМЕНЕНИЯ
+
+ +
+
{{ c.field }}
+
+ {{ c.from }} + + {{ c.to }} +
+
+
+
+ +
+ + Секреты и чувствительные payload не отображаются в журнале аудита. +
+
+
+
+
+
+ + + diff --git a/design/internal/baseline/Интеграции.dc.html b/design/internal/baseline/Интеграции.dc.html new file mode 100644 index 0000000..32c344a --- /dev/null +++ b/design/internal/baseline/Интеграции.dc.html @@ -0,0 +1,153 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Интеграции
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+

Интеграции

+

Внешние сервисы платформы · {{ okCount }} в норме, {{ issueCount }} требуют внимания

+
+ +
+ + + +
+
+

{{ g.title }}

+ {{ g.note }} +
+
+ +
+
{{ i.mark }}
+
+
+ {{ i.name }} + {{ i.stLabel }} +
+
{{ i.desc }}
+
+
+
Последняя активность
+
{{ i.activity }}
+
+ {{ i.btnLabel }} +
+
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Использование AI.dc.html b/design/internal/baseline/Использование AI.dc.html new file mode 100644 index 0000000..9340a1c --- /dev/null +++ b/design/internal/baseline/Использование AI.dc.html @@ -0,0 +1,265 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/AI/Использование AI
+
+ +
+
ИП
+
+
+ + + + +
+
+ + +
+
+

Использование AI

+

Стоимость, токены и эффективность агентов · {{ periodLabel }}

+
+
+ + + +
+
+ + +
+ +
+
{{ k.label }}
+
{{ k.value }}
+
{{ k.sub }}
+
+
+
+ + +
+
+ Приближение к бюджету · {{ periodLabel }} + {{ budgetSpent }} из {{ budgetTotal }} · {{ budgetPct }}% +
+
+
+ + +
+
+

Динамика стоимости AI

{{ periodLabel }} · {{ chartTotal }} суммарно
+
Стоимость
+
+
+ + + + + + + + + +
+ {{ x.label }} +
+
+
+ + +
+ + +
+

По продукту

+ + + + + + + +
ПРОДУКТДИАЛОГИСТОИМОСТЬ/ ДИАЛОГ
{{ r.name }}{{ r.dialogs }}{{ r.cost }}{{ r.per }}
+
+ + +
+

По модели

+ + + + + + + +
МОДЕЛЬTOKENS INTOKENS OUTСТОИМОСТЬ
{{ r.name }}{{ r.tin }}{{ r.tout }}{{ r.cost }}
+
+ + +
+

По агенту

+ + + + + + + +
АГЕНТДИАЛОГИHANDOFF/ ПРОДАЖА
{{ r.name }}{{ r.dialogs }}{{ r.handoff }}{{ r.perSale }}
+
+ + +
+

По каналу

+ + + + + + + +
КАНАЛДИАЛОГИСТОИМОСТЬДОЛЯ
{{ r.name }}{{ r.dialogs }}{{ r.cost }}{{ r.share }}
+
+ +
+
+
+
+
+
+ + + diff --git a/design/internal/baseline/Карточка AI-агента.dc.html b/design/internal/baseline/Карточка AI-агента.dc.html new file mode 100644 index 0000000..b0d083a --- /dev/null +++ b/design/internal/baseline/Карточка AI-агента.dc.html @@ -0,0 +1,291 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/AI/AI-агенты/FirePage Sales
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+
+
+
+
+

FirePage Sales

+ {{ rt.label }} +
+
+
Продукт
FirePage
+
Модель
gpt-4o-mini
+
Текущий release
REL-FP-v4
+
Лимиты
5К диал./день · ₽5 000
+
+
+
+ +
+
+ + +
+ + + + + +
+ + + +
+
+
+

Назначение агента

+

Sales-агент консультирует клиентов по продукту FirePage, квалифицирует потребность, предлагает подходящий Offer и оформляет покупку. При выходе за рамки сценария передаёт диалог оператору.

+
+
+

Runtime сейчас

+
+
Активные диалоги
21
+
В очереди на ответ
2
+
Ср. время ответа
3,4с
+
+
+
+
+
+

Лимиты

+
Диалогов в день847 / 5 000
+
+
Бюджет в день₽1 240 / ₽5 000
+
+
+
+

Управление

+ + +
+
+
+
+ + + +
+
+ + Инструкции редактируются в составе release candidate. Активный release published и доступен только для чтения — изменения применяются после публикации новой версии. +
+ +
+
1

Системный prompt

+
Ты — менеджер по продажам FirePage, готовых нишевых сайтов. Отвечай дружелюбно и по делу, на русском. Помогай выбрать между коробкой и годовой поддержкой. Не обещай возможности, которых нет в базе знаний. При запросе человека или нестандартной ситуации — передавай диалог оператору.
+
+ +
+
2

Квалификация (qualification)

+
+
Уточни нишу/тематику сайта и нужен ли он под ключ.
+
Выясни, нужна ли годовая поддержка сразу или позже.
+
Определи срочность и бюджет, не дави на клиента.
+
+
+ +
+
3

Поведение в продаже (sales behavior)

+
+
Предлагай коробку как основное решение, поддержку — как опцию.
+
Оформляй checkout сразу, как клиент подтвердил выбор.
+
Используй только актуальные цены из активного release.
+
+
+ +
+

Ограничения

+
+
Не выдумывай функции и не обещай сроки вне базы знаний.
+
Не предоставляй скидки сверх правил Offer.
+
Не запрашивай данные карты в чате — оплата только через checkout.
+
+
+
+
+ + + +
+

Обязательные знания release

Retrieval index · FirePage KB · 6 материалов
+ +
+ +
{{ k.title }}
{{ k.meta }}
+ В индексе +
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
RELEASEСТАТУСДАТААВТОРДИАЛОГИКОНВЕРСИЯ
{{ r.id }}{{ r.status }}{{ r.date }}{{ r.author }}{{ r.dialogs }}{{ r.conv }}
+
+
+ + + +
+
+
Диалоги · 30 дней
1 342
+
Продажи · 30 дней
56
+
Конверсия
15,1%
+
Стоимость / продажа
₽67
+
+
+

Причины передачи оператору · 30 дней

+
+ +
+
{{ h.label }}{{ h.count }} · {{ h.pct }}%
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Карточка интеграции.dc.html b/design/internal/baseline/Карточка интеграции.dc.html new file mode 100644 index 0000000..d88b084 --- /dev/null +++ b/design/internal/baseline/Карточка интеграции.dc.html @@ -0,0 +1,289 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Интеграции/{{ name }}
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+
+
{{ mark }}
+
+
+

{{ name }}

+ {{ st.label }} +
+
{{ subtitle }}
+
+
+
+ +
+
+ +
+
Healthcheck
{{ hc.label }}
+
Аптайм · 30 дней
{{ uptime }}
+
Ответ (p95)
{{ latency }}
+
Последняя проверка
{{ lastCheck }}
+
+
+ + +
+ + +
+ + +
+

Параметры

+
Секреты не отображаются — только статус и метаданные
+ +
+ {{ p.label }} + {{ p.value }} +
+
+
+ + +
+

{{ specialTitle }}

+ + + +
Модель по умолчанию
gpt-4o-mini
+
Разрешённые модели
+
+ {{ m.name }} +
+
+ + + +
+
Merchant account
acc_ed···4021
+
JWT credential
Активен · до 30 июня
+
Webhook
Задержка ответа
+
Reconciliation
Авто · 02:00
+
+
Последние платёжные события
+
+ +
{{ e.name }}{{ e.id }}{{ e.time }}
+
+
+
+ + + +
+
Связанные продукты
FirePageFoxray
+
Идентификатор бота
@edevs_sales
+
Webhook
Активен
+
Сообщений · сегодня
1 284
+
+
+
+ + +
+

Журнал ошибок

за 7 дней
+ + +
+ +
{{ e.code }}
{{ e.message }}
+ {{ e.time }} +
+
+
+ +
Ошибок за период нет
+
+
+
+ + +
+ +
+

Подключение

+
Статус{{ st.label }}
+ +
+ + +
+

Секрет

+
{{ secretNote }}
+
+ + {{ secretMask }} + {{ secretAge }} +
+ +
+ + +
+

Последняя активность

+
+ +
{{ a.text }}
{{ a.time }}
+
+
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Карточка продукта.dc.html b/design/internal/baseline/Карточка продукта.dc.html new file mode 100644 index 0000000..4339184 --- /dev/null +++ b/design/internal/baseline/Карточка продукта.dc.html @@ -0,0 +1,313 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продукты/{{ p.name }}
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+
+
+
+

{{ p.name }}

+ Активен + {{ p.kind }} +
+

{{ p.desc }}

+
+ +
+ +
+
Продажи · 30 дней
{{ p.sales30 }}
+
Выручка · 30 дней
{{ p.rev30 }}
+
{{ p.metric3Label }}
{{ p.metric3 }}
+
Конверсия диалогов
{{ p.conv }}
+
+
+ + +
+ + + + + +
+ + + +
+
+
+

Описание для продаж

+

{{ p.longDesc }}

+
+
+

Ключевые тезисы для AI

+
+ +
{{ b.text }}
+
+
+
+
+
+
+

Параметры

+
Тип{{ p.kind }}
+
ОтделПродажи
+
Sales-agent{{ p.agent }}
+
Создан12 мая 2026
+
+
+

Sales-agent

+
{{ p.agentNote }}
+ Открыть конфигурацию AI +
+
+
+
+ + + +
+
+
Активные Offer привязаны к версии цены. Изменение цены создаёт новую версию — прошлые заказы не затрагиваются.
+ +
+
+ +
+
{{ o.name }}{{ o.type }}
+
{{ o.price }}{{ o.unit }}
+
{{ o.secondary }}
+
{{ o.detail }}
+
{{ o.code }} · price {{ o.priceVer }}AI может предлагать
+
+
+
+ +
+

История версий цен

+ + + + + + + +
ВЕРСИЯOFFERЦЕНАДЕЙСТВУЕТ ССТАТУС
{{ h.ver }}{{ h.offer }}{{ h.price }}{{ h.from }}{{ h.status }}
+
+
+
+ + + +
+

База знаний продукта

Используется AI-агентом для ответов · {{ p.kbCount }} материалов
+ +
+ +
{{ k.title }}
{{ k.meta }}
+ {{ k.status }} +
+
+
+
+ + + +
+

Каналы продаж

Через какие каналы продукт доступен клиентам
+ +
+ +
{{ c.name }}
{{ c.meta }}
+ Активен +
+
+
+
+
+ + + +
+
+

Способ исполнения

{{ p.fulMethod }}
Подключено
+
+
Endpoint / handler
{{ p.fulEndpoint }}
+
Среднее время
{{ p.fulTime }}
+
Успешность · 30 дней
{{ p.fulRate }}
+
+
+
+

Последние операции

+ + + + + + + +
ЗАКАЗОПЕРАЦИЯСТАТУСВРЕМЯ
ORD-10519OP-55120Успешносегодня 14:03
ORD-10516OP-55117В процессесегодня 12:19
ORD-10470OP-55088Ошибкавчера 15:40
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Карточка сотрудника.dc.html b/design/internal/baseline/Карточка сотрудника.dc.html new file mode 100644 index 0000000..6521333 --- /dev/null +++ b/design/internal/baseline/Карточка сотрудника.dc.html @@ -0,0 +1,203 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Сотрудники/Анна Котова
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+
АК
+
+
+

Анна Котова

+ OPERATOR + {{ st.label }} +
+
a.kotova@edevs.tech · Отдел продаж
+
+
+ + +
+
+
+ + +
+ + +
+ +
+

Основные данные

+
+
+
+
+
+
+ + +
+

Роль и доступ

+
+
+
+
+ +
+ Диалоги + Клиенты + Заказы · чтение + Финансы · нет + Настройки · нет +
+
+ + +
+

Безопасность

+
+
Пароль
Последняя смена: 28 мая 2026
+ +
+
+
Двухфакторная аутентификация (TOTP)
{{ totpLabel }}
+
+
+
+
Активные сессии
2 устройства · Chrome (Москва), Safari (Москва)
+ +
+
+ + +
+

Опасная зона

+
+
{{ dangerNote }}
+ +
+
+
+ + +
+
+

Статус аккаунта

+
Статус{{ st.label }}
+
Создан12 мая 2026
+
Приглашение принято12 мая 2026
+
Последний вход5 мин назад
+
+ +
+

Текущая нагрузка

+
+
Активные диалоги
3
+
В очереди
0
+
Продажи · сегодня
5
+
Ср. ответ
1м 40с
+
+
+ +
+

Последние действия

+
+
Закрыла диалог · продажа Foxray
8 мин назад
+
Забрала диалог из очереди
22 мин назад
+
Вход в систему · Chrome
сегодня 09:02
+
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Командный центр.dc.html b/design/internal/baseline/Командный центр.dc.html new file mode 100644 index 0000000..8864bde --- /dev/null +++ b/design/internal/baseline/Командный центр.dc.html @@ -0,0 +1,451 @@ + + + + + + + + + + + + + +
+ + + + + +
+ + +
+
+ Edevs + / + Командный центр +
+
+
+ + {{ st.label }} +
+ +
+ +
+
+ + +
+
+ + +
+
+

Командный центр

+

Состояние компании одним взглядом · обновлено только что

+
+
+
+ + + +
+ +
+
+ + +
+
+ +
+
+
+ Компания: {{ st.label }} +
+

{{ compSummary }}

+
+
+
+
Отделы
+
1
+
+
+
Открытые диалоги
+
{{ m.open }}
+
+
+
Выручка · {{ periodLabel }}
+
{{ m.rev }}
+
+
+
+ + +
+ + +
+
+

Отделы

+ 1 активный +
+ + +
+
+
+ +
+
+
+

Продажи

+ + + {{ st.label }} + +
+
Ответственный: Анна Котова · 4 сотрудника · 1 AI-агент
+
+ +
+ +
{{ deptSummary }}
+ + +
ДИАЛОГИ — СЕЙЧАС
+
+
+
Открытые диалоги
+
{{ m.open }}
+
+
+
Активны за 15 мин
+
{{ m.active }}
+
+
+
На AI
+
{{ m.ai }}
+
+
+
На операторах
+
{{ m.op }}
+
+
+
Ожидают оператора
+
{{ m.wait }}
+
+
+ + +
КОММЕРЦИЯ — {{ periodLabelUpper }}
+
+
+
Незавершённые платежи
+
{{ m.pend }}
+
+
+
Ошибки fulfillment
+
{{ m.ferr }}
+
+
+
Продажи
+
{{ m.sales }}
+
+
+
Чистая выручка
+
{{ m.rev }}
+
+
+
+
+ + +
+ + +
+
+
+ + Требует внимания + {{ attentionCount }} +
+ Все +
+ +
+ + +
+
+
+ + Состояние интеграций +
+ {{ okCount }}/6 в норме +
+
+ +
+ + + {{ i.name }} + {{ i.group }} + + {{ i.statusLabel }} +
+
+
+
+ + +
+
+
+ + Расходы AI +
+ {{ periodLabel }} +
+
+ {{ aiSpendStr }} + из {{ budgetStr }} +
+
+
+
+
{{ aiPct }}% дневного бюджета
+
+
+
Токены
+
{{ aiTokens }}
+
+
+
Диалоги
+
{{ aiDialogs }}
+
+
+
Цена диалога
+
{{ costPerDialog }}
+
+
+
+ +
+
+ +
Обновлено: сегодня, 14:32 · детерминированная сводка
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Настройки.dc.html b/design/internal/baseline/Настройки.dc.html new file mode 100644 index 0000000..dcd5496 --- /dev/null +++ b/design/internal/baseline/Настройки.dc.html @@ -0,0 +1,246 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Настройки
+
+ +
+
ИП
+
+
+ +
+
+
+

Настройки

+

Конфигурация компании и платформы

+
+ +
+ +
+ + + +
+ + +
+ + + +
+
+

Реквизиты

+
+
+
+
+
+
+
+

Брендинг и локаль

+
Логотип Hub
PNG или SVG, до 1 МБ
+
+
+
+
+
+
+
+
+ + + +
+
+ + Не указан резервный канал доставки чеков (ОФД email). Конфигурация считается неполной — чеки уходят только основным способом. +
+
+

Текущий фискальный профиль

Активен · v2
+
+
Режим налогообложения
УСН «Доходы»
+
Ставка НДС
20%
+
Признак способа расчёта
Полный расчёт
+
Период действия
с 01 янв 2026
+
+
Изменение фискального профиля создаёт новую версию — прошлые чеки сохраняют профиль, действовавший на момент продажи.
+
+
+
+
+ + + +
+
+

Политика паролей

+
Минимальная длина
символов в пароле
+
Требовать спецсимволы и цифры
усложнённая сложность пароля
+
+
+

Доступ и сессии

+
TOTP владельца
включена
+
Длительность сессии
до повторного входа
+
Активные сессии
5 устройств у сотрудников
+
+
+
+ + + +
+
+

Категории уведомлений

+ +
{{ n.label }}
{{ n.desc }}
+
+
+
+

Для операторов

+
Звуковое уведомление о новом диалоге
+
Browser notifications
+
+
+
+ + + +
+
+

Сроки хранения (retention)

+
Диалоги и сообщения
после закрытия диалога
+
Вложения
файлы из диалогов
+
Автообезличивание неактивных контактов
через 24 месяца без активности
+
+
+ + Обязательные финансовые записи (заказы, платежи, чеки) хранятся бессрочно по требованиям законодательства и не удаляются автоматически. +
+
+
+ +
+
+
+
+
+
+
+ + + diff --git a/design/internal/baseline/Отделы.dc.html b/design/internal/baseline/Отделы.dc.html new file mode 100644 index 0000000..62466be --- /dev/null +++ b/design/internal/baseline/Отделы.dc.html @@ -0,0 +1,141 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Отделы
+
+ +
+ +
+
+ +
+
+ + +
+

Отделы

+

Существующие отделы компании · 1 активный

+
+ + +
+
+ +
+
+
+
+
+

Продажи

+ {{ st.label }} +
+

{{ summary }}

+
+
+
+ + +
+
+ Ответственный + АКАнна Котова +
+
+ Состав + 4 сотрудника · 1 AI-агент +
+
+ Связанные продукты + FirePageFoxray +
+
+ + +
+
Открытые диалоги
{{ open }}
+
Продажи · сегодня
{{ sales }}
+
Выручка
{{ rev }}
+
+ + + +
+
+ +

Новые отделы появятся здесь по мере их создания.

+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Диалоги.dc.html b/design/internal/baseline/Продажи · Диалоги.dc.html new file mode 100644 index 0000000..af550e2 --- /dev/null +++ b/design/internal/baseline/Продажи · Диалоги.dc.html @@ -0,0 +1,523 @@ + + + + + + + + + + + + + +
+ + + + + +
+ + +
+
+ Edevs + / + Продажи + / + Диалоги +
+
+ + + OWNER + + +
+
ИП
+
+
+ + +
+ + +
+
+
+

Диалоги

+ {{ totalCount }} всего +
+
+ + +
+
+ + +
+ + + + + +
+ + + +
+ + +
+ +
+
+
МС
+
+
+ Мария Соколова + + + {{ st.label }} + +
+
+ FirePage· + MAX + ·@maria.s +
+
+
+
+ + + + + + + +
+
+ + +
+
+ +
+
+ Сегодня +
+
+ +
Диалог начат · канал MAX · 14:02
+ + +
+
МС
+
+
Здравствуйте! Смотрю FirePage для лендингов. Есть тариф на команду?
+
14:02
+
+
+ + +
+
+
+
AI-агент FirePage
+
Да! Для команд есть тариф Business: до 10 пользователей, общие проекты и приоритетная поддержка. Помесячная оплата доступна.
+
14:03
+
+
+ + +
+
МС
+
+
Отлично. И ещё вопрос по интеграции с нашей CRM (amoCRM) — можно обсудить с менеджером?
+
14:08
+
+
+ + + +
AI передал диалог оператору · причина: запрос человека (CRM-интеграция)
+
Ожидает оператора · 4 мин
+
+ + + +
+
+
+ + + +
+
+
+ + + +
Иван Петров забрал диалог · 14:09
+
+
ИП
+
+
Иван · оператор
+
Здравствуйте, Мария! Я подключился. По amoCRM — у FirePage Business есть готовая двусторонняя интеграция, лиды и сделки синхронизируются автоматически. Расскажу подробнее?
+
14:10
+
+
+
+ +
+
+ + +
+
+ + + +
+
+
+
Диалог ждёт оператора
+
Возьмите диалог, чтобы ответить клиенту. AI поставлен на паузу.
+
+
+ + +
+
+
+ + + +
+
+
+
AI ведёт диалог
+
Перехватите, чтобы ответить вручную. Контроль перейдёт к вам.
+
+ +
+
+ + + +
+
+ + +
+ + +
+
+ + + +
+
+
+ +
+
+
+ + +
+ +
+ + + +
+ +
+ + + +
+
+
МС
+
Мария Соколова
+
Согласие получено
+
+ +
+
КАНАЛЫ И КОНТАКТЫ
+
+ +
MAX
@maria.s
+ основной +
+
+ +
m.sokolova@workmail.ru
Email · подтверждён
+
+
+ +
+7 ··· ·· 14
Телефон · скрыт
+
+
+ +
+
+ РАБОЧАЯ ЗАМЕТКА + +
+
Команда ~8 человек, интересует FirePage Business помесячно. Открытый вопрос — интеграция с их CRM (amoCRM).
+
+ +
+
СВОДКА
+
+
Заказы
0
+
Диалоги
2
+
Покупки
₽0
+
+
Первый контакт: сегодня, 14:02
+
+ +
+
ДЕЙСТВИЯ OWNER
+ + +
+
+
+ + + +
+
+
+
FirePage
Конструктор лендингов · активен
+
+
ПОДХОДЯЩИЕ OFFER
+
+
BusinessAI может предлагать
+
₽2 490/ мес · помесячно
+
До 10 пользователей · общие проекты · amoCRM-интеграция · приоритетная поддержка.
+
+
+
Proдля одного
+
₽990/ мес
+
1 пользователь · все шаблоны · базовая поддержка.
+
+
+
+ + + +
+
ПРЕДЫДУЩИЕ ДИАЛОГИ
+
+
+
Консультация по тарифам3 дня назад
+
FirePage · MAX · закрыт. Клиент уточнял лимиты тарифов, продажа не оформлена.
+
+
+
Текущий диалогсейчас
+
FirePage · MAX · ждёт оператора. Вопрос по amoCRM-интеграции для команды.
+
+
+
+
+ +
+
+ +
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Карточка заказа.dc.html b/design/internal/baseline/Продажи · Карточка заказа.dc.html new file mode 100644 index 0000000..e173d09 --- /dev/null +++ b/design/internal/baseline/Продажи · Карточка заказа.dc.html @@ -0,0 +1,282 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продажи/Продажи/ORD-10519
+
+ OWNER + +
+
ИП
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+

ORD-10519

+ Выполнен · подписка активна +
+
+
Сумма
₽2 490 / мес
+ +
Продукт / Offer
FirePage · Business
+
Дата
16 июня 2026
+
Источник
MAX
+
Оформление
AI-агент
+
+
+ +
+
+ + +
+

Commerce timeline

+
+
Checkout
14:00
+
+
Order
14:01
+
+
Payment
14:02
+
+
Receipt
14:02
+
+
Fulfillment
14:03
+
+
Entitlement
активен
+
+
Refund
нет
+
+
+ + +
+ + + + + + +
+ + + +
+
+
+

Позиции заказа

Снимок на момент оформления · цена зафиксирована
+ + + + + + +
OFFERТИПЦЕНАКОЛ-ВОИТОГО
FirePage · Business
OFR-FP-BUS · price v4
Подписка · мес₽2 4901₽2 490
Итого к оплате₽2 490
+
+
+

Принятые документы

+
Публичная оферта v3.1принято 16 июня 14:00
+
Политика обработки персональных данныхпринято 16 июня 14:00
+
+
+
+
+

Данные покупателя

+
Татьяна Лебедева
+
t.lebedeva@agency.ru
+
ООО «Агентство Лебедева» · ИНН 7701234567
+
+
+

Источник оформления

+
Канал MAX · диалог с AI-агентом
+ checkout CHK-77120 → +
создан 16 июня 14:00
+
+
+
+
+ + + +
+
+

Платежи и попытки

+ + + +
ПЛАТЁЖПРОВАЙДЕРСУММАМЕТОДСТАТУССВЕРКА
PAY-88231Точка₽2 490Карта ···4242УспешноСверено
+
+
+

События провайдера

+
+
payment.authorizedevt_8831a14:02:03
+
payment.capturedevt_8831b14:02:05
+
payment.settledevt_8832c02:10
+
+
+
+
+ + + +
+

Фискальный чек

Отправлен в ОФД
+
+
Чек
RCP-44021
+
Тип
Приход
+
Фискальный признак (ФПД)
3920··· 7741
+
Сумма
₽2 490 · НДС 20%
+
ОФД
Принят · 16 июня 14:02
+
Отправлен клиенту
Email · доставлен
+
+
+
+ + + +
+

Product fulfillment

OP-55120 · FirePage
Исполнен
+
+
Попытки
1
+
Результат
Успешно
+
Обновлено
16 июня 14:03
+
+
PAYLOAD (SUMMARY)
+
{ "product": "firepage", "plan": "business", "seats": 10, "account": "agency-lebedeva", "activate_at": "2026-06-16T14:03Z" }
+
+
+ + + +
+

Подписка

SUB-2041 · FirePage Business
Активна
+
+
Текущий период
16 июн — 16 июл 2026
+
Следующее списание
16 июля 2026 · ₽2 490
+
Entitlement
10 рабочих мест
+
+
+ + +
+
+
+ + + +
+ + + + + + + + + +
ВРЕМЯДЕЙСТВИЕАКТОРРЕЗУЛЬТАТ
16 июня 14:03Fulfillment выполнен · OP-55120системауспешно
16 июня 14:02Чек отправлен в ОФД · RCP-44021системауспешно
16 июня 14:02Платёж получен · PAY-88231Точкауспешно
16 июня 14:01Заказ создан · ORD-10519AI-агентуспешно
16 июня 14:00Checkout оформлен · CHK-77120AI-агентуспешно
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Карточка клиента.dc.html b/design/internal/baseline/Продажи · Карточка клиента.dc.html new file mode 100644 index 0000000..2f4851c --- /dev/null +++ b/design/internal/baseline/Продажи · Карточка клиента.dc.html @@ -0,0 +1,276 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продажи/Клиенты/Елена Кузнецова
+
+ OWNER + +
+
ИП
+
+
+ +
+
+ + +
+
+ + +
+
+
ЕК
+
+
+

Елена Кузнецова

+ Согласие получено + CUS-4702 +
+
+ e.kuznetsova@corp.ru + +7 ··· ·· 88 + MAXWeb +
+
+ +
+
+ + +
+ + + + + + +
+ + + +
+
+ +
+
Заказы
3
+
Сумма покупок
₽12 470
+
Диалоги
4
+
Первый контакт
28 мая 2026
+
+ +
+

Потребность и заметка

+
Команда дизайна, 5 рабочих мест. Пользуется Foxray Team, рассматривает FirePage для лендингов. Платит по счёту, ЛПР — сама Елена.
+
+ +
+

Последняя активность

+
+
Создан заказ ORD-10512 · Foxray Team
сегодня 11:05
+
Оплата ₽6 500 · ORD-10455
02 июня 18:22
+
Диалог закрыт · продажа Foxray Pro
02 июня 14:00
+
Первый контакт · канал MAX
28 мая 2026
+
+
+
+ +
+ +
+

Текущий диалог

Оператор
+
Foxray · Web Chat · ведёт Иван Петров.
«Помогу с настройкой рабочих мест.»
+ Открыть диалог +
+ +
+

Связанные продукты

+
FoxrayTeam · активна
+
FirePagePro · разовая
+
+
+
+
+ + + + + + + + +
+ + + + + + + + + + + + + + +
ЗАКАЗДАТАПРОДУКТ / OFFERСУММАОПЛАТАИСПОЛНЕНИЕ
ORD-10512сегодня 11:05Foxray · Team₽4 980ОплаченИсполнен
ORD-1045502 июняFoxray · Pro₽6 500ОплаченИсполнен
ORD-1039028 маяFirePage · Pro₽990ОплаченИсполнен
+
+
+ + + +
+
MAX
@elena.kuz
основной · подтверждён
+
Web Chat
session 9f2a···
активна
+
Email
e.kuznetsova@corp.ru
подтверждён
+
Телефон
+7 ··· ·· 88 · скрыт
не подтверждён
+
+
+ + + +
+

История согласий

+
Согласие на обработку данных · получено
Канал MAX · подтверждено пользователем
04 июня 2026, 14:20
+
Согласие на маркетинговые сообщения · получено
Канал MAX
04 июня 2026, 14:20
+
Первичный контакт · согласие ещё не получено
Канал MAX · обработка по законному интересу
28 мая 2026
+
+
+ + + +
+ + + + + + + + + + + + + + +
ВРЕМЯДЕЙСТВИЕОБЪЕКТАКТОРРЕЗУЛЬТАТ
сегодня 11:05Создан заказORD-10512AI-агентуспешно
02 июня 18:22Объединение контактовCUS-4702 ← CUS-4810Иван Петровуспешно
02 июня 14:00Изменены контактные данныеemailИван Петровуспешно
28 мая 2026Контакт созданCUS-4702системауспешно
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Клиенты.dc.html b/design/internal/baseline/Продажи · Клиенты.dc.html new file mode 100644 index 0000000..5add358 --- /dev/null +++ b/design/internal/baseline/Продажи · Клиенты.dc.html @@ -0,0 +1,334 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продажи/Клиенты
+
+ OWNER + +
+
ИП
+
+
+ +
+
+ + +
+
+

Клиенты

+

Контакты отдела продаж · показано {{ shownCount }} из 248

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+ + + +
+
+
+ + +
+ + +
+ + + +
+
+
+ + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
КЛИЕНТКОНТАКТЫКАНАЛЫПРОДУКТЫПОСЛ. ДИАЛОГ {{ arrLast }}ОТКР. {{ arrOpen }}ЗАКАЗЫ {{ arrOrders }}СУММА ПОКУПОК {{ arrTotal }}
+
+
{{ r.initials }}
+
+ {{ r.name }} +
{{ r.cid }}
+
+
+
+
{{ r.email }}
+
{{ r.phone }}
+
+
+ + {{ ch.label }} + +
+
+
+ + {{ pr.name }} + +
+
+
{{ r.lastLabel }}
+
{{ r.openD }}{{ r.orders }}{{ r.total }} + + + + +
+
+ + + +
+
+
Клиенты не найдены
+
Измените условия фильтра или сбросьте их.
+
+
+ + +
+
Показано {{ shownCount }} из 248
+
+ + + + + … + + +
+ 10 / стр. +
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Обзор.dc.html b/design/internal/baseline/Продажи · Обзор.dc.html new file mode 100644 index 0000000..c99f4bc --- /dev/null +++ b/design/internal/baseline/Продажи · Обзор.dc.html @@ -0,0 +1,371 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
+ Edevs/ + Продажи/ + Обзор +
+
+ OWNER + +
+
ИП
+
+
+ +
+
+ + +
+
+

Обзор отдела продаж

+

Операционное состояние и результаты · обновлено только что

+
+
+ + + +
+
+ + +
ОПЕРАЦИОННЫЕ · СЕЙЧАС
+
+ +
+
{{ k.label }}
+
{{ k.value }}
+
{{ k.sub }}
+
+
+
+ + +
РЕЗУЛЬТАТ · {{ periodUpper }}
+
+ +
+
{{ k.label }}
+
{{ k.value }}
+
+ + + {{ k.deltaText }} + + {{ k.sub }} +
+
+
+
+ + +
+
+
+

Динамика продаж

+
+ {{ chartTotal }} + {{ chartDelta }} + чистая выручка · {{ periodLabel }} +
+
+
Выручка
+
+
+ + + + + + + + + +
+ + {{ x.label }} + +
+
+
+ + +
+ + +
+
+

Продукты

+ Все продукты +
+ + + + + + + + + + + + + + + + + + + + + +
ПРОДУКТСТАТУСПРОДАЖИВЫРУЧКАКОНВ.ДИАЛОГИ
{{ p.name }}{{ p.status }}{{ p.sales }}{{ p.rev }}{{ p.conv }}{{ p.dlg }}
+
+ + +
+

Каналы

+ +
+
+ {{ c.name }} + {{ c.dlg }} диал. · {{ c.sales }} продаж +
+
+
+
+
+ + +
+

AI и операторы

+
+
+
AI-агент
+
+
Диалоги
{{ aiDlg }}
+
Продажи
{{ aiSales }}
+
Конверсия
{{ aiConv }}
+
Стоимость
{{ aiCost }}
+
+
+
+
Операторы
+
+
Диалоги
{{ opDlg }}
+
Продажи
{{ opSales }}
+
Конверсия
{{ opConv }}
+
Передано AI→
{{ handoff }}
+
+
+
+
+ + +
+
+ +

Проблемные диалоги

+
+ + + + {{ p.title }}{{ p.meta }} + {{ p.time }} + + +
+ + +
+
+ +

Платежи и fulfillment

+
+ + + + {{ p.title }}{{ p.meta }} + {{ p.time }} + + +
+ +
+
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продажи · Продажи.dc.html b/design/internal/baseline/Продажи · Продажи.dc.html new file mode 100644 index 0000000..4cb4303 --- /dev/null +++ b/design/internal/baseline/Продажи · Продажи.dc.html @@ -0,0 +1,364 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продажи/Продажи
+
+ OWNER + +
+
ИП
+
+
+ +
+
+ + +
+
+

Продажи

+

Заказы, платежи, возвраты, подписки и исполнение · основной объект — заказ

+
+
+
+ + +
+ +
+
+ + +
+ + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ЗАКАЗДАТАКЛИЕНТПРОДУКТ / OFFERСУММАОПЛАТАИСПОЛНЕНИЕИСТОЧНИКПРОДАВЕЦ
{{ r.id }}{{ r.date }}{{ r.client }}{{ r.offer }}{{ r.amount }}{{ r.payLabel }}{{ r.fulLabel }}{{ r.srcLabel }}{{ r.seller }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПОДПИСКАКЛИЕНТOFFERСТАТУСПЕРИОДСЛЕД. СПИСАНИЕENTITLEMENT
{{ r.id }}{{ r.client }}{{ r.offer }}{{ r.stLabel }}{{ r.period }}{{ r.next }}{{ r.ent }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПЛАТЁЖЗАКАЗПРОВАЙДЕРСУММАСТАТУСМЕТОДДАТАСВЕРКА
{{ r.id }}{{ r.order }}{{ r.provider }}{{ r.amount }}{{ r.stLabel }}{{ r.method }}{{ r.date }}{{ r.rec }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ВОЗВРАТЗАКАЗ / ПЛАТЁЖСУММАПРИЧИНАСТАТУСИНИЦИАТОРДАТА
{{ r.id }}{{ r.ref }}{{ r.amount }}{{ r.reason }}{{ r.stLabel }}{{ r.actor }}{{ r.date }}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ЗАКАЗПРОДУКТОПЕРАЦИЯСТАТУСПОПЫТКИПОСЛ. ОШИБКАОБНОВЛЕНОДЕЙСТВИЕ
{{ r.order }}{{ r.product }}{{ r.op }}{{ r.stLabel }}{{ r.attempts }}{{ r.error }}{{ r.updated }} + + + + + — + +
+
+ + + +
+
+
Ничего не найдено
+
Измените поисковый запрос.
+
+
+
+ + +
+
Показано {{ shownCount }} из {{ totalCount }}
+
+ + + + +
+ 20 / стр. +
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Продукты.dc.html b/design/internal/baseline/Продукты.dc.html new file mode 100644 index 0000000..b8213c9 --- /dev/null +++ b/design/internal/baseline/Продукты.dc.html @@ -0,0 +1,213 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Продукты
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+ + +
+
+

Продукты

+

2 активных продукта · цены и Offer управляются в карточке продукта

+
+
+
+ + + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ПРОДУКТСТАТУСOFFER И ЦЕНЫSALES-AGENTКАНАЛЫFULFILLMENTПРОДАЖИ · {{ periodUpper }}
+
+
+
{{ r.name }}
{{ r.sub }}
+
+
Активен + +
+ {{ o.name }} · {{ o.type }} + {{ o.price }} +
+
+ +
{{ r.note }}
+
+
AI-агент
{{ r.agentRelease }}
+
+ + {{ c.label }} + +
+
Подключён
{{ r.fulfillment }}
{{ r.salesSum }}
{{ r.salesN }} продаж
+ + + + +
+
+
+
2 продукта
+
Активная цена не редактируется задним числом — создаётся новая версия
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Профиль.dc.html b/design/internal/baseline/Профиль.dc.html new file mode 100644 index 0000000..410a403 --- /dev/null +++ b/design/internal/baseline/Профиль.dc.html @@ -0,0 +1,153 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Профиль
+
+
ИП
+
+
+ +
+
+ + +
+
+
ИП
+
+

Иван Петров

OWNER
+
ivan@edevs.tech
+
+ +
+
+ +
+ +
+

Личные данные

+
+
+
+
+
+
+
+ + +
+

Смена пароля

+
+
+
+
+
+
+
+ + +
+
+

Двухфакторная аутентификация (TOTP)

{{ totpNote }}
+
+
+ +
Приложение-аутентификатор подключено · последний код принят 5 мин назад
+
+
+ + +
+

Активные сессии

+ +
+ {{ s.icon }} +
+
{{ s.device }}текущая
+
{{ s.meta }}
+
+ — + +
+
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Сотрудники.dc.html b/design/internal/baseline/Сотрудники.dc.html new file mode 100644 index 0000000..47bd3ff --- /dev/null +++ b/design/internal/baseline/Сотрудники.dc.html @@ -0,0 +1,223 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/Сотрудники
+
+ +
+
ИП
+
+
+ +
+
+ + +
+
+ + +
+
+

Сотрудники

+

Доступ к Hub · показано {{ shownCount }} из 6

+
+ +
+ + +
+
+ + +
+
Роль +
+ + + +
+
+
Статус +
+ + + + +
+
+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
СОТРУДНИКРОЛЬОТДЕЛСТАТУСПОСЛЕДНИЙ ВХОДДИАЛОГИ
+
+
{{ r.initials }}
+
{{ r.name }}
{{ r.email }}
+
+
{{ r.role }}{{ r.dept }}{{ r.stLabel }}{{ r.lastLogin }}{{ r.dialogs }} + + + + +
+
+ + +
+
+
Сотрудники не найдены
+
Измените условия фильтра.
+
+
+ +
+
Показано {{ shownCount }} из 6
+
Сессии и пароли управляются в карточке сотрудника
+
+
+ +
+
+
+
+
+ + + diff --git a/design/internal/baseline/Тестовый чат.dc.html b/design/internal/baseline/Тестовый чат.dc.html new file mode 100644 index 0000000..f8a45de --- /dev/null +++ b/design/internal/baseline/Тестовый чат.dc.html @@ -0,0 +1,218 @@ + + + + + + + + + + + + + +
+ + + + + +
+
+
Edevs/AI/Тестовый чат
+
+ Тестовая песочница +
+
ИП
+
+
+ + +
+ + +
+
+
RELEASE CANDIDATE
+
+ + +
+
+ + FirePage Sales · gpt-4o-mini · temp 0.4 +
+ +
КАНАЛ СИМУЛЯЦИИ
+
+ + + +
+ +
ГОТОВЫЕ СЦЕНАРИИ
+
+ + + +
+
+
+ +
+
+ + +
+
+
+
+
Test conversation
Симуляция клиента · сообщений: 4
+
+ Не создаёт Contact, Order или Payment +
+ +
+
+
Сценарий: «Покупка коробки» · канал {{ channelLabel }}
+ + +
Т
Здравствуйте, интересует FirePage для сайта-визитки. Сколько стоит?
тестовый клиент
+ + +
AI · REL-FP-v5
Здравствуйте! FirePage — это готовый нишевой сайт под ключ. Коробка стоит ₽4 900 единоразово. Дополнительно можно подключить годовую поддержку за ₽1 470. Под визитку отлично подходит — оформить?
debug ниже →
+ + +
Т
Да, давайте оформим коробку.
+ + +
Отлично! Подготовил оформление коробки FirePage за ₽4 900. Вызываю checkout — в реальном диалоге клиент получил бы ссылку на оплату.
tool: create_checkout
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+

Диагностика

Последний ответ AI
+
+ + +
+
Tokens
1 284
in 940 · out 344
+
Cost
₽0,42
latency 3,1с
+
+ + +
ИСПОЛЬЗОВАННЫЕ CHUNKS
+
+ +
+
{{ c.source }}{{ c.score }}
+
{{ c.text }}
+
+
+
+ + +
ВЫЗВАННЫЕ TOOLS
+
+
get_offers
product=firepage → 2 offers
120мс
+
create_checkout
offer=OFR-FP-BOX · ₽4 900 (симуляция)
88мс
+
+ + +
HANDOFF
+
Передачи оператору не было — сценарий в рамках агента.
+ +
+
+ +
+
+
+
+ + + diff --git a/design/internal/screens/.gitkeep b/design/internal/screens/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/design/web-chat/.gitkeep b/design/web-chat/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..0230ed9 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@edevs/hub-workspace", + "private": true, + "version": "0.1.0", + "packageManager": "npm@10.0.0", + "workspaces": [ + "apps/internal-ui", + "apps/checkout", + "apps/web-chat", + "packages/ui", + "packages/contracts", + "packages/shared" + ], + "scripts": { + "dev:internal-ui": "npm --workspace @edevs/internal-ui run dev", + "dev:checkout": "npm --workspace @edevs/checkout run dev", + "dev:web-chat": "npm --workspace @edevs/web-chat run dev", + "build": "npm run build --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "test": "npm run test --workspaces --if-present" + }, + "devDependencies": { + "@playwright/test": "^1.54.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vitest": "^3.2.0" + } +} diff --git a/packages/contracts/.gitkeep b/packages/contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..1c84c4a --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@edevs/contracts", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..3e4f3fb --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,13 @@ +export interface HealthResponse { + status: "ok" | "degraded"; + service?: string; + checks?: Record; +} + +export interface DepartmentSummary { + departmentId: string; + generatedAt: string; + attentionStatus: "NORMAL" | "ATTENTION" | "CRITICAL"; + summaryText: string; + workspaceRoute: string; +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/shared/.gitkeep b/packages/shared/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..b4d479b --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "@edevs/shared", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..42fdc97 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,5 @@ +export type AttentionStatus = "NORMAL" | "ATTENTION" | "CRITICAL"; + +export type HubApplication = "internal-ui" | "checkout" | "web-chat"; + +export const defaultApiBaseUrl = "http://localhost:8000/api/v1"; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/ui/.gitkeep b/packages/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..fdee80c --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,16 @@ +{ + "name": "@edevs/ui", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { + "antd": "^5.26.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..13689ef --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1 @@ +export { edevsHubTheme } from "./theme"; diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts new file mode 100644 index 0000000..53ef034 --- /dev/null +++ b/packages/ui/src/theme.ts @@ -0,0 +1,27 @@ +import type { ThemeConfig } from "antd"; + +export const edevsHubTheme: ThemeConfig = { + token: { + colorPrimary: "#2563eb", + colorSuccess: "#059669", + colorWarning: "#d97706", + colorError: "#dc2626", + colorInfo: "#2563eb", + borderRadius: 8, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + }, + components: { + Layout: { + headerBg: "#ffffff", + siderBg: "#ffffff", + bodyBg: "#f6f8fb", + }, + Card: { + borderRadiusLG: 8, + }, + Table: { + headerBg: "#f8fafc", + }, + }, +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..fb09d48 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + fullyParallel: true, + reporter: [["list"], ["html", { open: "never" }]], + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "internal-ui", + use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5173" }, + }, + { + name: "checkout", + use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5174" }, + }, + { + name: "web-chat-mobile", + use: { ...devices["Pixel 7"], baseURL: "http://localhost:5175" }, + }, + ], +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b90285c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["apps/backend"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "DJ"] +ignore = ["DJ001"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "hub_backend.settings" +pythonpath = ["apps/backend"] +testpaths = ["apps/backend"] +python_files = ["test_*.py", "*_tests.py"] + +[tool.mypy] +python_version = "3.12" +plugins = ["mypy_django_plugin.main"] +ignore_missing_imports = true + +[tool.django-stubs] +django_settings_module = "hub_backend.settings" diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check.ps1 b/scripts/check.ps1 new file mode 100644 index 0000000..516cee0 --- /dev/null +++ b/scripts/check.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" + +docker compose run --rm backend python manage.py test +docker compose run --rm internal-ui npm run typecheck +docker compose run --rm checkout npm run typecheck +docker compose run --rm web-chat npm run typecheck diff --git a/scripts/start.ps1 b/scripts/start.ps1 new file mode 100644 index 0000000..26b93c0 --- /dev/null +++ b/scripts/start.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +if (-not (Test-Path ".env")) { + Copy-Item ".env.example" ".env" +} + +docker compose up --build diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/apps.spec.ts b/tests/e2e/apps.spec.ts new file mode 100644 index 0000000..b76ee93 --- /dev/null +++ b/tests/e2e/apps.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from "@playwright/test"; + +test("application shell responds", async ({ page, baseURL }) => { + await page.goto(baseURL ?? "/"); + await expect(page.locator("body")).toBeVisible(); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..dbec643 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + } +}