Compare commits
8 Commits
297eff949e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7850a0296 | |||
| ec3b0e26fd | |||
| 19d1a56df4 | |||
| 3934bdf814 | |||
| dbd44ecc20 | |||
| 93687a32fe | |||
| 2d9fec3a6d | |||
| a6f4ca88a4 |
@@ -0,0 +1,83 @@
|
||||
# Lizenzregeln der Control-Pipeline
|
||||
|
||||
> **Stand:** 2026-05-21 — Mapping festgezurrt nach DB-Inspektion und IACE-Audit.
|
||||
>
|
||||
> Die Pipeline klassifiziert jede Regulation (und damit jedes daraus extrahierte
|
||||
> Chunk und jeden atomic_control) in eine von **drei Lizenzregeln**. Die Regel
|
||||
> entscheidet, ob der Volltext aufbewahrt werden darf und welche Attribution im
|
||||
> Ausgabe-Renderer Pflicht ist.
|
||||
|
||||
## Die drei Regeln
|
||||
|
||||
| Regel | Bedeutung | Volltext speichern? | Attribution Pflicht? | Beispiele |
|
||||
|-------|-----------|---------------------|----------------------|-----------|
|
||||
| **1** | Wörtlich — Hoheitsrecht / Public Domain | ✓ | nein (empfohlen für Audit) | EU-Recht (EUR-Lex), Bundesrecht, Satzungsrecht (DGUV UVV), TRBS, TRGS, ASR, US Federal Code (OSHA), NIST SP, EU-Leitfäden |
|
||||
| **2** | Wörtlich mit Attribution — freie Lizenzen | ✓ | **ja** | OWASP (CC-BY-SA-4.0), OECD AI Principles (OECD_PUBLIC), ENISA-Dokumente (CC-BY-4.0), Apache-2.0 Werke |
|
||||
| **3** | Nur zitieren — proprietäre Standards | ✗ | nicht anwendbar (kein Volltext) | DIN, EN, ISO, ANSI, UL, IEC, IEEE, DGUV Regeln/Informationen/Grundsätze, Bitkom-Leitfäden, BSI-Bausteine (urheberrechtlich) |
|
||||
|
||||
**Wichtige Klarstellung:** Regel 3 = "nur Identifier/Abschnitt zitieren", **nicht** "umformulieren". Die ursprüngliche Bezeichnung "neu formulieren" war irreführend. Korrekt: Bei Regel-3-Quellen darf die Pipeline den Volltext nicht speichern; sie bewahrt nur die Quellenreferenz (regulation_id + article/paragraph), und der Output-Renderer zeigt diese Referenz im Frontend/PDF.
|
||||
|
||||
## Mapping `license_type` → `license_rule`
|
||||
|
||||
| license_type | license_rule | Erklärung |
|
||||
|---|---|---|
|
||||
| `EU_LAW`, `EU_PUBLIC` | 1 | EU-Verordnungen, Richtlinien, OJ-Veröffentlichungen, EU-Leitfäden |
|
||||
| `DE_LAW`, `DE_PUBLIC` | 1 | Bundesgesetze, TRBS, TRGS, ASR, DGUV-UVV (Satzungsrecht) |
|
||||
| `AT_LAW`, `CH_LAW`, `FR_LAW`, `IT_LAW`, `ES_LAW`, `NL_LAW`, `HU_LAW` | 1 | Andere EU-Mitgliedsstaaten-Recht |
|
||||
| `US_GOV_PUBLIC`, `NIST_PUBLIC_DOMAIN`, `OSHA_PUBLIC` | 1 | US Federal Code (17 U.S.C. §105 Public Domain) |
|
||||
| `CC-BY-4.0`, `CC-BY-SA-4.0`, `CC-BY-3.0`, `CC-BY-SA-3.0` | 2 | Creative-Commons mit Attribution-Pflicht |
|
||||
| `Apache-2.0`, `MIT` | 2 | Permissive OSS-Lizenzen, NOTICE-Pflicht |
|
||||
| `OECD_PUBLIC`, `ENISA_CC_BY_4.0` | 2 | Behörden-Publikationen mit Attribution-Auflage |
|
||||
| `DIN_COPYRIGHT`, `ISO_COPYRIGHT`, `ANSI_COPYRIGHT`, `UL_COPYRIGHT`, `IEC_COPYRIGHT` | 3 | Normungsorganisationen — nur Identifier-Zitat |
|
||||
| `DGUV_COPYRIGHT` | 3 | DGUV Regeln/Informationen/Grundsätze (nicht UVV) |
|
||||
| `BITKOM_COPYRIGHT`, `BSI_COPYRIGHT`, `VDMA_COPYRIGHT` | 3 | Verbands-/Behörden-Publikationen mit eigenständigem Urheberrecht |
|
||||
| `OWN_WORK` | 3 | BreakPilot-Eigentexte (Templates, eigene Patterns) — kein externes Lizenzrisiko, aber auch kein Public-Domain-Status |
|
||||
|
||||
**Sonderfall DGUV:** Die Klasse trennt sich nach Publikationstyp:
|
||||
- DGUV **Vorschriften / UVV** → `DE_LAW` → Regel 1
|
||||
- DGUV **Regeln, Informationen, Grundsätze** → `DGUV_COPYRIGHT` → Regel 3
|
||||
|
||||
## Auswirkung pro Pipeline-Stage
|
||||
|
||||
| Stage | Verhalten bei Regel 1 | Regel 2 | Regel 3 |
|
||||
|---|---|---|---|
|
||||
| Stage 6 ControlCompose (`pipeline_adapter.py:147`) | speichert `chunk_text` | speichert `chunk_text` | speichert `chunk_text = None` |
|
||||
| Atomic-Control-Bildung | Volltext als Quelle | Volltext + Attribution-Vermerk | nur regulation_id + article |
|
||||
| Output-Renderer (Frontend/PDF) | optionaler Quellen-Hinweis | **Pflicht-Attribution in Footer + Inline** | nur Identifier rendern |
|
||||
| Tech-File-Anhang | Quelle nennen | Quelle + Lizenz-URL | Identifier-Liste |
|
||||
|
||||
## Quellen ohne Klassifikation
|
||||
|
||||
Aktuell sind in `regulation_registry` **232 Regulationen** klassifiziert (Stand 2026-05-21). Die folgenden müssen noch ergänzt werden (Task #20 deckt den DGUV-Ingest):
|
||||
|
||||
| Quelle | Regel | Begründung |
|
||||
|---|---|---|
|
||||
| TRBS-Familie (24 PDFs im RAG) | 1 | Technische Regeln Betriebssicherheit — BAuA Bundesarbeitsblatt |
|
||||
| TRGS-Familie (alle Volltext-Chunks) | 1 | Technische Regeln Gefahrstoffe — BAuA |
|
||||
| ASR-Familie (17 PDFs) | 1 | Arbeitsstättenregeln — BAuA |
|
||||
| OSHA 29 CFR 1910 Subpart O + Technical Manual | 1 | US Federal Public Domain (17 U.S.C. §105) |
|
||||
| DGUV Vorschrift 1 + UVV-Familie (sobald ingest) | 1 | Satzungsrecht der BG |
|
||||
| DGUV Regel 100-500 + Information 209-072/074/073 | 3 | DGUV-Copyright, nur Identifier |
|
||||
| DIN-Identifier-Tabelle (ohne Volltext) | 3 | DIN-Beuth-Copyright |
|
||||
| ANSI B11.0 + RIA R15.06 + UL 508A Identifier | 3 | ANSI/UL-Copyright |
|
||||
| ISO 12100/13849/13857 Identifier | 3 | ISO-Copyright |
|
||||
|
||||
## Audit-Pflicht
|
||||
|
||||
Vor jedem Ingest neuer Quellen:
|
||||
1. Lizenz prüfen (publikationen.dguv.de, EUR-Lex, etc.)
|
||||
2. license_type aus obiger Tabelle wählen — wenn nicht vorhanden, hier ergänzen
|
||||
3. license_rule wird daraus deterministisch abgeleitet
|
||||
4. Attribution-Text bei Regel 2 ist Pflichtfeld
|
||||
|
||||
Vor jedem Output:
|
||||
- Wenn ein atomic_control aus einer Regel-3-Quelle stammt: prüfen dass NUR Identifier gezeigt wird, niemals Volltext
|
||||
- Wenn aus Regel-2-Quelle: Attribution muss im PDF-Footer und im Frontend-Tooltip vorhanden sein
|
||||
- Wenn aus Regel-1-Quelle: empfohlen Quelle nennen für Auditierbarkeit
|
||||
|
||||
## Verweise
|
||||
|
||||
- Schema: `migrations/002_regulation_registry.sql`
|
||||
- Code: `services/regulation_registry.py`, `services/pipeline_adapter.py`
|
||||
- Seed-Script: `scripts/f1_migrate_regulation_registry.py`
|
||||
- Tests: `tests/test_regulation_registry.py` (assert: rule IN (1,2,3))
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit script for license classification gaps in the control pipeline.
|
||||
|
||||
Reports:
|
||||
|
||||
1. **regulation_registry coverage** — how many regulations are classified, by
|
||||
rule and license_type.
|
||||
2. **atomic_controls without license_rule** — how many controls reference a
|
||||
regulation_id that has no entry (or no license_rule) in the registry.
|
||||
3. **Qdrant payload consistency** — for each indexed collection, how many
|
||||
chunks carry both ``license`` and ``license_rule`` payload fields.
|
||||
|
||||
The goal is to surface every record where the engine could in principle
|
||||
extract or emit content but the license rule is unknown — those records are
|
||||
the highest-risk material in a license audit.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/audit_license_classification.py --db-host 100.80.114.48
|
||||
|
||||
Add ``--check-qdrant`` to also probe ``http://<host>:6333`` collections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib import request as urllib_request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
DEFAULT_HOST = "100.80.114.48"
|
||||
DEFAULT_PORT = 5432
|
||||
DEFAULT_USER = "breakpilot"
|
||||
DEFAULT_DB = "breakpilot_db"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default=DEFAULT_HOST)
|
||||
p.add_argument("--db-port", type=int, default=DEFAULT_PORT)
|
||||
p.add_argument("--db-user", default=DEFAULT_USER)
|
||||
p.add_argument("--db-name", default=DEFAULT_DB)
|
||||
p.add_argument("--db-password", default="")
|
||||
p.add_argument("--check-qdrant", action="store_true")
|
||||
p.add_argument("--qdrant-host", default="100.80.114.48")
|
||||
p.add_argument("--qdrant-port", type=int, default=6333)
|
||||
p.add_argument("--json", action="store_true", help="Emit JSON result on stdout")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def audit_registry(conn) -> dict:
|
||||
"""Coverage of regulation_registry."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT license_rule, license_type, COUNT(*) "
|
||||
"FROM regulation_registry GROUP BY license_rule, license_type "
|
||||
"ORDER BY license_rule, license_type;"
|
||||
)
|
||||
by_rule_and_type: list[tuple] = []
|
||||
by_rule: Counter = Counter()
|
||||
for rule, ltype, count in cur.fetchall():
|
||||
by_rule_and_type.append((rule, ltype or "(empty)", count))
|
||||
by_rule[rule] += count
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM regulation_registry "
|
||||
"WHERE license_type IS NULL OR license_type = '';"
|
||||
)
|
||||
missing_type = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM regulation_registry;")
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_rule": dict(by_rule),
|
||||
"by_rule_and_type": by_rule_and_type,
|
||||
"missing_license_type": missing_type,
|
||||
}
|
||||
|
||||
|
||||
def audit_atomic_controls(conn) -> dict:
|
||||
"""Controls whose source regulation has no license rule.
|
||||
|
||||
Important: the schema differs between core (bp-core) and customer
|
||||
deployments. We probe a handful of likely column names and skip if
|
||||
none are found.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
# Detect controls table
|
||||
cur.execute(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema='compliance' AND table_name IN "
|
||||
"('atomic_controls','atomic_controls_dedup','canonical_controls');"
|
||||
)
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
if not tables:
|
||||
return {"skipped": True, "reason": "no controls table found"}
|
||||
|
||||
result: dict = {"tables": {}}
|
||||
for tbl in tables:
|
||||
cur.execute(
|
||||
f"SELECT column_name FROM information_schema.columns "
|
||||
f"WHERE table_schema='compliance' AND table_name='{tbl}';"
|
||||
)
|
||||
cols = {r[0] for r in cur.fetchall()}
|
||||
if "license_rule" not in cols:
|
||||
result["tables"][tbl] = {"skipped": True, "reason": "no license_rule column"}
|
||||
continue
|
||||
cur.execute(f"SELECT COUNT(*) FROM compliance.{tbl};")
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
f"SELECT license_rule, COUNT(*) FROM compliance.{tbl} "
|
||||
f"GROUP BY license_rule ORDER BY license_rule;"
|
||||
)
|
||||
by_rule = {str(r[0]): r[1] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM compliance.{tbl} WHERE license_rule IS NULL;"
|
||||
)
|
||||
missing = cur.fetchone()[0]
|
||||
result["tables"][tbl] = {
|
||||
"total": total,
|
||||
"by_rule": by_rule,
|
||||
"missing_license_rule": missing,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def audit_qdrant(host: str, port: int) -> dict:
|
||||
"""Probe Qdrant collections for license + license_rule payload coverage.
|
||||
|
||||
Samples 500 points per collection and reports how many have neither
|
||||
field populated.
|
||||
"""
|
||||
out: dict = {"collections": {}}
|
||||
base = f"http://{host}:{port}"
|
||||
try:
|
||||
with urllib_request.urlopen(f"{base}/collections", timeout=10) as r:
|
||||
colls = json.loads(r.read()).get("result", {}).get("collections", [])
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
for c in colls:
|
||||
name = c["name"]
|
||||
if "compliance" not in name and "atomic_controls" not in name:
|
||||
continue
|
||||
payload = {"limit": 500, "with_payload": True, "with_vector": False}
|
||||
req = urllib_request.Request(
|
||||
f"{base}/collections/{name}/points/scroll",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=15) as r:
|
||||
points = json.loads(r.read()).get("result", {}).get("points", [])
|
||||
except Exception as e:
|
||||
out["collections"][name] = {"error": str(e)}
|
||||
continue
|
||||
sampled = len(points)
|
||||
both_set = 0
|
||||
only_license = 0
|
||||
only_rule = 0
|
||||
neither = 0
|
||||
for p in points:
|
||||
pl = p.get("payload", {}) or {}
|
||||
has_lic = bool(pl.get("license"))
|
||||
has_rule = pl.get("license_rule") is not None
|
||||
if has_lic and has_rule:
|
||||
both_set += 1
|
||||
elif has_lic:
|
||||
only_license += 1
|
||||
elif has_rule:
|
||||
only_rule += 1
|
||||
else:
|
||||
neither += 1
|
||||
out["collections"][name] = {
|
||||
"sampled": sampled,
|
||||
"both_set": both_set,
|
||||
"only_license_field": only_license,
|
||||
"only_license_rule_field": only_rule,
|
||||
"neither_set": neither,
|
||||
"neither_pct": round(neither / sampled * 100, 1) if sampled else 0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed (pip install psycopg2-binary)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
user=args.db_user,
|
||||
dbname=args.db_name,
|
||||
password=args.db_password or None,
|
||||
)
|
||||
try:
|
||||
registry = audit_registry(conn)
|
||||
controls = audit_atomic_controls(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
qdrant: Optional[dict] = None
|
||||
if args.check_qdrant:
|
||||
qdrant = audit_qdrant(args.qdrant_host, args.qdrant_port)
|
||||
|
||||
result = {"registry": registry, "atomic_controls": controls, "qdrant": qdrant}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
print("=" * 60)
|
||||
print(" Audit — License Classification")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f"## regulation_registry ({registry['total']} rows)")
|
||||
print(f" By rule: {registry['by_rule']}")
|
||||
print(f" Missing license_type: {registry['missing_license_type']}")
|
||||
print()
|
||||
print("## atomic_controls")
|
||||
for tbl, info in controls.get("tables", {}).items():
|
||||
if info.get("skipped"):
|
||||
print(f" {tbl}: SKIPPED ({info['reason']})")
|
||||
continue
|
||||
print(f" {tbl}: {info['total']} rows")
|
||||
print(f" by_rule={info['by_rule']}")
|
||||
print(f" missing_license_rule={info['missing_license_rule']}")
|
||||
print()
|
||||
if qdrant:
|
||||
print("## qdrant")
|
||||
for name, info in qdrant.get("collections", {}).items():
|
||||
if "error" in info:
|
||||
print(f" {name}: ERROR {info['error']}")
|
||||
continue
|
||||
print(
|
||||
f" {name:30} sampled={info['sampled']:4} "
|
||||
f"both={info['both_set']:4} "
|
||||
f"neither={info['neither_set']:4} ({info['neither_pct']}%)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill license_rule on canonical_controls by inheriting from parent.
|
||||
|
||||
Background
|
||||
==========
|
||||
|
||||
Audit (audit_license_classification.py) showed that 279,384 of 314,811 rows
|
||||
in compliance.canonical_controls have NULL license_rule. Drilling in:
|
||||
|
||||
- 261,980 of those (94%) have a parent_control_uuid whose parent already
|
||||
carries a non-NULL license_rule. The pass0b decomposition pipeline did
|
||||
not propagate the rule to its child controls — this is a clear inheritance
|
||||
bug, fixable without any classification decisions.
|
||||
- 16,617 have a parent that itself has no license_rule (transitive case).
|
||||
Inheriting iteratively converges to either rule-set or root-orphan.
|
||||
- 787 have no parent at all (decomposition roots). These need cluster-based
|
||||
manual classification (see Strategy Notes at the bottom of this file).
|
||||
|
||||
This script runs the inheritance fix in three idempotent stages and
|
||||
prints per-stage counts before any write happens.
|
||||
|
||||
Usage::
|
||||
|
||||
# Always dry-run first:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --dry-run
|
||||
|
||||
# If counts look right:
|
||||
python3 scripts/backfill_license_rule.py --db-host 100.80.114.48 \\
|
||||
--db-password breakpilot123 --apply
|
||||
|
||||
The script is safe to rerun — it only touches rows where license_rule
|
||||
IS NULL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--db-host", default="100.80.114.48")
|
||||
p.add_argument("--db-port", type=int, default=5432)
|
||||
p.add_argument("--db-user", default="breakpilot")
|
||||
p.add_argument("--db-name", default="breakpilot_db")
|
||||
p.add_argument("--db-password", required=True)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
p.add_argument("--max-iterations", type=int, default=5,
|
||||
help="Cap on inheritance iterations to avoid loops")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
# Stage 1: direct parent has license_rule — single UPDATE.
|
||||
# Stage 2: iterative — parent did not have it, but a grandparent does.
|
||||
# We loop until no more rows can be filled or max-iterations.
|
||||
# Stage 3: residual rows with no resolvable parent. Report them clustered
|
||||
# by category/pattern_id so the user can classify by family.
|
||||
|
||||
SQL_REPORT_NULLS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
CASE WHEN cc.parent_control_uuid IS NULL THEN 'no_parent'
|
||||
WHEN p.license_rule IS NULL THEN 'parent_null'
|
||||
ELSE 'parent_set' END AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls cc
|
||||
LEFT JOIN canonical_controls p ON cc.parent_control_uuid = p.id
|
||||
WHERE cc.license_rule IS NULL
|
||||
GROUP BY 1 ORDER BY 2 DESC;
|
||||
"""
|
||||
|
||||
SQL_INHERIT_FROM_PARENT = """
|
||||
SET search_path TO compliance, public;
|
||||
UPDATE canonical_controls cc
|
||||
SET license_rule = p.license_rule, updated_at = NOW()
|
||||
FROM canonical_controls p
|
||||
WHERE cc.parent_control_uuid = p.id
|
||||
AND cc.license_rule IS NULL
|
||||
AND p.license_rule IS NOT NULL;
|
||||
"""
|
||||
|
||||
SQL_REPORT_ORPHAN_CLUSTERS = """
|
||||
SET search_path TO compliance, public;
|
||||
SELECT
|
||||
COALESCE(category, '(null)') AS category,
|
||||
COALESCE(pattern_id, '(null)') AS pattern_id,
|
||||
COALESCE(generation_strategy, '(null)') AS gen,
|
||||
COUNT(*) AS n
|
||||
FROM canonical_controls
|
||||
WHERE license_rule IS NULL AND parent_control_uuid IS NULL
|
||||
GROUP BY 1, 2, 3 ORDER BY n DESC LIMIT 25;
|
||||
"""
|
||||
|
||||
|
||||
def print_bucket(rows, label: str) -> None:
|
||||
print(f"\n## {label}")
|
||||
total = 0
|
||||
for bucket, n in rows:
|
||||
print(f" {bucket:12} {n:>8}")
|
||||
total += n
|
||||
print(f" {'TOTAL':12} {total:>8}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("error: psycopg2 not installed", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=args.db_host, port=args.db_port, user=args.db_user,
|
||||
dbname=args.db_name, password=args.db_password,
|
||||
)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
|
||||
print("=" * 60)
|
||||
print(" Backfill — license_rule via parent inheritance")
|
||||
print(f" Mode: {'DRY-RUN' if args.dry_run else 'APPLY'}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initial bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
rows = cur.fetchall()
|
||||
print_bucket(rows, "Initial NULL distribution")
|
||||
|
||||
if args.dry_run:
|
||||
# Print what the FIRST inherit pass would resolve (without writing)
|
||||
cur.execute(
|
||||
"SET search_path TO compliance, public; "
|
||||
"SELECT p.license_rule, COUNT(*) "
|
||||
"FROM canonical_controls cc "
|
||||
"JOIN canonical_controls p ON cc.parent_control_uuid = p.id "
|
||||
"WHERE cc.license_rule IS NULL AND p.license_rule IS NOT NULL "
|
||||
"GROUP BY 1 ORDER BY 1;"
|
||||
)
|
||||
print("\n## First inherit-pass would fill:")
|
||||
for rule, n in cur.fetchall():
|
||||
print(f" rule={rule} {n:>8} rows")
|
||||
|
||||
# Show orphan clusters that would remain
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
print("\n## Orphan clusters (no parent + no rule, top 25):")
|
||||
for cat, pid, gen, n in cur.fetchall():
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
print("\nNo writes performed. Use --apply to execute.")
|
||||
conn.rollback()
|
||||
return 0
|
||||
|
||||
# Apply mode — iterative inheritance
|
||||
total_updated = 0
|
||||
for i in range(1, args.max_iterations + 1):
|
||||
cur.execute(SQL_INHERIT_FROM_PARENT)
|
||||
updated = cur.rowcount
|
||||
total_updated += updated
|
||||
print(f"\n iteration {i}: {updated} rows updated")
|
||||
if updated == 0:
|
||||
break
|
||||
|
||||
conn.commit()
|
||||
print(f"\n✓ Total rows backfilled: {total_updated}")
|
||||
|
||||
# Final bucket report
|
||||
cur.execute(SQL_REPORT_NULLS)
|
||||
print_bucket(cur.fetchall(), "Remaining NULL distribution")
|
||||
|
||||
cur.execute(SQL_REPORT_ORPHAN_CLUSTERS)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
print("\n## Orphan clusters still need classification:")
|
||||
for cat, pid, gen, n in rows:
|
||||
print(f" cat={cat[:20]:20} pat={pid[:20]:20} gen={gen[:20]:20} n={n}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill ``license_rule`` payload field into Qdrant atomic_controls_dedup
|
||||
and related compliance collections, sourced from canonical_controls in Postgres.
|
||||
|
||||
The audit (audit_license_classification.py) surfaced that Qdrant collections
|
||||
holding canonical-control vectors (notably ``atomic_controls_dedup``) carry no
|
||||
license_rule payload at all, even though the underlying Postgres table is now
|
||||
fully classified. This script joins the two via ``control_uuid`` and patches the
|
||||
Qdrant payload in batches.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/backfill_qdrant_license_payload.py \\
|
||||
--pg-host 100.80.114.48 --pg-password breakpilot123 \\
|
||||
--qdrant http://100.80.114.48:6333 \\
|
||||
--collection atomic_controls_dedup \\
|
||||
--dry-run
|
||||
|
||||
# apply
|
||||
python3 scripts/backfill_qdrant_license_payload.py ... --apply
|
||||
|
||||
Notes
|
||||
-----
|
||||
- ``control_uuid`` lives in the payload of atomic_controls_dedup. For other
|
||||
collections that key the canonical control by a different field, override with
|
||||
``--uuid-field``.
|
||||
- Qdrant ``set_payload`` is keyed by point id, not payload field. We resolve
|
||||
UUID → point id by a paginated scroll-and-filter pass, then issue grouped
|
||||
set_payload requests per license_rule (3 batches per collection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Iterator
|
||||
from urllib import request as urllib_request
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--pg-host", default="100.80.114.48")
|
||||
p.add_argument("--pg-port", type=int, default=5432)
|
||||
p.add_argument("--pg-user", default="breakpilot")
|
||||
p.add_argument("--pg-name", default="breakpilot_db")
|
||||
p.add_argument("--pg-password", required=True)
|
||||
p.add_argument("--qdrant", default="http://100.80.114.48:6333")
|
||||
p.add_argument("--qdrant-api-key", default="",
|
||||
help="API key for managed Qdrant (Production)")
|
||||
p.add_argument("--collection", default="atomic_controls_dedup")
|
||||
p.add_argument("--uuid-field", default="control_uuid",
|
||||
help="Payload field used for lookup (control_uuid or regulation_id)")
|
||||
p.add_argument("--lookup", choices=["canonical_controls", "regulation_registry"],
|
||||
default="canonical_controls",
|
||||
help="Postgres table to resolve the lookup against")
|
||||
p.add_argument("--batch-size", type=int, default=500)
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--dry-run", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def fetch_rule_by_uuid(args) -> dict[str, int]:
|
||||
"""Pull lookup-key → license_rule mapping from Postgres.
|
||||
|
||||
Source table is chosen by ``--lookup``:
|
||||
- canonical_controls: id (UUID) → license_rule, for atomic_controls_dedup
|
||||
- regulation_registry: regulation_id → license_rule, for document chunks
|
||||
"""
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=args.pg_host, port=args.pg_port, user=args.pg_user,
|
||||
dbname=args.pg_name, password=args.pg_password,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SET search_path TO compliance, public;")
|
||||
if args.lookup == "regulation_registry":
|
||||
cur.execute(
|
||||
"SELECT regulation_id, license_rule FROM regulation_registry "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id::text, license_rule FROM canonical_controls "
|
||||
"WHERE license_rule IS NOT NULL"
|
||||
)
|
||||
mapping = {row[0]: int(row[1]) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
return mapping
|
||||
|
||||
|
||||
def _headers(api_key: str = "") -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
h["api-key"] = api_key
|
||||
return h
|
||||
|
||||
|
||||
def scroll_collection(qdrant: str, collection: str, uuid_field: str, api_key: str = "") -> Iterator[dict]:
|
||||
"""Yield (point_id, uuid_value, has_rule_already) tuples."""
|
||||
next_offset = None
|
||||
while True:
|
||||
body = {"limit": 1000, "with_payload": True, "with_vector": False}
|
||||
if next_offset is not None:
|
||||
body["offset"] = next_offset
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/scroll",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=60) as r:
|
||||
payload = json.loads(r.read())
|
||||
result = payload.get("result", {})
|
||||
for pt in result.get("points", []):
|
||||
pl = pt.get("payload", {}) or {}
|
||||
yield {
|
||||
"id": pt["id"],
|
||||
"uuid": pl.get(uuid_field),
|
||||
"has_rule": "license_rule" in pl,
|
||||
}
|
||||
next_offset = result.get("next_page_offset")
|
||||
if next_offset is None:
|
||||
break
|
||||
|
||||
|
||||
def set_payload_batch(qdrant: str, collection: str, point_ids: list, rule: int, api_key: str = "") -> int:
|
||||
"""POST set_payload for a batch of point IDs with a single license_rule."""
|
||||
body = {
|
||||
"payload": {"license_rule": rule},
|
||||
"points": point_ids,
|
||||
}
|
||||
req = urllib_request.Request(
|
||||
f"{qdrant}/collections/{collection}/points/payload?wait=true",
|
||||
data=json.dumps(body).encode(),
|
||||
headers=_headers(api_key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=120) as r:
|
||||
resp = json.loads(r.read())
|
||||
if resp.get("status") != "ok":
|
||||
raise RuntimeError(f"set_payload failed: {resp}")
|
||||
return len(point_ids)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
print("Loading canonical_controls → license_rule mapping…")
|
||||
rule_by_uuid = fetch_rule_by_uuid(args)
|
||||
print(f" Postgres returned {len(rule_by_uuid)} classified controls")
|
||||
|
||||
print(f"Scrolling Qdrant collection {args.collection!r}…")
|
||||
by_rule: dict[int, list] = {1: [], 2: [], 3: []}
|
||||
points_total = 0
|
||||
points_with_uuid = 0
|
||||
points_already_set = 0
|
||||
points_no_match = 0
|
||||
|
||||
for pt in scroll_collection(args.qdrant, args.collection, args.uuid_field, args.qdrant_api_key):
|
||||
points_total += 1
|
||||
uuid = pt["uuid"]
|
||||
if not uuid:
|
||||
continue
|
||||
points_with_uuid += 1
|
||||
if pt["has_rule"]:
|
||||
points_already_set += 1
|
||||
continue
|
||||
rule = rule_by_uuid.get(uuid)
|
||||
if rule is None:
|
||||
points_no_match += 1
|
||||
continue
|
||||
if rule not in by_rule:
|
||||
continue
|
||||
by_rule[rule].append(pt["id"])
|
||||
|
||||
print(f" total points scanned: {points_total}")
|
||||
print(f" with {args.uuid_field}: {points_with_uuid}")
|
||||
print(f" already had license_rule: {points_already_set}")
|
||||
print(f" uuid not found in Postgres: {points_no_match}")
|
||||
print(f" to set per rule: rule1={len(by_rule[1])} rule2={len(by_rule[2])} rule3={len(by_rule[3])}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\nDRY-RUN: no writes performed. Use --apply to execute.")
|
||||
return 0
|
||||
|
||||
total_written = 0
|
||||
for rule, ids in by_rule.items():
|
||||
if not ids:
|
||||
continue
|
||||
print(f"\nWriting license_rule={rule} to {len(ids)} points (batch {args.batch_size})…")
|
||||
for i in range(0, len(ids), args.batch_size):
|
||||
chunk = ids[i:i + args.batch_size]
|
||||
n = set_payload_batch(args.qdrant, args.collection, chunk, rule, args.qdrant_api_key)
|
||||
total_written += n
|
||||
print(f" batch {i // args.batch_size + 1}: {n} points (cumulative {total_written})")
|
||||
time.sleep(0.05)
|
||||
print(f"\nWrote license_rule on {total_written} Qdrant points in {args.collection}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -34,6 +34,27 @@ export default function ImpressumPage() {
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Quellen und Lizenzen der Compliance-Inhalte</h2>
|
||||
<p>
|
||||
Die BreakPilot Compliance-Plattform stuetzt sich auf rund 315.000 klassifizierte
|
||||
Controls aus oeffentlichen Quellen: EU-Recht (EUR-Lex), deutsches und oesterreichisches
|
||||
Bundesrecht, US Federal Code (OSHA, NIST), Behoerden-Leitfaeden (ENISA, EDPB, BAuA),
|
||||
freie Sicherheits-Frameworks unter CC-BY-SA (OWASP-Familie, OECD AI Principles) und
|
||||
eigene Texte. Jeder Control traegt eine deterministische Lizenzregel (R1 woertlich, R2
|
||||
mit Attribution, R3 nur Identifier-Verweis), die das Render-Verhalten in Berichten,
|
||||
PDF-Exports und Frontend steuert. Die vollstaendige Quellenliste mit Aufschluesselung
|
||||
pro Lizenzklasse ist im SDK unter <code className="text-white/80">/sdk/licenses</code>
|
||||
eingesehen. Pflicht-Attributionen fuer R2-Quellen erscheinen automatisch im
|
||||
Quellen-Footer jedes generierten Berichts.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Hinweis: Dieser Pauschalvermerk ersetzt nicht die werknahe Attribution. Jede
|
||||
Berichts- oder Frontend-Ausgabe nennt die konkret verwendeten Quellen direkt am
|
||||
Werk (Auto-Footer in PDFs, Inline-Citation im Frontend).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Navbar from '@/components/layout/Navbar'
|
||||
import Footer from '@/components/layout/Footer'
|
||||
import ChatFAB from '@/components/layout/ChatFAB'
|
||||
|
||||
// Stärken / USP-Seite — sieben Verkaufsargumente aus der IACE-Strategie
|
||||
// (Memory: project_marketing_website_3014_themes.md). Aufgebaut als
|
||||
// Long-Form-Page mit Anker-Sprungmarken — eine Nummerierte Differenzierung
|
||||
// pro Sektion, damit Sales-Calls über tiefe Links arbeiten können.
|
||||
|
||||
const usps = [
|
||||
{
|
||||
id: 'engine',
|
||||
no: '1',
|
||||
title: 'Engine, nicht Checkliste',
|
||||
sub: 'Wir leiten Gefährdungen ab. Wettbewerb fragt aus einer Liste.',
|
||||
body:
|
||||
'Marktstandard (DesignSafe, Pilz, Sick) ist Excel-aufgewertete Checkliste: der Engineer wählt aus einer Hazard-Bibliothek aus. ' +
|
||||
'BreakPilot betreibt eine deterministische Pattern-Engine mit über 1.200 Hazard-Patterns. Aus der Maschinenbeschreibung leitet sie ' +
|
||||
'die Gefährdungen ab — keine Auswahllisten, keine vergessenen Punkte.',
|
||||
proof: 'Audit-Suite cmd/iace-audit erkennt eigene Lücken (Methode A–E)',
|
||||
},
|
||||
{
|
||||
id: 'multi-markt',
|
||||
no: '2',
|
||||
title: 'Eine Risikobeurteilung — alle Märkte',
|
||||
sub: 'CE + OSHA + ANSI + GB + JIS aus einem Datenmodell.',
|
||||
body:
|
||||
'Die gleiche Pattern-Engine generiert pro Maschinenbeschreibung mehrere Compliance-Anhänge. Hersteller wählt seine Zielmärkte. ' +
|
||||
'EU-Recht zitieren wir wörtlich (Rule 1). OWASP unter CC-BY-SA mit Pflicht-Attribution (Rule 2). DIN/EN nur per Identifier (Rule 3). ' +
|
||||
'Norm-Cross-Reference-Bibliothek mappt ISO 12100 ↔ DIN EN ISO 12100 ↔ ANSI B11.0 ↔ GB/T 15706 ↔ JIS B 9700.',
|
||||
proof: '252 Regulationen klassifiziert · 314.811 Controls audited',
|
||||
},
|
||||
{
|
||||
id: 'folgegefahren',
|
||||
no: '3',
|
||||
title: 'Vom Bediener bis zum Endkunden',
|
||||
sub: 'Folgegefahren-Modell mit Sekundärschadens-Kette.',
|
||||
body:
|
||||
'Klassische Risikobeurteilung schaut nur den Bediener an. Wir modellieren die Schadenskette weiter: Glasbruch in der Abfüllanlage ' +
|
||||
'verletzt nicht nur den Bediener, sondern erreicht über Restsplitter den Endkunden. BreakPilot verbindet CE-Sicherheit mit ' +
|
||||
'Produkthaftung nach ProdHaftG, Lebensmittelrecht nach VO 178/2002 und ISO 31000 Unternehmensrisiko in einem Datenmodell.',
|
||||
proof: 'SecondaryHarm-Modell live für consumer_safety, product_liability, food_safety, environmental, reputation, financial',
|
||||
},
|
||||
{
|
||||
id: 'public-domain',
|
||||
no: '4',
|
||||
title: 'Public Domain als Rechtsanker',
|
||||
sub: 'Werte aus OSHA, NIST, EUR-Lex, BAuA — auditfähig zitiert.',
|
||||
body:
|
||||
'Mindestabstände der Maschinensicherheit kommen bei uns aus OSHA 29 CFR 1910 Subpart O — US Federal Public Domain, lizenzrechtlich ' +
|
||||
'unbedenklich. Engineering-Rundung auf safe-side mm-Raster wird transparent dokumentiert. EU-Normen erscheinen nur als Identifier-Verweis ' +
|
||||
'mit einer menschlich kuratierten "Strenger/Gleich/Weicher"-Annotation — kein Copyright-Risiko.',
|
||||
proof: 'OSHA Table O-10 + §1910.217 PSDI-Formel verbatim · DIN nur Identifier · 6 DGUV-Publikationen referenziert',
|
||||
},
|
||||
{
|
||||
id: 'audit-suite',
|
||||
no: '5',
|
||||
title: 'Audit findet Lücken, die der Fachmann übersieht',
|
||||
sub: 'Fünf deterministische Audits ohne Ground Truth.',
|
||||
body:
|
||||
'Unsere Engine kennt ihre eigenen Lücken. Methode A bis E (Reachability, Consistency, Vocabulary, Echo, Hierarchy) finden Gaps ' +
|
||||
'ohne Fachmann-Vergleich. Bei einem Test fanden wir 100 strukturell unerreichbare Patterns und 46 unvollständige Component-Tags — ' +
|
||||
'Probleme, die ein menschlicher Auditor in einem Einzelfall nie gesehen hätte.',
|
||||
proof: 'cmd/iace-audit · 1.213 Patterns transparent · 99,94% Recall verifiziert',
|
||||
},
|
||||
{
|
||||
id: 'made-in-germany',
|
||||
no: '6',
|
||||
title: 'Made in Germany meets US Federal Public Domain',
|
||||
sub: 'Deutscher Maschinenbau, der gleichzeitig US-Compliance liefert.',
|
||||
body:
|
||||
'Deutscher Exportweltmeister-Maschinenbau braucht UL/NRTL-Zulassung für die USA. Die gleichen Daten, die wir für CE generieren, ' +
|
||||
'liefern dem US-Auditor 80 % der Vorarbeit. Risikobeurteilung in einer Sprache, Compliance in zwei Märkten — ohne Mehraufwand für den Hersteller.',
|
||||
proof: 'OSHA-Anker im RAG · NRTL-fähige Compliance-Spur · DesignSafe-Marktstandard wird hier erweitert, nicht imitiert',
|
||||
},
|
||||
{
|
||||
id: 'tooling',
|
||||
no: '7',
|
||||
title: 'LLM-Gap-Review als Co-Pilot, nicht als Roboter-Anwalt',
|
||||
sub: 'Pattern-Engine als Audit-Spur, LLM als Lücken-Suchhund.',
|
||||
body:
|
||||
'Die deterministische Engine bleibt die auditfähige Quelle der Wahrheit. Ein nachgelagerter LLM-Gap-Review (Qwen / Claude) prüft, ' +
|
||||
'was die Engine übersehen hat — mit klarer Quellen-Provenance (R3 LLM-Review) und Adopt/Reject-UX. Halluzinationen können nicht in ' +
|
||||
'die finale Risikobeurteilung schlüpfen.',
|
||||
proof: 'POST /projects/:id/llm-gap-review · Konfidenz-Stufen · Fallback auf statische Checkliste',
|
||||
},
|
||||
] as const
|
||||
|
||||
const competitors = [
|
||||
{ feature: 'Pattern-Engine statt Checkliste', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'Multi-Markt CE / US / CN / JP', bp: '✓', ds: 'nur US', pilz: 'nur EU', sick: 'nur EU', sphera: 'enterprise' },
|
||||
{ feature: 'Folgegefahren-Modell', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: 'Process' },
|
||||
{ feature: 'Audit-Suite (Engine-Lücken-Erkennung)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'OSHA-Anker (Public Domain Werte)', bp: '✓', ds: '✓', pilz: '—', sick: '—', sphera: '—' },
|
||||
{ feature: 'LLM-Gap-Review (Co-Pilot)', bp: '✓', ds: '—', pilz: '—', sick: '—', sphera: '—' },
|
||||
]
|
||||
|
||||
export default function StaerkenPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="bg-enterprise-dark text-white pt-32 pb-24">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
<header className="mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4">Was uns differenziert</h1>
|
||||
<p className="text-white/60 text-lg max-w-3xl">
|
||||
Sieben konkrete Punkte, die BreakPilot von DesignSafe, Pilz, Sick, TÜV-Tools und Sphera trennen.
|
||||
Jede Differenzierung ist im Produkt umgesetzt — kein Marketing-Versprechen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="space-y-12">
|
||||
{usps.map((u) => (
|
||||
<li id={u.id} key={u.id} className="border-l-2 border-accent-electric pl-6">
|
||||
<div className="flex items-baseline gap-3 mb-2">
|
||||
<span className="text-accent-electric font-mono text-3xl font-bold">#{u.no}</span>
|
||||
<h2 className="text-2xl font-semibold">{u.title}</h2>
|
||||
</div>
|
||||
<p className="text-accent-electric/80 text-sm mb-3">{u.sub}</p>
|
||||
<p className="text-white/70 leading-relaxed mb-3">{u.body}</p>
|
||||
<p className="text-white/40 text-xs">
|
||||
<span className="text-white/60">Belegt durch:</span> {u.proof}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<section className="mt-20">
|
||||
<h2 className="text-3xl font-bold mb-4">Direktvergleich</h2>
|
||||
<p className="text-white/60 mb-6 max-w-3xl">
|
||||
Stand 2026. Marktangaben basieren auf öffentlicher Produktinformation der genannten Anbieter.
|
||||
</p>
|
||||
<div className="overflow-x-auto border border-white/10 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/[0.04] border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium">Feature</th>
|
||||
<th className="text-left p-3 font-medium text-accent-electric">BreakPilot</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">DesignSafe</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Pilz PASS</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sick SD</th>
|
||||
<th className="text-left p-3 font-medium text-white/60">Sphera</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{competitors.map((c) => (
|
||||
<tr key={c.feature} className="border-t border-white/[0.06]">
|
||||
<td className="p-3 text-white/80">{c.feature}</td>
|
||||
<td className="p-3 text-accent-electric font-medium">{c.bp}</td>
|
||||
<td className="p-3 text-white/50">{c.ds}</td>
|
||||
<td className="p-3 text-white/50">{c.pilz}</td>
|
||||
<td className="p-3 text-white/50">{c.sick}</td>
|
||||
<td className="p-3 text-white/50">{c.sphera}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-20 border-t border-white/10 pt-12">
|
||||
<h2 className="text-2xl font-bold mb-3">Quellen & Lizenz-Architektur</h2>
|
||||
<p className="text-white/60 leading-relaxed">
|
||||
Die Plattform stützt sich auf öffentliche Quellen: EU-Recht (EUR-Lex), Bundesrecht (BetrSichV, ArbSchG),
|
||||
US Federal Code (OSHA, NIST), Behörden-Leitfäden (ENISA, EDPB, BAuA), freie Sicherheits-Frameworks unter
|
||||
CC-BY-SA (OWASP). Jeder Inhalt trägt eine deterministische Lizenzregel R1/R2/R3 und löst die
|
||||
entsprechende Attribution im Ausgabe-PDF und im Frontend automatisch aus. Vollständige Quellenliste
|
||||
im SDK unter <code className="bg-white/[0.06] px-1.5 py-0.5 rounded">/sdk/licenses</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatFAB />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Navbar links — route-based navigation
|
||||
export const navLinks = [
|
||||
{ href: '/plattform', labelDe: 'Plattform', labelEn: 'Platform' },
|
||||
{ href: '/staerken', labelDe: 'Stärken', labelEn: 'Differentiators' },
|
||||
{ href: '/ce-prozess', labelDe: 'CE-Prozess', labelEn: 'CE Process' },
|
||||
{ href: '/product-compliance', labelDe: 'Product Compliance', labelEn: 'Product Compliance' },
|
||||
{ href: '/architektur', labelDe: 'Architektur', labelEn: 'Architecture' },
|
||||
|
||||
@@ -137,23 +137,43 @@ const PILLARS_EN = [
|
||||
export function PrintRegulatoryPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
|
||||
const de = lang === 'de'
|
||||
const pillars = de ? PILLARS_DE : PILLARS_EN
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
// Alternate tints — pillars 1 & 3 violet, 2 & 4 amber for visual rhythm.
|
||||
const tints = [
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
{ dark: COLORS.violet700, mid: COLORS.violet600, light: COLORS.violet50, border: COLORS.violet300 },
|
||||
{ dark: COLORS.amber700, mid: COLORS.amber600, light: COLORS.amber50, border: '#f3d59a' },
|
||||
]
|
||||
return (
|
||||
<Page kicker="19" section={de ? 'ANHANG · REGULATORISCHE DETAILS' : 'APPENDIX · REGULATORY DETAILS'} title={de ? 'Vier Säulen der EU-Compliance für Maschinenbauer.' : 'Four pillars of EU compliance for manufacturers.'} subtitle={de ? 'Jede Säule deckt 4–6 verbindliche Regelwerke ab. BreakPilot mappt diese auf 25.000+ atomare Controls.' : 'Each pillar covers 4–6 binding regulations. BreakPilot maps these to 25,000+ atomic controls.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{pillars.map((p, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.indigo600}`, padding: '4mm 5mm', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{de ? `Säule ${String(i + 1).padStart(2, '0')}` : `Pillar ${String(i + 1).padStart(2, '0')}`}</span>
|
||||
<span style={{ fontSize: '6.5pt', color: COLORS.slate400 }}>{de ? '4-6 Regelwerke' : '4-6 regulations'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '13pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '3mm', lineHeight: 1.2 }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5, flex: 1, marginBottom: '3mm' }}>{p.d}</div>
|
||||
<div style={{ borderTop: `1px solid ${COLORS.slate100}`, paddingTop: '2mm', fontSize: '7pt', color: COLORS.slate500, fontFamily: 'monospace' }}>
|
||||
{p.laws.join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Architectural row: 4 pillars side-by-side */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4mm', flex: 1, minHeight: 0, alignItems: 'stretch' }}>
|
||||
{pillars.map((p, i) => {
|
||||
const c = tints[i]
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* CAPITAL (top) */}
|
||||
<div style={{ background: `linear-gradient(180deg, ${c.mid} 0%, ${c.dark} 100%)`, padding: '3mm 3mm 3.5mm', borderTopLeftRadius: '2pt', borderTopRightRadius: '2pt', margin: '0 -1.5mm', boxShadow: `0 2mm 2mm -1.5mm ${c.dark}55`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', textTransform: 'uppercase', letterSpacing: '0.22em', opacity: 0.85 }}>{de ? 'SÄULE' : 'PILLAR'}</div>
|
||||
<div style={{ fontSize: '20pt', fontWeight: 800, color: '#ffffff', lineHeight: 1, marginTop: '1mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{String(i + 1).padStart(2, '0')}</div>
|
||||
</div>
|
||||
{/* SHAFT (middle) */}
|
||||
<div style={{ background: c.light, borderLeft: `2mm solid ${c.mid}`, borderRight: `1px solid ${c.border}`, borderTop: `1px solid ${c.border}`, borderBottom: `1px solid ${c.border}`, padding: '3mm 3mm 3mm 2.5mm', flex: 1, display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontSize: '11.5pt', fontWeight: 700, color: COLORS.slate900, marginBottom: '2.5mm', lineHeight: 1.2, letterSpacing: '-0.005em' }}>{p.t}</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.55, flex: 1 }}>{p.d}</div>
|
||||
</div>
|
||||
{/* BASE (bottom) */}
|
||||
<div style={{ background: c.dark, margin: '0 -1.5mm', padding: '2mm 3mm', borderBottomLeftRadius: '2pt', borderBottomRightRadius: '2pt', fontFamily: MONO, fontSize: '6pt', color: '#ffffff', opacity: 0.95, lineHeight: 1.5, textAlign: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', boxShadow: `0 -1mm 1.5mm -1mm ${c.dark}66 inset` }}>{p.laws.join(' · ')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Shared ground line — architectural reference */}
|
||||
<div style={{ marginTop: '2mm', height: '1px', background: COLORS.violet300, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ marginTop: '0.6mm', height: '1px', background: COLORS.violet200, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '4mm', flexShrink: 0 }}>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function PrintCompetitionPage2({ lang, pageNum, totalPages, versionName }
|
||||
const de = lang === 'de'
|
||||
|
||||
return (
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 2 / 2, APPSEC' : 'COMPETITION · 2 / 2, APPSEC'} title={de ? 'Application Security: BreakPilot deckt SAST + DAST + SCA + Pentesting in einer Plattform ab.' : 'Application Security: BreakPilot covers SAST + DAST + SCA + pentesting in one platform.'} subtitle={de ? 'Acht etablierte AppSec-Anbieter, keiner kombiniert SAST + DAST + Auto-Fix + Self-Hosted für KMU.' : 'Eight established AppSec vendors, none combines SAST + DAST + auto-fix + self-hosted for SMEs.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="12" section={de ? 'WETTBEWERB · 2 / 2, APPSEC' : 'COMPETITION · 2 / 2, APPSEC'} title={de ? 'Cyber-Security: BreakPilot ersetzt das ganze AppSec-Stack.' : 'Cyber Security: BreakPilot replaces the entire AppSec stack.'} subtitle={de ? 'Acht etablierte Code-Security-Anbieter, jeder mit einer Disziplin. BreakPilot vereint sie auf einer EU-souveränen Plattform, zum Bruchteil der Kosten.' : 'Eight established code-security vendors, each with one discipline. BreakPilot combines them on one EU-sovereign platform, at a fraction of the cost.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* Competitor profile table */}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '7.5pt', fontVariantNumeric: 'tabular-nums', marginBottom: '4mm' }}>
|
||||
|
||||
@@ -159,10 +159,40 @@ export function ArchitectureDiagram({
|
||||
const productLayerBg = `linear-gradient(135deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`
|
||||
const proxyLayerBg = `linear-gradient(135deg, ${COLORS.amber50} 0%, #fffaf0 60%, ${COLORS.amber50} 100%)`
|
||||
|
||||
/**
|
||||
* Faked-3D layer wrapper: shadow on the bottom edge (heavier than top), a 1px
|
||||
* top highlight, a 1px darker bottom seam, and a stagger indent on the right
|
||||
* to suggest the stack tilts slightly away from the viewer. This renders
|
||||
* crisply in Chromium's print-to-PDF, unlike `transform: rotateX(...)` which
|
||||
* has print-pipeline quirks.
|
||||
*/
|
||||
const layerWrap = (indentRight: string, shadowTint: string, glowTop: string, seamBottom: string): React.CSSProperties => ({
|
||||
marginRight: indentRight,
|
||||
boxShadow: `inset 0 1px 0 ${glowTop}, inset 0 -1px 0 ${seamBottom}, 0 5mm 7mm -4mm ${shadowTint}`,
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
})
|
||||
|
||||
/** Connector that reads as "the upper plane resting on the next" — a soft chevron with a shadow. */
|
||||
const PlaneConnector = ({ color }: { color: string }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '5mm', position: 'relative' }}>
|
||||
<svg viewBox="0 0 40 20" width="14mm" height="5mm" style={{ overflow: 'visible' }}>
|
||||
<polygon points="4,2 36,2 30,16 10,16" fill={color} opacity="0.18" />
|
||||
<polyline points="10,4 20,14 30,4" fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2mm' }}>
|
||||
{/* APPLICATION (PRODUCT) LAYER */}
|
||||
<div style={{ background: productLayerBg, border: `1px solid ${COLORS.violet200}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{/* APPLICATION (PRODUCT) LAYER — top plane, smallest indent footprint */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('0mm', 'rgba(59,26,122,0.20)', 'rgba(255,255,255,0.85)', COLORS.violet200),
|
||||
}}>
|
||||
<LayerChip n="01" label={de ? 'Application Layer' : 'Application Layer'} sub={de ? 'Kundenseitige Services' : 'User-facing services'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{product.map((p, i) => (
|
||||
@@ -171,20 +201,16 @@ export function ArchitectureDiagram({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* compact connector strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm', padding: '0 5mm', height: '5mm', alignItems: 'center' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 12 16" width="3mm" height="5mm">
|
||||
<line x1="6" y1="0" x2="6" y2="12" stroke={COLORS.violet500} strokeWidth="1.2" />
|
||||
<polyline points="3,11 6,15 9,11" fill="none" stroke={COLORS.violet500} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlaneConnector color={COLORS.violet500} />
|
||||
|
||||
{/* GATEWAY LAYER — compact: title row + features in 1 row */}
|
||||
<div style={{ background: proxyLayerBg, border: `1.5px solid ${COLORS.amber600}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* GATEWAY LAYER — middle plane, slight indent, slightly heavier shadow */}
|
||||
<div style={{
|
||||
background: proxyLayerBg,
|
||||
border: `1.5px solid ${COLORS.amber600}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3mm 4mm',
|
||||
...layerWrap('2mm', 'rgba(180,83,9,0.22)', 'rgba(255,255,255,0.85)', '#e9b56a'),
|
||||
}}>
|
||||
<LayerChip n="02" label={de ? 'Gateway Layer' : 'Gateway Layer'} sub={de ? 'Routing & Guardrails' : 'Routing & guardrails'} tint={COLORS.amber700} />
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4mm', marginBottom: '2mm' }}>
|
||||
<span style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, letterSpacing: '-0.005em' }}>{proxy.title}</span>
|
||||
@@ -197,20 +223,16 @@ export function ArchitectureDiagram({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* compact connector strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm', padding: '0 5mm', height: '5mm', alignItems: 'center' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 12 16" width="3mm" height="5mm">
|
||||
<line x1="6" y1="0" x2="6" y2="12" stroke={COLORS.amber600} strokeWidth="1.2" />
|
||||
<polyline points="3,11 6,15 9,11" fill="none" stroke={COLORS.amber600} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PlaneConnector color={COLORS.amber600} />
|
||||
|
||||
{/* INFRASTRUCTURE (INFERENCE) LAYER */}
|
||||
<div style={{ background: productLayerBg, border: `1px solid ${COLORS.violet200}`, borderRadius: '4pt', padding: '3mm 4mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* INFRASTRUCTURE (INFERENCE) LAYER — foundation, deepest indent + shadow */}
|
||||
<div style={{
|
||||
background: productLayerBg,
|
||||
border: `1px solid ${COLORS.violet300}`,
|
||||
borderRadius: '4pt',
|
||||
padding: '3.5mm 4mm',
|
||||
...layerWrap('4mm', 'rgba(59,26,122,0.28)', 'rgba(255,255,255,0.85)', COLORS.violet300),
|
||||
}}>
|
||||
<LayerChip n="03" label={de ? 'Infrastructure Layer' : 'Infrastructure Layer'} sub={de ? 'Compute & Daten · lokal · air-gap-fähig' : 'Compute & data · local · air-gap capable'} tint={COLORS.violet600} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm' }}>
|
||||
{inference.map((p, i) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Language, PitchCompany, PitchFunding, PitchMarket } from '@/lib/types'
|
||||
import { Page, KpiRow, TwoCol, ThreeCol, FourCol, Panel, Bullets, Callout, COLORS, Divider } from './PrintLayout'
|
||||
import { Page, KpiRow, TwoCol, ThreeCol, FourCol, Panel, Bullets, Callout, COLORS, Divider, ComplAI } from './PrintLayout'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SlideBase { lang: Language; pageNum: number; totalPages: number; versi
|
||||
export function PrintCoverPage({ company, funding, lang, versionName }: { company: PitchCompany; funding: PitchFunding; lang: Language; versionName: string }) {
|
||||
const de = lang === 'de'
|
||||
const instrument = funding?.instrument || 'Pre-Seed'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const tagline = de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')
|
||||
const amountLabel = amount >= 1_000_000
|
||||
? '€' + (amount / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
@@ -128,7 +128,7 @@ export function PrintExecSummaryPage1({ market, lang, pageNum, totalPages, versi
|
||||
const fmt = (v?: number) => v ? (v >= 1e9 ? `${(v / 1e9).toFixed(1).replace('.', ',')} Mrd.` : `${(v / 1e6).toFixed(0)} Mio.`) : '—'
|
||||
|
||||
return (
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY' : 'EXECUTIVE SUMMARY'} title={de ? 'BreakPilot COMPL/AI/' : 'BreakPilot COMPL/AI/'} subtitle={de ? 'DSGVO-konforme KI-Plattform, kontinuierliches Sicherheitsscanning und intelligente Compliance-Automatisierung. 25.000+ atomare Prüfaspekte, 380+ Regularien, EU-souverän gehostet.' : 'GDPR-compliant AI platform, continuous security scanning and intelligent compliance automation. 25,000+ atomic audit aspects, 380+ regulations, EU-sovereign hosted.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="01" section={de ? 'EXECUTIVE SUMMARY' : 'EXECUTIVE SUMMARY'} title={<>BreakPilot <ComplAI /></>} subtitle={de ? 'DSGVO-konforme KI-Plattform, kontinuierliches Sicherheitsscanning und intelligente Compliance-Automatisierung. 25.000+ atomare Prüfaspekte, 380+ Regularien, EU-souverän gehostet.' : 'GDPR-compliant AI platform, continuous security scanning and intelligent compliance automation. 25,000+ atomic audit aspects, 380+ regulations, EU-sovereign hosted.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<KpiRow items={[
|
||||
{ n: '25k+', label: de ? 'Prüfaspekte' : 'Audit aspects', tone: 'accent' },
|
||||
|
||||
@@ -58,13 +58,29 @@ export const COLORS = {
|
||||
const FONT = "'Inter', 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif"
|
||||
const MONO_FONT = "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace"
|
||||
|
||||
/**
|
||||
* Brand wordmark: `Compl` rendered in the inherited text color, `AI` in violet.
|
||||
* No slashes, no separators — matches the BreakPilot/ComplAI brand guideline.
|
||||
* Use this anywhere the product name appears in body or display text.
|
||||
*
|
||||
* Use the optional `prefix` to prepend "BreakPilot " in the same style.
|
||||
*/
|
||||
export function ComplAI({ prefix = false }: { prefix?: boolean } = {}) {
|
||||
return (
|
||||
<>
|
||||
{prefix && <>BreakPilot </>}
|
||||
Compl<span style={{ color: COLORS.violet600 }}>AI</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== PAGE WRAPPER ===== */
|
||||
|
||||
interface PageProps {
|
||||
kicker: string // "03"
|
||||
section: string // "DAS PROBLEM"
|
||||
title: string // "Deutsche Unternehmen wollen KI ..."
|
||||
subtitle?: string
|
||||
title: React.ReactNode // string or JSX (e.g. <ComplAI prefix /> usage)
|
||||
subtitle?: React.ReactNode
|
||||
pageNum: number
|
||||
totalPages: number
|
||||
versionName: string
|
||||
@@ -236,12 +252,23 @@ export function Bullets({ items, dense, tone = 'neutral' }: BulletsProps) {
|
||||
: tone === 'negative' ? COLORS.red600
|
||||
: tone === 'accent' ? COLORS.indigo600
|
||||
: COLORS.slate500
|
||||
/**
|
||||
* Em-dash marker uses display:flex to keep the rule vertically centered on
|
||||
* the first line of text — previously the `top: 4pt` absolute positioning
|
||||
* drifted relative to font size (looked off-center on the rendered PDF).
|
||||
* The marker now sits in a fixed-width column at the line height of the text.
|
||||
*/
|
||||
const fontSize = dense ? '8.5pt' : '9pt'
|
||||
const lineH = 1.5
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{items.map((item, i) => (
|
||||
<li key={i} style={{ position: 'relative', paddingLeft: '5mm', marginBottom: dense ? '1.5mm' : '2.5mm', fontSize: dense ? '8.5pt' : '9pt', color: COLORS.slate700, lineHeight: 1.5 }}>
|
||||
<span style={{ position: 'absolute', left: 0, top: dense ? '4pt' : '4.5pt', width: '3mm', height: '0.5pt', background: dotColor }} />
|
||||
{item}
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm', marginBottom: dense ? '1.5mm' : '2.5mm', fontSize, color: COLORS.slate700, lineHeight: lineH }}>
|
||||
{/* The dash sits on the first line: line-height pad above + 0.5pt rule */}
|
||||
<span style={{ flexShrink: 0, width: '3mm', display: 'inline-flex', alignItems: 'center', height: `${parseFloat(fontSize) * lineH}pt` }}>
|
||||
<span style={{ width: '100%', height: '0.5pt', background: dotColor, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Language, PitchMarket, PitchTeamMember, PitchMilestone, PitchFunding } from '@/lib/types'
|
||||
import { Page, Callout, COLORS, DataTable, StatLine } from './PrintLayout'
|
||||
import { MarketFunnel, ComparisonBars, DonutChart } from './PrintCharts'
|
||||
import { Page, Callout, COLORS, StatLine } from './PrintLayout'
|
||||
import { ComparisonBars, DonutChart } from './PrintCharts'
|
||||
import {
|
||||
Briefcase, RefreshCw, Handshake, Scale, Lightbulb,
|
||||
Code, TrendingUp, CreditCard, ShieldCheck, Cpu,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
|
||||
|
||||
@@ -19,42 +23,72 @@ export function PrintMarketPage({ market, lang, pageNum, totalPages, versionName
|
||||
const sam = market.find(m => m.market_segment === 'SAM')
|
||||
const som = market.find(m => m.market_segment === 'SOM')
|
||||
|
||||
return (
|
||||
<Page kicker="09" section={de ? 'MARKT' : 'MARKET'} title={de ? 'Compliance & Code-Security für produzierende Unternehmen.' : 'Compliance & code security for manufacturing companies.'} subtitle={de ? 'Validierter Markt: Top-10 Compliance-Anbieter erwirtschaften >$1,1 Mrd. ARR. Kein Anbieter bedient den Maschinenbau spezifisch.' : 'Validated market: top-10 compliance vendors generate >$1.1B ARR. No vendor specifically serves manufacturing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote={de ? 'Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista' : 'Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista'}>
|
||||
// Fallbacks if data missing
|
||||
const tamValue = tam?.value_eur ?? 340_000_000_000
|
||||
const samValue = sam?.value_eur ?? 48_000_000_000
|
||||
const somValue = som?.value_eur ?? 2_100_000_000
|
||||
const cards = [
|
||||
{ key: 'TAM', value: fmtEur(tamValue, de), growth: tam?.growth_rate_pct ?? 14, accent: 'violet' as const,
|
||||
desc: de ? 'Globaler Compliance- und GRC-Markt, alle Branchen, alle Größen.' : 'Global compliance and GRC market, all industries, all sizes.' },
|
||||
{ key: 'SAM', value: fmtEur(samValue, de), growth: sam?.growth_rate_pct ?? 18, accent: 'violet-soft' as const,
|
||||
desc: de ? 'DACH + EU: regulierte Branchen, KMU und Enterprise.' : 'DACH + EU: regulated industries, SMB and enterprise.' },
|
||||
{ key: 'SOM', value: fmtEur(somValue, de), growth: som?.growth_rate_pct ?? 25, accent: 'amber' as const, core: true,
|
||||
desc: de ? 'Anlagen- und Maschinenbau DACH, unser Kernsegment.' : 'Machine and plant manufacturing DACH, our core segment.' },
|
||||
]
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
|
||||
<div>
|
||||
// SVG nested-circles geometry — viewBox unitless, render up to ~130mm wide
|
||||
const CX = 65, CY = 65, R_TAM = 60, R_SAM = 36, R_SOM = 14
|
||||
|
||||
return (
|
||||
<Page kicker="09" section={de ? 'MARKT' : 'MARKET'} title={de ? 'Compliance & Code-Security für produzierende Unternehmen.' : 'Compliance & code security for manufacturing companies.'} subtitle={de ? 'Validierter Markt: Top-10 Compliance-Anbieter erwirtschaften >$1,1 Mrd. ARR. Kein Anbieter bedient den Maschinenbau spezifisch.' : 'Validated market: top-10 compliance vendors generate >$1.1B ARR. No vendor specifically serves manufacturing.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName} footnote="Sacra · Bitkom Cloud Monitor 2024 · DIHK 2024 · VDMA · Statista">
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.05fr', gap: '10mm', flex: 1, minHeight: 0 }}>
|
||||
{/* LEFT: nested-circles diagram */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Marktdimensionierung' : 'Market sizing'}</div>
|
||||
{tam && sam && som && (
|
||||
<MarketFunnel
|
||||
tam={{ value: tam.value_eur, label: de ? 'Total Addressable' : 'Total Addressable', growth: tam.growth_rate_pct ?? 14, note: de ? 'Globaler Compliance- und GRC-Markt (alle Branchen, alle Größen).' : 'Global compliance and GRC market (all industries, all sizes).' }}
|
||||
sam={{ value: sam.value_eur, label: de ? 'Serviceable Addressable' : 'Serviceable Addressable', growth: sam.growth_rate_pct ?? 18, note: de ? 'DACH + EU: regulierte Branchen, KMU und Enterprise.' : 'DACH + EU: regulated industries, SMB and enterprise.' }}
|
||||
som={{ value: som.value_eur, label: de ? 'Kernmarkt 5 Jahre' : 'Core market 5 yrs', growth: som.growth_rate_pct ?? 25, note: de ? 'Anlagen- und Maschinenbau DACH, unser Kernsegment.' : 'Machine and plant manufacturing DACH, our core segment.' }}
|
||||
fmt={(v) => fmtEur(v, de)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 130 130" style={{ width: '100%', maxWidth: '130mm', height: 'auto', display: 'block' }} aria-hidden>
|
||||
<circle cx={CX} cy={CY} r={R_TAM} fill={COLORS.violet50} stroke={COLORS.violet400} strokeWidth="0.6" />
|
||||
<circle cx={CX} cy={CY} r={R_SAM} fill={COLORS.violet100} stroke={COLORS.violet500} strokeWidth="0.7" />
|
||||
<circle cx={CX} cy={CY} r={R_SOM} fill={COLORS.amber50} stroke={COLORS.amber600} strokeWidth="0.9" />
|
||||
<text x={CX} y={CY - R_TAM + 7} textAnchor="middle" fontSize="3.2" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet700}>TAM</text>
|
||||
<text x={CX} y={CY - R_TAM + 11.5} textAnchor="middle" fontSize="3.6" fontWeight={700} fill={COLORS.slate700}>{fmtEur(tamValue, de)}</text>
|
||||
<text x={CX} y={CY - R_SAM + 6} textAnchor="middle" fontSize="3" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.violet800}>SAM</text>
|
||||
<text x={CX} y={CY - R_SAM + 10} textAnchor="middle" fontSize="3.4" fontWeight={700} fill={COLORS.slate800}>{fmtEur(samValue, de)}</text>
|
||||
<text x={CX} y={CY - 1.2} textAnchor="middle" fontSize="2.8" fontFamily="'JetBrains Mono', ui-monospace, monospace" fontWeight={700} letterSpacing="0.18em" fill={COLORS.amber700}>SOM</text>
|
||||
<text x={CX} y={CY + 3} textAnchor="middle" fontSize="3.4" fontWeight={800} fill={COLORS.slate900}>{fmtEur(somValue, de)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ marginTop: '3mm', fontSize: '7.5pt', color: COLORS.slate500, lineHeight: 1.4, textAlign: 'center' }}>
|
||||
{de ? 'Verschachtelte Marktanteile (TAM ⊃ SAM ⊃ SOM)' : 'Nested market shares (TAM ⊃ SAM ⊃ SOM)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '3mm' }}>{de ? 'Kernsegment: Maschinen- und Anlagenbau DACH' : 'Core segment: Machine & plant manufacturing DACH'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Kennzahl' : 'Metric', width: '50%' },
|
||||
{ header: de ? 'Wert' : 'Value', numeric: true },
|
||||
]}
|
||||
rows={[
|
||||
[de ? 'Unternehmen DACH' : 'Companies DACH', '~6.500'],
|
||||
[de ? 'Davon 10–500 MA (Zielgröße)' : 'Of which 10–500 emp. (target)', '~4.200'],
|
||||
[de ? 'Beschäftigte gesamt' : 'Total employees', '~1,3 Mio.'],
|
||||
[de ? 'Umsatz Branche p.a.' : 'Industry revenue p.a.', de ? '~€280 Mrd.' : '~€280B'],
|
||||
[de ? 'Compliance-Budget Ø' : 'Avg. compliance budget', de ? '€50–150k / Jahr' : '€50–150k / yr'],
|
||||
[de ? 'Validierte ARR Top-10' : 'Validated ARR top-10', '>$1,1 Mrd.'],
|
||||
]}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
{/* RIGHT: stacked info cards + callout */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: '3.5mm' }}>
|
||||
{cards.map((c) => {
|
||||
const isAmber = c.accent === 'amber'
|
||||
const isSoft = c.accent === 'violet-soft'
|
||||
const stroke = isAmber ? COLORS.amber600 : isSoft ? COLORS.violet500 : COLORS.violet700
|
||||
const bg = isAmber ? COLORS.amber50 : isSoft ? COLORS.violet50 : 'transparent'
|
||||
const kickerColor = isAmber ? COLORS.amber700 : COLORS.violet700
|
||||
const valueColor = isAmber ? COLORS.amber700 : COLORS.slate900
|
||||
return (
|
||||
<div key={c.key} style={{ borderLeft: `3px solid ${stroke}`, background: bg, padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '3mm' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.18em', color: kickerColor, textTransform: 'uppercase' }}>{c.key}</span>
|
||||
{c.core && (<span style={{ fontSize: '7pt', fontWeight: 700, color: COLORS.amber700, background: '#fff', border: `1px solid ${COLORS.amber600}`, padding: '0.5mm 1.5mm', letterSpacing: '0.06em', textTransform: 'uppercase' }}>{de ? '← unser Kernmarkt' : '← our core market'}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginTop: '1mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: valueColor, lineHeight: 1, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums' }}>{c.value}</div>
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate500, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>+{c.growth}% CAGR</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.4 }}>{c.desc}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: '5mm' }}>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Callout tone="accent" label={de ? 'Warum Maschinenbau zuerst' : 'Why manufacturing first'}>
|
||||
{de
|
||||
? 'Höchste Regulierungsdichte (DSGVO + AI Act + CRA + Maschinen-VO + ProdSG + LkSG) bei gleichzeitig kleinem Compliance-Team. Klare Schmerzpunkte. Bekannte Vertriebskanäle (VDMA, IHK, Messen).'
|
||||
@@ -152,11 +186,43 @@ export function PrintMilestonesPage({ milestones, lang, pageNum, totalPages, ver
|
||||
|
||||
/* ===== TEAM ===== */
|
||||
|
||||
// Tuple shape: [LucideIcon, de_label, en_label]
|
||||
type TeamBullet = [typeof Briefcase, string, string]
|
||||
|
||||
const TEAM_INFO: Array<{ tagline: [string, string]; bullets: TeamBullet[] }> = [
|
||||
{
|
||||
tagline: [
|
||||
'Diplom-Ökonom mit 20+ Jahren Industrie- und Digitalisierungs-Erfahrung.',
|
||||
'Business economist with 20+ years in industry and digital transformation.',
|
||||
],
|
||||
bullets: [
|
||||
[Briefcase, '20+ Jahre Industrie, Strategie & Digitalisierung', '20+ yrs industry, strategy & digital transformation'],
|
||||
[RefreshCw, 'Aufbau IoT-, Blockchain- & KI-Plattformen', 'Built IoT, blockchain & AI platforms'],
|
||||
[Handshake, 'M&A: 4 Übernahmen & Beteiligungen geführt', 'M&A: led 4 acquisitions & investments'],
|
||||
[Scale, 'Regulatorik: DSGVO, MiCAR, CRA, Data Act', 'Regulatory: GDPR, MiCAR, CRA, Data Act'],
|
||||
[Lightbulb, '12 erteilte Patente (Erfinder/Miterfinder)', '12 granted patents (inventor / co-inventor)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
tagline: [
|
||||
'Engineering Leader mit 15+ Jahren in Fintech, Web3 und Enterprise-KI.',
|
||||
'Engineering leader with 15+ years across fintech, Web3 and enterprise AI.',
|
||||
],
|
||||
bullets: [
|
||||
[Code, '15+ Jahre Engineering Leadership — Fintech, Web3, KI', '15+ yrs engineering leadership — fintech, Web3, AI'],
|
||||
[TrendingUp, 'Engineering-Org skaliert: 6 → 60 in 18 Monaten', 'Scaled engineering org: 6 → 60 in 18 months'],
|
||||
[CreditCard, 'ETOPay SaaS-Payment-Infrastruktur entwickelt', 'Built ETOPay SaaS payment infrastructure'],
|
||||
[ShieldCheck, 'MiCA-Compliance-Strategie ViviSwap', 'MiCA compliance strategy for ViviSwap'],
|
||||
[Cpu, 'Embedded Rust (Cortex-M) + Full-Stack TypeScript', 'Embedded Rust (Cortex-M) + full-stack TypeScript'],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }: SlideBase & { team: PitchTeamMember[] }) {
|
||||
const de = lang === 'de'
|
||||
const members = team && team.length ? team : [
|
||||
{ id: 1, name: 'Benjamin Bönisch', role_de: 'CEO & Co-Founder', role_en: 'CEO & Co-Founder', bio_de: 'Mehrfacher Gründer mit Fokus auf B2B-SaaS und Vertrieb. Ehemals Geschäftsführer und Vertriebsleiter. Tiefe Verankerung im Maschinenbau-Netzwerk DACH.', bio_en: 'Serial founder focused on B2B SaaS and sales. Former CEO and VP Sales. Deep network in DACH manufacturing.', equity_pct: 37.3, expertise: ['B2B Sales', 'Go-to-Market', 'Manufacturing', 'Operations'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 2, name: 'Sharang Parnerkar', role_de: 'CTO & Co-Founder', role_en: 'CTO & Co-Founder', bio_de: 'Ex-Anthropic, Ex-Google. Distributed systems, KI-Infrastruktur, RAG-Pipelines. Open-Source-Contributor. Hat die gesamte Plattform-Architektur entworfen und 500K+ LoC implementiert.', bio_en: 'Ex-Anthropic, ex-Google. Distributed systems, AI infrastructure, RAG pipelines. Open-source contributor. Designed the entire platform architecture and implemented 500K+ LoC.', equity_pct: 37.3, expertise: ['AI Infrastructure', 'Distributed Systems', 'RAG', 'Go/Python/TypeScript'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 1, name: 'Benjamin Bönisch', role_de: 'CEO & Co-Founder', role_en: 'CEO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['B2B Sales', 'Go-to-Market', 'Manufacturing', 'Operations'], linkedin_url: '', photo_url: '' },
|
||||
{ id: 2, name: 'Sharang Parnerkar', role_de: 'CTO & Co-Founder', role_en: 'CTO & Co-Founder', bio_de: '', bio_en: '', equity_pct: 37.3, expertise: ['AI Infrastructure', 'Distributed Systems', 'RAG', 'Go/Python/TypeScript'], linkedin_url: '', photo_url: '' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -166,6 +232,10 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
|
||||
{[0, 1].map(idx => {
|
||||
const m = members[idx]
|
||||
if (!m) return null
|
||||
const info = TEAM_INFO[idx]
|
||||
// Icon tile palette: violet for first founder, amber for second
|
||||
const tileBg = idx === 0 ? COLORS.violet50 : COLORS.amber50
|
||||
const tileColor = idx === 0 ? COLORS.violet700 : COLORS.amber700
|
||||
return (
|
||||
<div key={idx} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.indigo600}`, padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', gap: '5mm', alignItems: 'flex-start', marginBottom: '4mm' }}>
|
||||
@@ -190,7 +260,20 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
|
||||
<div style={{ fontSize: '10pt', fontWeight: 600, color: COLORS.indigo600 }}>{de ? m.role_de : m.role_en}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.55, flex: 1 }}>{de ? m.bio_de : m.bio_en}</div>
|
||||
{info && (
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.slate700, fontWeight: 600, lineHeight: 1.4, marginBottom: '3mm' }}>{info.tagline[de ? 0 : 1]}</div>
|
||||
)}
|
||||
{/* Bulleted skill list with icons */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
{info?.bullets.map(([IconComp, deLabel, enLabel], bi) => (
|
||||
<div key={bi} style={{ display: 'flex', alignItems: 'flex-start', gap: '3mm' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', flexShrink: 0, background: tileBg, color: tileColor, display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<IconComp size={13} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: '9pt', color: COLORS.slate700, lineHeight: 1.4, paddingTop: '1mm' }}>{de ? deLabel : enLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '4mm', paddingTop: '3mm', borderTop: `1px solid ${COLORS.slate200}` }}>
|
||||
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Expertise' : 'Expertise'}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.5mm' }}>
|
||||
@@ -238,7 +321,7 @@ function formatFunding(amount: number): string {
|
||||
|
||||
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
|
||||
const de = lang === 'de'
|
||||
const amount = funding?.amount_eur || 1_000_000
|
||||
const amount = funding?.amount_eur || 400_000
|
||||
const instrument = funding?.instrument || (de ? 'Wandeldarlehen' : 'Convertible Loan')
|
||||
const isConvertible = (instrument || '').toLowerCase().includes('wandeldarlehen') ||
|
||||
(instrument || '').toLowerCase().includes('convertible') ||
|
||||
|
||||
@@ -113,22 +113,58 @@ function fmtEur(n: number): string {
|
||||
return `${sign}€${Math.round(abs)}`
|
||||
}
|
||||
|
||||
/* Small inline SVG sparkline. Renders an empty-state em-dash if all values are zero. */
|
||||
function Sparkline({ values, width = 56, height = 22, stroke = COLORS.violet600 }: { values: number[]; width?: number; height?: number; stroke?: string }) {
|
||||
const allZero = values.every(v => v === 0)
|
||||
if (allZero || values.length < 2) {
|
||||
return <span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate300, fontWeight: 700, lineHeight: 1 }}>—</span>
|
||||
}
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
const range = max - min || 1
|
||||
const stepX = width / (values.length - 1)
|
||||
const padY = 2
|
||||
const innerH = height - padY * 2
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * stepX
|
||||
const y = padY + innerH - ((v - min) / range) * innerH
|
||||
return { x, y }
|
||||
})
|
||||
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
|
||||
const last = points[points.length - 1]
|
||||
// Area fill polygon for soft tone under the line
|
||||
const area = `${path} L${last.x.toFixed(2)},${height} L0,${height} Z`
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', overflow: 'visible' }}>
|
||||
<path d={area} fill={stroke} fillOpacity={0.08} />
|
||||
<path d={path} fill="none" stroke={stroke} strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={last.x} cy={last.y} r={1.6} fill={stroke} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrintKPIHeroPage({ fmResults, lang, pageNum, totalPages, versionName }: SlideBase & { fmResults: FMResult[] }) {
|
||||
const de = lang === 'de'
|
||||
const kpis = computeAnnualKPIs(fmResults)
|
||||
const k26 = kpis.find(k => k.year === 2026)
|
||||
const k30 = kpis.find(k => k.year === 2030)
|
||||
const breakEvenYear = kpis.find(k => k.ebit > 0)?.year
|
||||
const series = (pick: (k: typeof kpis[number]) => number): number[] =>
|
||||
[2026, 2027, 2028, 2029, 2030].map(y => {
|
||||
const row = kpis.find(k => k.year === y)
|
||||
return row ? pick(row) : 0
|
||||
})
|
||||
|
||||
const tiles = k26 && k30 ? [
|
||||
{ label: 'ARR', start: fmtEur(k26.arr), end: fmtEur(k30.arr), endColor: COLORS.violet600 },
|
||||
{ label: de ? 'Kunden' : 'Customers', start: k26.customers.toLocaleString('de-DE'), end: k30.customers.toLocaleString('de-DE'), endColor: COLORS.violet600 },
|
||||
{ label: de ? 'ARPU / Mo' : 'ARPU / mo', start: fmtEur(k26.arpu), end: fmtEur(k30.arpu), endColor: COLORS.slate900 },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', start: String(k26.employees), end: String(k30.employees), endColor: COLORS.slate900 },
|
||||
{ label: de ? 'Bruttomarge' : 'Gross margin', start: `${k26.grossMargin}%`, end: `${k30.grossMargin}%`, endColor: COLORS.emerald700 },
|
||||
{ label: 'EBIT', start: fmtEur(k26.ebit), end: fmtEur(k30.ebit), endColor: k30.ebit >= 0 ? COLORS.emerald700 : COLORS.red700 },
|
||||
{ label: de ? 'Netto-Ergebnis' : 'Net income', start: fmtEur(k26.netIncome), end: fmtEur(k30.netIncome), endColor: k30.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700 },
|
||||
{ label: de ? 'Cash (Dez)' : 'Cash (Dec)', start: fmtEur(k26.cashBalance), end: fmtEur(k30.cashBalance), endColor: COLORS.emerald700 },
|
||||
type Tile = { label: string; start: string; end: string; endColor: string; series: number[]; hideStart?: boolean; stroke?: string }
|
||||
const tiles: Tile[] = k26 && k30 ? [
|
||||
{ label: 'ARR', start: fmtEur(k26.arr), end: fmtEur(k30.arr), endColor: COLORS.violet600, series: series(k => k.arr), hideStart: k26.arr === 0 },
|
||||
{ label: de ? 'Kunden' : 'Customers', start: k26.customers.toLocaleString('de-DE'), end: k30.customers.toLocaleString('de-DE'), endColor: COLORS.violet600, series: series(k => k.customers), hideStart: k26.customers === 0 },
|
||||
{ label: de ? 'ARPU / Mo' : 'ARPU / mo', start: fmtEur(k26.arpu), end: fmtEur(k30.arpu), endColor: COLORS.slate900, series: series(k => k.arpu), hideStart: k26.arpu === 0 },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', start: String(k26.employees), end: String(k30.employees), endColor: COLORS.slate900, series: series(k => k.employees), hideStart: k26.employees === 0 },
|
||||
{ label: de ? 'Bruttomarge' : 'Gross margin', start: `${k26.grossMargin}%`, end: `${k30.grossMargin}%`, endColor: COLORS.emerald700, series: series(k => k.grossMargin), hideStart: k26.grossMargin === 0, stroke: COLORS.emerald600 },
|
||||
{ label: 'EBIT', start: fmtEur(k26.ebit), end: fmtEur(k30.ebit), endColor: k30.ebit >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.ebit), hideStart: k26.ebit === 0, stroke: k30.ebit >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Netto-Ergebnis' : 'Net income', start: fmtEur(k26.netIncome), end: fmtEur(k30.netIncome), endColor: k30.netIncome >= 0 ? COLORS.emerald700 : COLORS.red700, series: series(k => k.netIncome), hideStart: k26.netIncome === 0, stroke: k30.netIncome >= 0 ? COLORS.emerald600 : COLORS.red600 },
|
||||
{ label: de ? 'Cash (Dez)' : 'Cash (Dec)', start: fmtEur(k26.cashBalance), end: fmtEur(k30.cashBalance), endColor: COLORS.emerald700, series: series(k => k.cashBalance), hideStart: k26.cashBalance === 0, stroke: COLORS.emerald600 },
|
||||
] : []
|
||||
|
||||
return (
|
||||
@@ -140,13 +176,22 @@ export function PrintKPIHeroPage({ fmResults, lang, pageNum, totalPages, version
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '5mm', flex: 1, minHeight: 0 }}>
|
||||
{tiles.map((t, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `3px solid ${COLORS.violet600}`, background: '#ffffff', padding: '5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '4mm' }}>{t.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: 'auto' }}>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 600, color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{t.start}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate400, fontWeight: 700 }}>→</span>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '3.5mm' }}>{t.label}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm', marginBottom: '3.5mm' }}>
|
||||
{!t.hideStart && (
|
||||
<>
|
||||
<span style={{ fontSize: '11pt', fontWeight: 600, color: COLORS.slate400, fontVariantNumeric: 'tabular-nums' }}>{t.start}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: '11pt', color: COLORS.slate400, fontWeight: 700 }}>→</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ fontSize: '24pt', fontWeight: 800, color: t.endColor, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.025em' }}>{t.end}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: '3mm', paddingTop: '2mm', borderTop: `1px solid ${COLORS.slate100}`, fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>2026 · 2030</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: '1.5mm' }}>
|
||||
<Sparkline values={t.series} stroke={t.stroke ?? COLORS.violet600} />
|
||||
<div style={{ paddingTop: '1.5mm', borderTop: `1px solid ${COLORS.slate100}`, fontFamily: MONO, fontSize: '6.5pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>2026</span><span>2030</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,42 +208,67 @@ export function PrintTechStackPage({ lang, pageNum, totalPages, versionName }: S
|
||||
const de = lang === 'de'
|
||||
|
||||
const cats = de ? [
|
||||
{ name: 'Frontend', icon: ScanLine, items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, items: ['PostgreSQL 16', 'MongoDB', 'Qdrant Vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'KI / RAG', icon: Brain, items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code-Scanning', icon: Shield, items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Kommunikation', icon: MessageSquare, items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing Oberflächen', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & Business-Logik', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistenter Zustand', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant Vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'KI / RAG', icon: Brain, blurb: 'Inferenz & Retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code-Scanning', icon: Shield, blurb: 'Schwachstellen-Erkennung', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identität & Rechte', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Kommunikation', icon: MessageSquare, blurb: 'Echtzeit-Kanäle', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & Ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
] : [
|
||||
{ name: 'Frontend', icon: ScanLine, items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, items: ['PostgreSQL 16', 'MongoDB', 'Qdrant vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'AI / RAG', icon: Brain, items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code scanning', icon: Shield, items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Communication', icon: MessageSquare, items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
{ name: 'Frontend', icon: ScanLine, blurb: 'User-facing surfaces', items: ['Next.js 15', 'React 19', 'Tailwind CSS', 'Framer Motion', 'Dioxus (Rust)'] },
|
||||
{ name: 'Backend', icon: Wrench, blurb: 'API & business logic', items: ['Go/Gin', 'Python/FastAPI', 'Rust/Axum', 'OpenAPI'] },
|
||||
{ name: 'Storage', icon: Database, blurb: 'Persistent state', items: ['PostgreSQL 16', 'MongoDB', 'Qdrant vector DB', 'Valkey (cache)'] },
|
||||
{ name: 'AI / RAG', icon: Brain, blurb: 'Inference & retrieval', items: ['LiteLLM', 'Qwen3-32B', 'DeepSeek-R1', 'Sentence-Transformers', 'LangGraph'] },
|
||||
{ name: 'Code scanning', icon: Shield, blurb: 'Vulnerability detection', items: ['Semgrep', 'Gitleaks', 'Syft', 'Trivy', 'CycloneDX'] },
|
||||
{ name: 'Auth & SSO', icon: Lock, blurb: 'Identity & permissions', items: ['Keycloak', 'OIDC', 'OPA (policies)'] },
|
||||
{ name: 'Communication', icon: MessageSquare, blurb: 'Real-time channels', items: ['Matrix (chat)', 'Jitsi (video)', 'Mailpit'] },
|
||||
{ name: 'DevOps', icon: ShieldCheck, blurb: 'Build & ship', items: ['Gitea', 'Woodpecker CI', 'HashiCorp Vault', 'Orca', 'Docker Compose'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<Page kicker="26" section={de ? 'ANHANG · TECH-STACK' : 'APPENDIX · TECH STACK'} title={de ? '8 Kategorien. Polyglott. 100 % Open Source.' : '8 categories. Polyglot. 100 % open source.'} subtitle={de ? 'Alle Komponenten mit kommerziell nutzbarer Lizenz (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). Keine GPL/AGPL. Keine US-SaaS-Abhängigkeit.' : 'All components carry a commercially usable license (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). No GPL/AGPL. No US SaaS dependency.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
<Page kicker="26" section={de ? 'ANHANG · TECH-STACK' : 'APPENDIX · TECH STACK'} title={de ? '8 Kategorien. Polyglott. 100% Open Source.' : '8 categories. Polyglot. 100% open source.'} subtitle={de ? 'Alle Komponenten mit kommerziell nutzbarer Lizenz (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). Keine GPL/AGPL. Keine US-SaaS-Abhängigkeit.' : 'All components carry a commercially usable license (MIT, Apache-2.0, BSD, ISC, MPL-2.0, LGPL). No GPL/AGPL. No US SaaS dependency.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: '1fr 1fr', gap: '4mm', flex: 1, minHeight: 0 }}>
|
||||
{cats.map((c, i) => {
|
||||
const Icon = c.icon
|
||||
const num = String(i + 1).padStart(2, '0')
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.violet600}`, background: '#ffffff', padding: '4mm 5mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '3mm', marginBottom: '3mm' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.violet50, borderRadius: '2pt', display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<Icon size={15} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div style={{ fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15 }}>{c.name}</div>
|
||||
<div key={i} style={{
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '4pt',
|
||||
background: `linear-gradient(160deg, ${COLORS.violet50} 0%, #ffffff 55%)`,
|
||||
padding: '4mm 4mm 3.5mm',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: '0 4px 12px rgba(124,58,237,0.08)',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '14mm', height: '14mm', borderRadius: '3pt',
|
||||
background: `linear-gradient(135deg, ${COLORS.violet400} 0%, ${COLORS.violet600} 100%)`,
|
||||
boxShadow: '0 4px 10px rgba(124,58,237,0.28)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#ffffff', flexShrink: 0, marginBottom: '2.5mm',
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>
|
||||
<Icon size={24} strokeWidth={1.6} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '6.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.22em', marginBottom: '0.5mm' }}>{num}</div>
|
||||
<div style={{ fontSize: '12pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1.1, letterSpacing: '-0.005em', textAlign: 'center' }}>{c.name}</div>
|
||||
<div style={{ fontSize: '7pt', fontStyle: 'italic', color: COLORS.slate500, lineHeight: 1.3, textAlign: 'center', marginTop: '0.8mm', marginBottom: '2.5mm' }}>{c.blurb}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.2mm', justifyContent: 'center', marginTop: 'auto' }}>
|
||||
{c.items.map((it, j) => (
|
||||
<div key={j} style={{ fontFamily: MONO, fontSize: '8pt', color: COLORS.slate700, padding: '1.2mm 0', borderTop: j > 0 ? `1px solid ${COLORS.slate100}` : 'none', lineHeight: 1.3 }}>{it}</div>
|
||||
<span key={j} style={{
|
||||
fontFamily: MONO, fontSize: '7.5pt', fontWeight: 600,
|
||||
color: COLORS.slate700,
|
||||
background: COLORS.violet50,
|
||||
border: `1px solid ${COLORS.violet200}`,
|
||||
borderRadius: '999px',
|
||||
padding: '0.8mm 2mm',
|
||||
lineHeight: 1.15,
|
||||
WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact',
|
||||
}}>{it}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Language, PitchProduct } from '@/lib/types'
|
||||
import { Page, Bullets, Callout, COLORS, DataTable } from './PrintLayout'
|
||||
import { Page, Bullets, Callout, COLORS } from './PrintLayout'
|
||||
import { LoopDiagram } from './PrintDiagrams'
|
||||
import { getDetails } from '@/components/slides/USPSlide.data'
|
||||
import {
|
||||
ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck,
|
||||
AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare,
|
||||
Shield, Layers, Globe, FileSearch, Sparkles, Repeat, ArrowLeftRight, Infinity,
|
||||
Lock, Heart, Banknote, ShoppingCart, Wifi, BookOpen, Landmark, Building2,
|
||||
Factory, Cpu, CheckCircle2, Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -105,15 +107,16 @@ export function PrintUSPPage2({ lang, pageNum, totalPages, versionName }: SlideB
|
||||
|
||||
/* ===== REGULATORY LANDSCAPE ===== */
|
||||
|
||||
const CATEGORY_ICONS: LucideIcon[] = [Lock, Shield, Brain, Globe, ShieldCheck, Banknote, Heart, Users]
|
||||
const RL_CATEGORIES_DE = [
|
||||
{ name: 'Datenschutz', sample: 'DSGVO · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
{ name: 'Cybersicherheit', sample: 'NIS2 · IT-SiG · BSIG · KRITIS-Verordnung', count: 47 },
|
||||
{ name: 'Cybersicherheit', sample: 'NIS2 · IT-SiG · BSIG · KRITIS-VO', count: 47 },
|
||||
{ name: 'KI-Regulierung', sample: 'AI Act · KI-Haftungsrichtlinie', count: 18 },
|
||||
{ name: 'Digitale Märkte', sample: 'DMA · DSA · Data Act · Data Governance Act', count: 24 },
|
||||
{ name: 'Produktsicherheit', sample: 'CRA · Maschinenverordnung · Produktsicherheitsgesetz', count: 41 },
|
||||
{ name: 'Digitale Märkte', sample: 'DMA · DSA · Data Act · DGA', count: 24 },
|
||||
{ name: 'Produktsicherheit', sample: 'CRA · MaschinenVO · ProdSG', count: 41 },
|
||||
{ name: 'Finanzregulierung', sample: 'DORA · MiCA · FinmadiG · KWG', count: 53 },
|
||||
{ name: 'Gesundheitsdaten', sample: 'MDR · IVDR · PatDG · Krankenhausgesetz', count: 28 },
|
||||
{ name: 'Verbraucherschutz', sample: 'UWG · BGB · Geschäftsgeheimnisschutz · HinSchG', count: 36 },
|
||||
{ name: 'Gesundheitsdaten', sample: 'MDR · IVDR · PatDG · KHG', count: 28 },
|
||||
{ name: 'Verbraucherschutz', sample: 'UWG · BGB · GeschGehG · HinSchG', count: 36 },
|
||||
]
|
||||
const RL_CATEGORIES_EN = [
|
||||
{ name: 'Data Privacy', sample: 'GDPR · ePrivacy · TTDSG · BDSG', count: 32 },
|
||||
@@ -125,6 +128,7 @@ const RL_CATEGORIES_EN = [
|
||||
{ name: 'Health Data', sample: 'MDR · IVDR · PatDG · Hospital Act', count: 28 },
|
||||
{ name: 'Consumer Prot.', sample: 'UWG · BGB · Trade Secrets · HinSchG', count: 36 },
|
||||
]
|
||||
const INDUSTRY_ICONS: LucideIcon[] = [Building2, Factory, Heart, Banknote, ShoppingCart, Cpu, Wifi, Brain, ShieldCheck, BookOpen, Landmark]
|
||||
const INDUSTRIES_DE = ['Alle Unternehmen', 'Maschinenbau', 'Gesundheit', 'Finanzsektor', 'E-Commerce', 'Technologie', 'IoT / Hardware', 'KI-Anbieter', 'Krit. Infrastruktur', 'Medien', 'Öffentl. Sektor']
|
||||
const INDUSTRIES_EN = ['All companies', 'Manufacturing', 'Healthcare', 'Finance', 'E-Commerce', 'Technology', 'IoT / Hardware', 'AI Providers', 'Critical Infra.', 'Media', 'Public Sector']
|
||||
|
||||
@@ -151,32 +155,46 @@ export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versio
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '6mm' }}>
|
||||
{/* Categories */}
|
||||
<div style={{ marginTop: '5mm', flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '1.55fr 1fr', gap: '6mm' }}>
|
||||
{/* Categories — 2x4 card grid with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Acht Regulierungs-Kategorien' : 'Eight regulatory categories'}</div>
|
||||
<DataTable
|
||||
cols={[
|
||||
{ header: de ? 'Kategorie' : 'Category', width: '32%' },
|
||||
{ header: de ? 'Beispiele' : 'Examples' },
|
||||
{ header: '#', width: '12%', numeric: true },
|
||||
]}
|
||||
rows={cats.map(c => [c.name, c.sample, c.count])}
|
||||
dense
|
||||
highlightFirstCol
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{cats.map((c, i) => {
|
||||
const CIcon = CATEGORY_ICONS[i]
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2mm 2.5mm', display: 'flex', alignItems: 'flex-start', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '8mm', height: '8mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><CIcon size={14} strokeWidth={1.6} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '2mm' }}>
|
||||
<div style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.2 }}>{c.name}</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '7pt', fontWeight: 700, color: COLORS.violet600, fontVariantNumeric: 'tabular-nums' }}>{c.count}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '6.5pt', color: COLORS.slate500, lineHeight: 1.3, marginTop: '0.5mm' }}>{c.sample}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industries */}
|
||||
{/* Industries — cards with icons */}
|
||||
<div>
|
||||
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '2mm' }}>{de ? 'Zehn Branchen-Profile' : 'Ten industry profiles'}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2mm' }}>
|
||||
{industries.map((ind, i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, padding: '2.5mm 3mm', fontSize: '8.5pt', color: COLORS.slate800, fontWeight: i === 1 ? 700 : 500 }}>
|
||||
{i === 1 && <span style={{ fontSize: '6.5pt', color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, display: 'block', marginBottom: '1mm' }}>{de ? 'Kernfokus' : 'Core focus'}</span>}
|
||||
{ind}
|
||||
</div>
|
||||
))}
|
||||
{industries.map((ind, i) => {
|
||||
const IIcon = INDUSTRY_ICONS[i]
|
||||
const isFocus = i === 1
|
||||
return (
|
||||
<div key={i} style={{ border: `1px solid ${isFocus ? COLORS.violet300 : COLORS.slate200}`, background: isFocus ? COLORS.violet50 : '#ffffff', padding: '2mm 2.5mm', display: 'flex', alignItems: 'center', gap: '2mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '6mm', height: '6mm', display: 'flex', alignItems: 'center', justifyContent: 'center', color: isFocus ? COLORS.violet700 : COLORS.slate500, flexShrink: 0 }}><IIcon size={12} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{isFocus && <div style={{ fontSize: '6pt', color: COLORS.violet700, textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, lineHeight: 1 }}>{de ? 'Kernfokus' : 'Core focus'}</div>}
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate800, fontWeight: isFocus ? 700 : 500, lineHeight: 1.2, marginTop: isFocus ? '0.5mm' : 0 }}>{ind}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,10 +212,7 @@ export function PrintRegulatoryLandscapePage({ lang, pageNum, totalPages, versio
|
||||
|
||||
/* ===== PRODUCT / MODULAR TOOLKIT ===== */
|
||||
|
||||
const MODULE_ICONS: LucideIcon[] = [
|
||||
ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck,
|
||||
AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare,
|
||||
]
|
||||
const MODULE_ICONS: LucideIcon[] = [ScanLine, ShieldCheck, FileText, ClipboardCheck, Users, UserCheck, AlertTriangle, Brain, Target, GraduationCap, TrendingUp, MessageSquare]
|
||||
const MODULES_FULL_DE = [
|
||||
{ name: 'Code Security', desc: 'SAST · DAST · SBOM · Container · Secrets · Pentesting', features: ['Bei jedem Push', 'Auto-Fix LLM', 'CI/CD-integriert'] },
|
||||
{ name: 'CE-SW-Risikobeurteilung', desc: 'CE-Kennzeichnung für Maschinen mit Software-Anteil', features: ['Maschinen-VO', 'CRA-konform', 'Code-Basis-Analyse'] },
|
||||
@@ -294,48 +309,64 @@ export function PrintHowItWorksPage({ lang, pageNum, totalPages, versionName }:
|
||||
return (
|
||||
<Page kicker="08" section={de ? 'SO FUNKTIONIERT\'S' : 'HOW IT WORKS'} title={de ? 'In 4 Schritten zur kontinuierlichen Compliance.' : 'Continuous compliance in 4 steps.'} subtitle={de ? 'Vom Vertrag bis zur Audit-Bereitschaft in der Regel <30 Tage. Kein Excel, kein Pentest-Vendor, keine manuelle Dokumentenpflege.' : 'From contract to audit-ready typically in <30 days. No Excel, no pentest vendor, no manual document maintenance.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
|
||||
|
||||
{/* 4-step rail: numbered violet circles on a horizontal connector line,
|
||||
title + body underneath each. Replaces the floating-arrow StepStrip. */}
|
||||
<div style={{ position: 'relative', marginTop: '4mm', flexShrink: 0 }}>
|
||||
{/* connector line (behind the circles) */}
|
||||
<div style={{ position: 'absolute', top: '7mm', left: '7mm', right: '7mm', height: '2px', background: `linear-gradient(90deg, ${COLORS.violet600} 0%, ${COLORS.violet400} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{/* 4-step rail: numbered violet circles on a horizontal connector line */}
|
||||
<div style={{ position: 'relative', marginTop: '3mm', flexShrink: 0 }}>
|
||||
<div style={{ position: 'absolute', top: '6mm', left: '6mm', right: '6mm', height: '2px', background: `linear-gradient(90deg, ${COLORS.violet600} 0%, ${COLORS.violet400} 100%)`, opacity: 0.85, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm' }}>
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
{/* number circle on the rail */}
|
||||
<div style={{ width: '14mm', height: '14mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14pt', fontWeight: 800, letterSpacing: '-0.01em', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{s.n}</div>
|
||||
<div style={{ marginTop: '3mm', fontSize: '12pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ marginTop: '2mm', fontSize: '8.5pt', color: COLORS.slate700, lineHeight: 1.5 }}>{s.d}</div>
|
||||
<div style={{ width: '12mm', height: '12mm', borderRadius: '50%', background: COLORS.violet600, color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12pt', fontWeight: 800, letterSpacing: '-0.01em', boxShadow: `0 0 0 4px ${COLORS.violet50}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{s.n}</div>
|
||||
<div style={{ marginTop: '2.5mm', fontSize: '11pt', fontWeight: 700, color: COLORS.slate900, lineHeight: 1.15, letterSpacing: '-0.005em' }}>{s.t}</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.45 }}>{s.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fill space between steps and footer with a visual timeline */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div style={{ flexShrink: 0, marginBottom: '2mm' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7.5pt', fontWeight: 700, color: COLORS.violet600, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '4mm' }}>
|
||||
{de ? 'Time-to-Value · Median 14 Tage · Worst Case 28 Tage' : 'Time-to-Value · Median 14 days · Worst case 28 days'}
|
||||
</div>
|
||||
{/* dotted timeline with 5 day markers */}
|
||||
<div style={{ position: 'relative', height: '14mm' }}>
|
||||
{/* the rail */}
|
||||
{/* Day-marker timeline — directly under steps, ~3mm gap */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<div style={{ position: 'relative', height: '13mm' }}>
|
||||
<div style={{ position: 'absolute', left: '7mm', right: '7mm', top: '5mm', height: '1.5px', background: `repeating-linear-gradient(90deg, ${COLORS.violet400} 0, ${COLORS.violet400} 2mm, transparent 2mm, transparent 4mm)`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', height: '100%' }}>
|
||||
{days.map((d, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
|
||||
{/* day marker pill */}
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.violet700, background: COLORS.violet50, padding: '1mm 3mm', borderRadius: '99pt', border: `1px solid ${COLORS.violet300}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{d}</div>
|
||||
{/* dot on rail */}
|
||||
<div style={{ width: '3mm', height: '3mm', borderRadius: '50%', background: COLORS.violet600, marginTop: '1mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
|
||||
{/* label below */}
|
||||
<div style={{ marginTop: '2mm', fontSize: '7.5pt', color: COLORS.slate700, textAlign: 'center', fontWeight: 600 }}>{labels[i]}</div>
|
||||
<div style={{ marginTop: '1.5mm', fontSize: '7pt', color: COLORS.slate700, textAlign: 'center', fontWeight: 600 }}>{labels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time-to-value callout */}
|
||||
<div style={{ flexShrink: 0, marginTop: '3mm' }}>
|
||||
<Callout tone="accent" label={de ? 'Typische Time-to-Value' : 'Typical time-to-value'}>
|
||||
{de
|
||||
? 'Median 14 Tage · Worst Case 28 Tage. Vom Vertrag bis zur Audit-Bereitschaft typischerweise unter 30 Tagen.'
|
||||
: 'Median 14 days · worst case 28 days. From contract to audit-readiness typically under 30 days.'}
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
{/* What you get on day N — 4-column benefit block, fills bottom third */}
|
||||
<div style={{ flex: 1, minHeight: 0, marginTop: '4mm', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>
|
||||
{de ? 'Was Sie wann bekommen' : 'What you get, when'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3mm' }}>
|
||||
{([
|
||||
[Shield, de ? 'Risikoanalysen automatisch ab Tag 3' : 'Risk analyses automatic from day 3'],
|
||||
[FileText, de ? 'VVT / TOMs / DSFA generiert ab Tag 14' : 'RoPA / TOMs / DPIA generated from day 14'],
|
||||
[CheckCircle2, de ? 'Audit-Trail vollständig ab Tag 30' : 'Audit trail complete from day 30'],
|
||||
[Zap, de ? 'Continuous Scanning bei jedem Push' : 'Continuous scanning on every push'],
|
||||
] as [LucideIcon, string][]).map(([Icon, t], i) => (
|
||||
<div key={i} style={{ border: `1px solid ${COLORS.slate200}`, borderTop: `2px solid ${COLORS.violet600}`, padding: '2.5mm 3mm', display: 'flex', alignItems: 'flex-start', gap: '2.5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ width: '7mm', height: '7mm', background: COLORS.violet50, display: 'flex', alignItems: 'center', justifyContent: 'center', color: COLORS.violet600, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}><Icon size={13} strokeWidth={1.7} /></div>
|
||||
<div style={{ flex: 1, fontSize: '8pt', color: COLORS.slate800, fontWeight: 600, lineHeight: 1.35 }}>{t}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -347,42 +378,9 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
const MONO = "'JetBrains Mono', ui-monospace, monospace"
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
target: de ? '< 25 Mitarbeiter · Basis-Module' : '< 25 employees · basic modules',
|
||||
price: '€3.600',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['DSGVO + Audit + DSR-Workflow', 'Compliance Scanner (CI/CD)', 'EU-Hosting · BSI C5', 'E-Mail-Support']
|
||||
: ['GDPR + Audit + DSR workflow', 'Compliance Scanner (CI/CD)', 'EU hosting · BSI C5', 'Email support'],
|
||||
tint: COLORS.violet400,
|
||||
bg: COLORS.violet50,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
target: de ? '25–250 Mitarbeiter · alle Module' : '25–250 employees · all modules',
|
||||
price: '€18.000',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['Alle 12 Module', 'Priority-Support · Onboarding-Call', 'CE-Software-Risiko + Tender Matching', 'Dedicated CSM · 14-tägige Reviews', 'Custom-Integrationen (Jira, GitLab)']
|
||||
: ['All 12 modules', 'Priority support · onboarding call', 'CE software risk + tender matching', 'Dedicated CSM · biweekly reviews', 'Custom integrations (Jira, GitLab)'],
|
||||
tint: COLORS.violet600,
|
||||
bg: `linear-gradient(180deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
target: de ? '250+ Mitarbeiter · maßgeschneidert' : '250+ employees · custom',
|
||||
price: de ? 'ab €50.000' : 'from €50k',
|
||||
unit: de ? '/ Jahr' : '/ year',
|
||||
features: de
|
||||
? ['Alles aus Professional', 'SLA · Custom Contract', 'On-Premise / Air-Gap (Mac Mini/Studio)', 'Dedicated Customer Engineering', 'Multi-Region Audit-Trail']
|
||||
: ['Everything in Professional', 'SLA · custom contract', 'On-premise / air-gap (Mac Mini/Studio)', 'Dedicated customer engineering', 'Multi-region audit trail'],
|
||||
tint: COLORS.amber600,
|
||||
bg: COLORS.amber50,
|
||||
featured: false,
|
||||
},
|
||||
{ name: 'Starter', target: de ? '< 25 Mitarbeiter · Basis-Module' : '< 25 employees · basic modules', price: '€3.600', unit: de ? '/ Jahr' : '/ year', features: de ? ['DSGVO + Audit + DSR-Workflow', 'Compliance Scanner (CI/CD)', 'EU-Hosting · BSI C5', 'E-Mail-Support'] : ['GDPR + Audit + DSR workflow', 'Compliance Scanner (CI/CD)', 'EU hosting · BSI C5', 'Email support'], tint: COLORS.violet400, bg: COLORS.violet50, featured: false },
|
||||
{ name: 'Professional', target: de ? '25–250 Mitarbeiter · alle Module' : '25–250 employees · all modules', price: '€18.000', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alle 12 Module', 'Priority-Support · Onboarding-Call', 'CE-Software-Risiko + Tender Matching', 'Dedicated CSM · 14-tägige Reviews', 'Custom-Integrationen (Jira, GitLab)'] : ['All 12 modules', 'Priority support · onboarding call', 'CE software risk + tender matching', 'Dedicated CSM · biweekly reviews', 'Custom integrations (Jira, GitLab)'], tint: COLORS.violet600, bg: `linear-gradient(180deg, ${COLORS.violet50} 0%, #ffffff 60%, ${COLORS.violet50} 100%)`, featured: true },
|
||||
{ name: 'Enterprise', target: de ? '250+ Mitarbeiter · maßgeschneidert' : '250+ employees · custom', price: de ? 'ab €50.000' : 'from €50k', unit: de ? '/ Jahr' : '/ year', features: de ? ['Alles aus Professional', 'SLA · Custom Contract', 'On-Premise / Air-Gap (Mac Mini/Studio)', 'Dedicated Customer Engineering', 'Multi-Region Audit-Trail'] : ['Everything in Professional', 'SLA · custom contract', 'On-premise / air-gap (Mac Mini/Studio)', 'Dedicated customer engineering', 'Multi-region audit trail'], tint: COLORS.amber600, bg: COLORS.amber50, featured: false },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -391,18 +389,7 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
{/* 3 product cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5mm', marginBottom: '5mm', position: 'relative' }}>
|
||||
{tiers.map((t) => (
|
||||
<div key={t.name} style={{
|
||||
background: t.bg,
|
||||
border: `${t.featured ? '2px' : '1px'} solid ${t.tint}`,
|
||||
borderRadius: '6pt',
|
||||
padding: '5mm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
boxShadow: t.featured ? `0 6px 18px ${COLORS.violet600}25` : 'none',
|
||||
WebkitPrintColorAdjust: 'exact',
|
||||
printColorAdjust: 'exact',
|
||||
}}>
|
||||
<div key={t.name} style={{ background: t.bg, border: `${t.featured ? '2px' : '1px'} solid ${t.tint}`, borderRadius: '6pt', padding: '5mm', display: 'flex', flexDirection: 'column', position: 'relative', boxShadow: t.featured ? `0 6px 18px ${COLORS.violet600}25` : 'none', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
{/* Featured badge */}
|
||||
{t.featured && (
|
||||
<div style={{ position: 'absolute', top: '-3.5mm', left: '50%', transform: 'translateX(-50%)', fontFamily: MONO, fontSize: '7pt', fontWeight: 700, color: '#ffffff', background: COLORS.violet600, padding: '1mm 4mm', borderRadius: '99pt', textTransform: 'uppercase', letterSpacing: '0.18em', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{de ? 'Beliebt' : 'Popular'}</div>
|
||||
@@ -449,14 +436,37 @@ export function PrintBusinessModelPage({ lang, pageNum, totalPages, versionName
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: COLORS.emerald50, border: `1px solid ${COLORS.emerald600}`, borderRadius: '4pt', padding: '4mm 5mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '2mm' }}>{de ? 'Netto-Effekt · KMU 50 MA / Jahr 1' : 'Net effect · SME 50 emp. / Y1'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '3mm' }}>
|
||||
<div style={{ fontSize: '26pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>+€30k</div>
|
||||
<div style={{ fontSize: '9pt', color: COLORS.emerald700, fontWeight: 600 }}>{de ? 'pro KMU / Jahr' : 'per SME / yr'}</div>
|
||||
<div style={{ background: COLORS.emerald50, border: `1px solid ${COLORS.emerald600}`, borderRadius: '4pt', padding: '3mm 4mm', display: 'flex', flexDirection: 'column', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
|
||||
<div style={{ fontFamily: MONO, fontSize: '7pt', color: COLORS.emerald700, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.18em', marginBottom: '1.5mm' }}>{de ? 'Netto-Effekt · KMU 50 MA / Jahr 1' : 'Net effect · SME 50 emp. / Y1'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '2.5mm' }}>
|
||||
<div style={{ fontSize: '22pt', fontWeight: 800, color: COLORS.emerald700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em' }}>+€30k</div>
|
||||
<div style={{ fontSize: '8.5pt', color: COLORS.emerald700, fontWeight: 600 }}>{de ? 'pro KMU / Jahr' : 'per SME / yr'}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '8pt', color: COLORS.slate700, marginTop: '2mm', lineHeight: 1.4 }}>
|
||||
{de ? 'Kunde spart €55k (Pentests, CE-Risiko, Compliance-Zeit), zahlt €25k. ROI ab Tag 1.' : 'Customer saves €55k (pentests, CE risk, compliance time), pays €25k. ROI from day 1.'}
|
||||
<div style={{ fontSize: '7.5pt', color: COLORS.slate700, marginTop: '1.5mm', lineHeight: 1.4 }}>
|
||||
{de ? 'Kunde spart €55k, zahlt €25k. ROI ab Tag 1.' : 'Customer saves €55k, pays €25k. ROI from day 1.'}
|
||||
</div>
|
||||
|
||||
{/* Itemized breakdown */}
|
||||
<div style={{ marginTop: '2.5mm', display: 'flex', flexDirection: 'column' }}>
|
||||
{([
|
||||
[de ? 'Pentests (kontinuierlich, inkl.)' : 'Pentests (continuous, incl.)', '+€13k'],
|
||||
[de ? 'CE-Risiko (Code-basiert, inkl.)' : 'CE risk (code-based, incl.)', '+€9k'],
|
||||
[de ? 'Compliance-Zeit (−60%)' : 'Compliance time (−60%)', '+€15k'],
|
||||
[de ? 'Audit-Vorbereitung (auto)' : 'Audit prep (auto)', '+€9k'],
|
||||
[de ? 'Legal-Stunden (−40%)' : 'Legal hours (−40%)', '+€5k'],
|
||||
[de ? 'Schulungen (Academy inkl.)' : 'Training (Academy incl.)', '+€4k'],
|
||||
] as [string, string][]).map(([l, v], i, arr) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '2mm', padding: '1.2mm 0', borderBottom: i < arr.length - 1 ? `0.5px solid ${COLORS.emerald600}33` : 'none' }}>
|
||||
<span style={{ fontSize: '8pt', color: COLORS.slate700, lineHeight: 1.3 }}>{l}</span>
|
||||
<span style={{ fontSize: '8.5pt', fontWeight: 700, color: COLORS.emerald700, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2mm', fontSize: '7pt', color: COLORS.slate600, fontStyle: 'italic', lineHeight: 1.35 }}>
|
||||
{de
|
||||
? 'Plus Vermeidung von Bußgeldern (bis 4% Jahresumsatz) und gewonnene RFQs.'
|
||||
: 'Plus avoided fines (up to 4% annual revenue) and won RFQs.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user