Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ea958655 | |||
| f2035d79dd | |||
| f514349ccf | |||
| a71f86560b | |||
| de8314732d | |||
| bdf7773310 | |||
| a25e4aa1d4 | |||
| ecc2163b8e | |||
| af02378d29 | |||
| f8847a7a10 | |||
| 117b23db1e | |||
| d6f9a24823 | |||
| 422e4fccba | |||
| 57ec4d7544 | |||
| a4d021c658 | |||
| 269d19bbef | |||
| 30ff08c66d | |||
| 81deaf447f | |||
| a0ebc58d6d | |||
| 7498c24c9a | |||
| efba82337c | |||
| c083b309fb | |||
| eb8bf60408 | |||
| a3819490ac | |||
| 1127954fea | |||
| fa0b133012 | |||
| 1b40baebd4 | |||
| 316c03869a | |||
| 63d2acfab5 | |||
| bdeae0aca6 | |||
| 47c70a16f1 | |||
| b96d44bf6d |
82
.agent/workflows/cms-workflow.md
Normal file
82
.agent/workflows/cms-workflow.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
description: How to manage and deploy Directus CMS infrastructure changes.
|
||||
---
|
||||
|
||||
# Directus CMS Infrastructure Workflow
|
||||
|
||||
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
|
||||
|
||||
## 1. Local Development Lifecycle
|
||||
|
||||
### Starting the CMS
|
||||
To start the local Directus instance with extensions:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run up
|
||||
```
|
||||
|
||||
### Modifying Schema
|
||||
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
|
||||
2. **Take Snapshot**:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run snapshot:local
|
||||
```
|
||||
This updates `packages/cms-infra/schema/snapshot.yaml`.
|
||||
3. **Commit**: Commit the updated `snapshot.yaml`.
|
||||
|
||||
## 2. Deploying Schema Changes
|
||||
|
||||
### To Local Environment (Reconciliation)
|
||||
If you pull changes from Git and need to apply them to your local database:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run schema:apply:local
|
||||
```
|
||||
> [!IMPORTANT]
|
||||
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
|
||||
|
||||
### To Production (Infra)
|
||||
To deploy the local snapshot to the production server:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run schema:apply:infra
|
||||
```
|
||||
This script:
|
||||
1. Syncs built extensions via rsync.
|
||||
2. Injects the `snapshot.yaml` into the remote container.
|
||||
3. Runs `directus schema apply`.
|
||||
4. Restarts Directus to clear the schema cache.
|
||||
|
||||
## 3. Data Synchronization
|
||||
|
||||
### Pulling from Production
|
||||
To update your local environment with production data and assets:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run sync:pull
|
||||
```
|
||||
|
||||
### Pushing to Production
|
||||
> [!CAUTION]
|
||||
> This will overwrite production data. Use with extreme care.
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run sync:push
|
||||
```
|
||||
|
||||
## 4. Extension Management
|
||||
When modifying extensions in `packages/*-manager`:
|
||||
1. Extensions are automatically built and synced when running `npm run up`.
|
||||
2. To sync manually without restarting the stack:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run build:extensions
|
||||
```
|
||||
|
||||
## 5. Troubleshooting "Field already exists"
|
||||
If `schema:apply` fails with "Field already exists", run:
|
||||
```bash
|
||||
./scripts/cms-reconcile.sh
|
||||
```
|
||||
This script ensures the database state matches Directus's internal field registry (`directus_fields`).
|
||||
36
.env
Normal file
36
.env
Normal file
@@ -0,0 +1,36 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.8.2
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
|
||||
# Host Config (Local)
|
||||
TRAEFIK_HOST=at-mintel.localhost
|
||||
DIRECTUS_HOST=cms.localhost
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://cms.localhost
|
||||
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
|
||||
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
|
||||
CORS_ENABLED=true
|
||||
CORS_ORIGIN=true
|
||||
LOG_LEVEL=debug
|
||||
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
|
||||
# Sentry / Glitchtip
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
@@ -1,5 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.7.9
|
||||
IMAGE_TAG=v1.8.2
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
**/index.js
|
||||
**/dist/**
|
||||
packages/cms-infra/extensions/**
|
||||
packages/cms-infra/extensions/**
|
||||
@@ -1,16 +1,37 @@
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/v* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
||||
|
||||
# Run sync script
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Stage the changed files (excluding ignored files like .env)
|
||||
git add package.json packages/*/package.json apps/*/package.json .env.example
|
||||
# Check for changes in relevant files
|
||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
||||
|
||||
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||
if [[ -n "$CHANGES" ]]; then
|
||||
echo "📝 Version sync made changes. Integrating into tag..."
|
||||
|
||||
# Stage and commit
|
||||
git add $SYNC_FILES
|
||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
||||
|
||||
# Force update the local tag to point to the new commit
|
||||
git tag -f "$TAG" > /dev/null
|
||||
|
||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
||||
echo "🚀 Auto-pushing updated tag..."
|
||||
|
||||
# Push the updated tag directly (using --no-verify to avoid recursion)
|
||||
git push origin "$TAG" --force --no-verify
|
||||
|
||||
echo "✨ All done! Hook integrated the sync and pushed for you."
|
||||
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
|
||||
else
|
||||
echo "✨ Versions already in sync for $TAG."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
1
directus/uploads/directus-health-file
Normal file
1
directus/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
||||
S9WsV
|
||||
@@ -24,6 +24,12 @@ services:
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -35,7 +41,7 @@ services:
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_HOST: 'at-mintel-directus-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
@@ -53,7 +59,7 @@ services:
|
||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||
|
||||
directus-db:
|
||||
at-mintel-directus-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"cms:up": "./scripts/sync-extensions.sh && cd packages/cms-infra && npm run up -- --force-recreate",
|
||||
"cms:down": "cd packages/cms-infra && npm run down",
|
||||
"cms:logs": "cd packages/cms-infra && npm run logs",
|
||||
"dev:infra": "docker-compose up -d directus directus-db",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
@@ -52,7 +55,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.7.10",
|
||||
"version": "1.8.2",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,16 +2,13 @@
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||
"icon": "account_balance_wallet",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
@@ -20,7 +17,7 @@
|
||||
"name": "Acquisition Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
import { defineModule } from "@directus/extensions-sdk";
|
||||
import ModuleComponent from "./module.vue";
|
||||
|
||||
export default defineModule({
|
||||
id: 'acquisition-manager',
|
||||
name: 'Acquisition Manager',
|
||||
icon: 'account_balance_wallet',
|
||||
id: "acquisition-manager",
|
||||
name: "Acquisition",
|
||||
icon: "auto_awesome",
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
path: "",
|
||||
component: ModuleComponent,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
component: ModuleComponent,
|
||||
props: true
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,18 +1,445 @@
|
||||
<template>
|
||||
<private-view title="Acquisition Manager">
|
||||
<div class="acquisition-manager">
|
||||
<h1>Acquisition Manager</h1>
|
||||
<p>Modern Industrial Acquisition Management Interface</p>
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="showAddLead = true" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neuen Lead anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="lead in leads"
|
||||
:key="lead.id"
|
||||
:active="selectedLeadId === lead.id"
|
||||
class="lead-item"
|
||||
clickable
|
||||
@click="selectLead(lead.id)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="getCompanyName(lead)" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice type="success" style="margin-bottom: 16px;">
|
||||
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedLead" class="empty-state">
|
||||
<v-info title="Lead auswählen" icon="auto_awesome" center>
|
||||
Wähle einen Lead in der Navigation aus oder
|
||||
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
|
||||
<p class="subtitle">
|
||||
<v-icon name="language" x-small />
|
||||
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
||||
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
||||
</a>
|
||||
· Status: {{ selectedLead.status.toUpperCase() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button
|
||||
v-if="selectedLead.status === 'new'"
|
||||
secondary
|
||||
:loading="loadingAudit"
|
||||
@click="runAudit"
|
||||
>
|
||||
<v-icon name="settings_suggest" left />
|
||||
Audit starten
|
||||
</v-button>
|
||||
|
||||
<template v-if="selectedLead.status === 'audit_ready'">
|
||||
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
||||
<v-icon name="mail" left />
|
||||
Audit E-Mail
|
||||
</v-button>
|
||||
<v-button :loading="loadingPdf" @click="generatePdf">
|
||||
<v-icon name="picture_as_pdf" left />
|
||||
PDF Erstellen
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||
<v-icon name="open_in_new" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="selectedLead.audit_pdf_path"
|
||||
primary
|
||||
:loading="loadingEmail"
|
||||
@click="sendEstimateEmail"
|
||||
>
|
||||
<v-icon name="send" left />
|
||||
Angebot senden
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sections">
|
||||
<div class="main-info">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson</span>
|
||||
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
|
||||
{{ getPersonName(selectedLead.contact_person) }}
|
||||
</div>
|
||||
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail (Legacy)</span>
|
||||
<div class="value">{{ selectedLead.contact_email || '—' }}</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div v-if="selectedLead.ai_state" class="ai-observations">
|
||||
<h3 class="section-title">AI Observations & Estimation</h3>
|
||||
|
||||
<div class="metrics">
|
||||
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
|
||||
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
|
||||
</div>
|
||||
|
||||
<v-table
|
||||
v-if="selectedLead.ai_state.sitemap"
|
||||
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
|
||||
:items="selectedLead.ai_state.sitemap"
|
||||
class="observation-table"
|
||||
>
|
||||
<template #[`item.title`]="{ item }">
|
||||
<span class="page-title">{{ item.title }}</span>
|
||||
</template>
|
||||
<template #[`item.url`]="{ item }">
|
||||
<span class="page-url">{{ item.url }}</span>
|
||||
</template>
|
||||
</v-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: New Lead -->
|
||||
<v-drawer
|
||||
v-model="showAddLead"
|
||||
title="Neuen Lead registrieren"
|
||||
icon="person_add"
|
||||
@cancel="showAddLead = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma (Zentral)</span>
|
||||
<v-select
|
||||
v-model="newLead.company"
|
||||
:items="companyOptions"
|
||||
placeholder="Bestehende Firma auswählen..."
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma (Legacy / Neu)</span>
|
||||
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Website URL</span>
|
||||
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Ansprechpartner</span>
|
||||
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail Adresse</span>
|
||||
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson (Optional)</span>
|
||||
<v-select
|
||||
v-model="newLead.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Logic will be added here
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const leads = ref<any[]>([]);
|
||||
const selectedLeadId = ref<string | null>(null);
|
||||
const loadingAudit = ref(false);
|
||||
const loadingPdf = ref(false);
|
||||
const loadingEmail = ref(false);
|
||||
const showAddLead = ref(false);
|
||||
const savingLead = ref(false);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const newLead = ref({
|
||||
company_name: '', // Legacy
|
||||
company: null,
|
||||
website_url: '',
|
||||
contact_name: '', // Legacy
|
||||
contact_email: '', // Legacy
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
});
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(c => ({
|
||||
text: c.name,
|
||||
value: c.id
|
||||
}))
|
||||
);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
function getCompanyName(lead: any) {
|
||||
if (lead.company) {
|
||||
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
|
||||
}
|
||||
return lead.company_name;
|
||||
}
|
||||
|
||||
function getPersonName(id: string | any) {
|
||||
if (!id) return '';
|
||||
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
|
||||
const person = people.value.find(p => p.id === id);
|
||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||
}
|
||||
|
||||
function goToPerson(id: string) {
|
||||
// Logic to navigate to people manager or open details
|
||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||
}
|
||||
|
||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||
|
||||
onMounted(fetchData);
|
||||
|
||||
async function fetchData() {
|
||||
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
|
||||
api.get('/items/leads', {
|
||||
params: {
|
||||
sort: '-date_created',
|
||||
fields: '*.*'
|
||||
}
|
||||
}),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } }),
|
||||
api.get('/items/companies', { params: { sort: 'name' } })
|
||||
]);
|
||||
leads.value = leadsResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
companies.value = companiesResp.data.data;
|
||||
|
||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||
selectedLeadId.value = leads.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeads() {
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
function selectLead(id: string) {
|
||||
selectedLeadId.value = id;
|
||||
}
|
||||
|
||||
async function runAudit() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingAudit.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
|
||||
} finally {
|
||||
loadingAudit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAuditEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingPdf.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
|
||||
} finally {
|
||||
loadingPdf.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEstimateEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPdf() {
|
||||
if (!selectedLead.value?.audit_pdf_path) return;
|
||||
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
|
||||
}
|
||||
|
||||
async function saveLead() {
|
||||
if (!newLead.value.company_name && !newLead.value.company) {
|
||||
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' };
|
||||
return;
|
||||
}
|
||||
savingLead.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: crypto.randomUUID(),
|
||||
...newLead.value
|
||||
};
|
||||
await api.post('/items/leads', payload);
|
||||
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
||||
showAddLead.value = false;
|
||||
await fetchLeads();
|
||||
selectedLeadId.value = payload.id;
|
||||
newLead.value = {
|
||||
company_name: '',
|
||||
company: null,
|
||||
website_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
};
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||
} finally {
|
||||
savingLead.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'fiber_new';
|
||||
case 'auditing': return 'hourglass_empty';
|
||||
case 'audit_ready': return 'check_circle';
|
||||
case 'contacted': return 'mail_outline';
|
||||
default: return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'var(--theme--primary)';
|
||||
case 'auditing': return 'var(--theme--warning)';
|
||||
case 'audit_ready': return 'var(--theme--success)';
|
||||
case 'contacted': return 'var(--theme--secondary)';
|
||||
default: return 'var(--theme--foreground-subdued)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acquisition-manager {
|
||||
padding: 20px;
|
||||
}
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.lead-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.url-link:hover { border-bottom-color: currentColor; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.sections { display: flex; flex-direction: column; gap: 32px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field.full { grid-column: span 2; }
|
||||
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.value { font-size: 15px; color: var(--theme--foreground); }
|
||||
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
|
||||
|
||||
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
||||
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||
|
||||
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
||||
.page-title { font-weight: 600; }
|
||||
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
||||
const outfile = resolve(__dirname, 'index.js');
|
||||
const outfile = resolve(__dirname, 'dist/index.js');
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outfile), { recursive: true });
|
||||
@@ -21,20 +21,25 @@ build({
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: outfile,
|
||||
format: 'esm',
|
||||
external: [],
|
||||
jsx: 'automatic',
|
||||
loader: {
|
||||
'.tsx': 'tsx',
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||
plugins: [{
|
||||
name: 'mock-jquery',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-canvas',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-jsdom',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}]
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "node build.js --watch"
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "workspace:*",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,176 @@
|
||||
import { defineEndpoint } from '@directus/extensions-sdk';
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
||||
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
||||
import { createElement } from "react";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
export default defineEndpoint((router) => {
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
export default defineEndpoint((router, { services, env }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
router.get("/ping", (req, res) => res.send("pong"));
|
||||
|
||||
router.post("/audit/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||
|
||||
await leadsService.updateOne(id, { status: "auditing" });
|
||||
|
||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "audit_ready",
|
||||
ai_state: result.state,
|
||||
audit_context: JSON.stringify(result.usage),
|
||||
});
|
||||
|
||||
res.send({ success: true, result });
|
||||
} catch (error: any) {
|
||||
console.error("Audit failed:", error);
|
||||
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const { ItemsService, MailService } = services;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const auditHighlights = [
|
||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
|
||||
];
|
||||
|
||||
const html = await render(createElement(SiteAuditTemplate, {
|
||||
companyName: companyName,
|
||||
websiteUrl: lead.website_url,
|
||||
auditHighlights
|
||||
}));
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||
html
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Audit Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
||||
|
||||
const pdfEngine = new PdfEngine();
|
||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const outputPath = path.join(storageRoot, filename);
|
||||
|
||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
audit_pdf_path: filename,
|
||||
});
|
||||
|
||||
res.send({ success: true, filename });
|
||||
} catch (error: any) {
|
||||
console.error("PDF Generation failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company?.name || lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const html = await render(createElement(ProjectEstimateTemplate, {
|
||||
companyName: companyName,
|
||||
}));
|
||||
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${companyName}.pdf`,
|
||||
content: fs.readFileSync(attachmentPath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Estimate Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
41
packages/cloner-library/build.mjs
Normal file
41
packages/cloner-library/build.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { build } from 'esbuild';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoints = [
|
||||
resolve(__dirname, 'src/index.ts')
|
||||
];
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||
} catch (e) { }
|
||||
|
||||
console.log(`Building entry point...`);
|
||||
|
||||
build({
|
||||
entryPoints: entryPoints,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outdir: resolve(__dirname, 'dist'),
|
||||
format: 'esm',
|
||||
loader: {
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
}).catch((e) => {
|
||||
if (e.errors) {
|
||||
console.error("Build failed with errors:");
|
||||
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
|
||||
} else {
|
||||
console.error("Build failed:", e);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
30
packages/cloner-library/package.json
Normal file
30
packages/cloner-library/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0",
|
||||
"crawlee": "^3.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"cheerio": "^1.0.0-rc.12"
|
||||
}
|
||||
}
|
||||
93
packages/cloner-library/src/AssetManager.ts
Normal file
93
packages/cloner-library/src/AssetManager.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface AssetMap {
|
||||
[originalUrl: string]: string;
|
||||
}
|
||||
|
||||
export class AssetManager {
|
||||
private userAgent: string;
|
||||
|
||||
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public sanitizePath(rawPath: string): string {
|
||||
return rawPath
|
||||
.split("/")
|
||||
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
|
||||
if (url.startsWith("//")) url = `https:${url}`;
|
||||
if (!url.startsWith("http")) return null;
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const relPath = this.sanitizePath(u.hostname + u.pathname);
|
||||
const dest = path.join(assetsDir, relPath);
|
||||
|
||||
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
||||
|
||||
const res = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
|
||||
if (!fs.existsSync(path.dirname(dest)))
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
||||
return `./assets/${relPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async processCssRecursively(
|
||||
cssContent: string,
|
||||
cssUrl: string,
|
||||
assetsDir: string,
|
||||
urlMap: AssetMap,
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 5) return cssContent;
|
||||
|
||||
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
|
||||
let match;
|
||||
let newContent = cssContent;
|
||||
|
||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||
const originalUrl = match[1];
|
||||
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||
continue;
|
||||
|
||||
try {
|
||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
||||
const local = await this.downloadFile(absUrl, assetsDir);
|
||||
|
||||
if (local) {
|
||||
const u = new URL(cssUrl);
|
||||
const cssPath = u.hostname + u.pathname;
|
||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||
|
||||
const rel = path.relative(
|
||||
path.dirname(this.sanitizePath(cssPath)),
|
||||
this.sanitizePath(assetPath),
|
||||
);
|
||||
|
||||
newContent = newContent.split(originalUrl).join(rel);
|
||||
urlMap[absUrl] = local;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
184
packages/cloner-library/src/PageCloner.ts
Normal file
184
packages/cloner-library/src/PageCloner.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { chromium, Browser, BrowserContext, Page } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
import { AssetManager, AssetMap } from "./AssetManager.js";
|
||||
|
||||
export interface PageClonerOptions {
|
||||
outputDir: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export class PageCloner {
|
||||
private options: PageClonerOptions;
|
||||
private assetManager: AssetManager;
|
||||
private userAgent: string;
|
||||
|
||||
constructor(options: PageClonerOptions) {
|
||||
this.options = options;
|
||||
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||||
this.assetManager = new AssetManager(this.userAgent);
|
||||
}
|
||||
|
||||
public async clone(targetUrl: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
||||
const assetsDir = path.join(domainDir, "assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||
if (!pageSlug) pageSlug = "index";
|
||||
const htmlFilename = `${pageSlug}.html`;
|
||||
|
||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: this.userAgent,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const urlMap: AssetMap = {};
|
||||
const foundAssets = new Set<string>();
|
||||
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 200) {
|
||||
const url = response.url();
|
||||
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
|
||||
foundAssets.add(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
|
||||
|
||||
// Scroll Wave
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 400;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(timer);
|
||||
window.scrollTo(0, 0);
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Sanitization
|
||||
await page.evaluate(() => {
|
||||
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
|
||||
const htmlEl = el as HTMLElement;
|
||||
const style = window.getComputedStyle(htmlEl);
|
||||
if (style.opacity === "0" || style.visibility === "hidden") {
|
||||
htmlEl.style.setProperty("opacity", "1", "important");
|
||||
htmlEl.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value;
|
||||
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
|
||||
if (el.tagName === "IMG") {
|
||||
const img = el as HTMLImageElement;
|
||||
if (name.includes("srcset")) img.srcset = val;
|
||||
else if (!img.src || img.src.includes("data:")) img.src = val;
|
||||
}
|
||||
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
|
||||
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
|
||||
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
|
||||
const bg = htmlEl.style.backgroundImage;
|
||||
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (document.body) {
|
||||
document.body.style.setProperty("opacity", "1", "important");
|
||||
document.body.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
let content = await page.content();
|
||||
const regexPatterns = [
|
||||
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
|
||||
/url\(["']?([^"'\)]+)["']?\)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of regexPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of foundAssets) {
|
||||
const local = await this.assetManager.downloadFile(url, assetsDir);
|
||||
if (local) {
|
||||
urlMap[url] = local;
|
||||
const clean = url.split("?")[0];
|
||||
urlMap[clean] = local;
|
||||
if (clean.endsWith(".css")) {
|
||||
try {
|
||||
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
|
||||
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
|
||||
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
|
||||
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
|
||||
if (sortedUrls.length > 0) {
|
||||
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
|
||||
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
|
||||
}
|
||||
|
||||
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
|
||||
for (const dir of commonDirs) {
|
||||
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
||||
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
|
||||
}
|
||||
|
||||
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
|
||||
finalContent = finalContent.replace(domainPattern, () => "./");
|
||||
|
||||
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
|
||||
const lower = scriptContent.toLowerCase();
|
||||
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
|
||||
});
|
||||
|
||||
const headEnd = finalContent.indexOf("</head>");
|
||||
if (headEnd > -1) {
|
||||
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
|
||||
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
|
||||
}
|
||||
|
||||
const finalPath = path.join(domainDir, htmlFilename);
|
||||
fs.writeFileSync(finalPath, finalContent);
|
||||
return finalPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export interface WebsiteClonerOptions {
|
||||
baseOutputDir: string;
|
||||
maxRequestsPerCrawl?: number;
|
||||
maxConcurrency?: number;
|
||||
}
|
||||
|
||||
export class WebsiteCloner {
|
||||
private options: WebsiteClonerOptions;
|
||||
|
||||
constructor(options: WebsiteClonerOptions) {
|
||||
this.options = {
|
||||
maxRequestsPerCrawl: 100,
|
||||
maxConcurrency: 3,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domain = urlObj.hostname;
|
||||
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
|
||||
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
|
||||
|
||||
if (fs.existsSync(baseOutputDir)) {
|
||||
fs.rmSync(baseOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(baseOutputDir, { recursive: true });
|
||||
|
||||
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
||||
console.log(`📂 Output: ${baseOutputDir}`);
|
||||
|
||||
const requestQueue = await RequestQueue.open();
|
||||
await requestQueue.addRequest({ url: targetUrl });
|
||||
|
||||
const crawler = new PlaywrightCrawler({
|
||||
requestQueue,
|
||||
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
||||
maxConcurrency: this.options.maxConcurrency,
|
||||
|
||||
async requestHandler({ request, enqueueLinks, log }) {
|
||||
const url = request.url;
|
||||
log.info(`Capturing ${url}...`);
|
||||
|
||||
const u = new URL(url);
|
||||
let relPath = u.pathname;
|
||||
if (relPath === '/' || relPath === '') relPath = '/index.html';
|
||||
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
|
||||
if (relPath.startsWith('/')) relPath = relPath.substring(1);
|
||||
|
||||
const fullPath = path.join(baseOutputDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
try {
|
||||
// Note: This assumes single-file-cli is available in the environment
|
||||
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error(`Failed to capture ${url} with SingleFile`);
|
||||
}
|
||||
|
||||
await enqueueLinks({
|
||||
strategy: 'same-domain',
|
||||
transformRequestFunction: (req) => {
|
||||
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
|
||||
return req;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run();
|
||||
|
||||
console.log('🔗 Rewriting internal links for offline navigation...');
|
||||
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
|
||||
|
||||
for (const file of allFiles) {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
const fileRelToRoot = path.relative(baseOutputDir, file);
|
||||
|
||||
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
||||
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
|
||||
try {
|
||||
const linkUrl = new URL(href, targetUrl);
|
||||
if (linkUrl.hostname === domain) {
|
||||
let linkPath = linkUrl.pathname;
|
||||
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
|
||||
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
|
||||
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
|
||||
|
||||
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
|
||||
return `href="${relativeLink}"`;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
|
||||
return baseOutputDir;
|
||||
}
|
||||
|
||||
private getFiles(dir: string, fileList: string[] = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const name = path.join(dir, file);
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
this.getFiles(name, fileList);
|
||||
} else {
|
||||
fileList.push(name);
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
}
|
||||
3
packages/cloner-library/src/index.ts
Normal file
3
packages/cloner-library/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./AssetManager.js";
|
||||
export * from "./PageCloner.js";
|
||||
export * from "./WebsiteCloner.js";
|
||||
17
packages/cloner-library/tsconfig.json
Normal file
17
packages/cloner-library/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
BIN
packages/cms-infra/database/data.db
Normal file → Executable file
BIN
packages/cms-infra/database/data.db
Normal file → Executable file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||
"icon": "account_balance_wallet",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.12",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "node build.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.12",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
@@ -27,4 +27,4 @@
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.12",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity People Management for Directus",
|
||||
"icon": "person",
|
||||
"version": "1.0.0",
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity People Management for Directus",
|
||||
"icon": "person",
|
||||
"version": "1.7.12",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "People Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "People Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"up": "npm run build:extensions && docker compose up -d",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f",
|
||||
"build:extensions": "../../scripts/sync-extensions.sh"
|
||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
||||
"schema:apply:local": "../../scripts/cms-apply.sh local",
|
||||
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
|
||||
"snapshot:local": "../../scripts/cms-snapshot.sh",
|
||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
||||
}
|
||||
}
|
||||
|
||||
1203
packages/cms-infra/schema/current.yaml
Normal file
1203
packages/cms-infra/schema/current.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
packages/company-manager/package.json
Normal file
27
packages/company-manager/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Central Company Management for Directus",
|
||||
"icon": "business",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Company Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,13 @@
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
@@ -20,7 +17,7 @@
|
||||
"name": "Customer Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Zentrale Person (Verknüpfung)</span>
|
||||
<v-select
|
||||
v-model="employeeForm.person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person aus dem People Manager auswählen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
|
||||
<div v-if="isEditingEmployee" class="field">
|
||||
@@ -158,7 +167,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
@@ -183,6 +192,7 @@ const employeeForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
person: null,
|
||||
temporary_password: ''
|
||||
});
|
||||
|
||||
@@ -192,14 +202,22 @@ const tableHeaders = [
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
const res = await api.get('/items/companies', {
|
||||
params: {
|
||||
fields: ['id', 'name'],
|
||||
sort: 'name',
|
||||
},
|
||||
});
|
||||
companies.value = res.data.data;
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name} (${p.email})`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
async function fetchData() {
|
||||
const [companiesResp, peopleResp] = await Promise.all([
|
||||
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
companies.value = companiesResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
}
|
||||
|
||||
async function selectCompany(company: any) {
|
||||
@@ -209,7 +227,7 @@ async function selectCompany(company: any) {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: { company: { _eq: company.id } },
|
||||
fields: ['*'],
|
||||
fields: ['*', 'person.*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
});
|
||||
@@ -273,6 +291,7 @@ async function openEditEmployee(item: any) {
|
||||
first_name: item.first_name || '',
|
||||
last_name: item.last_name || '',
|
||||
email: item.email || '',
|
||||
person: item.person?.id || item.person || null,
|
||||
temporary_password: item.temporary_password || ''
|
||||
};
|
||||
isEditingEmployee.value = true;
|
||||
@@ -288,7 +307,8 @@ async function saveEmployee() {
|
||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email
|
||||
email: employeeForm.value.email,
|
||||
person: employeeForm.value.person
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||
} else {
|
||||
@@ -296,7 +316,8 @@ async function saveEmployee() {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
company: selectedCompany.value.id
|
||||
company: selectedCompany.value.id,
|
||||
person: employeeForm.value.person
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||
}
|
||||
@@ -343,7 +364,7 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCompanies();
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
31
packages/directus-extension-toolkit/package.json
Normal file
31
packages/directus-extension-toolkit/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@mintel/directus-extension-toolkit",
|
||||
"version": "1.8.2",
|
||||
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@directus/extensions-sdk": "*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<private-view :title="title">
|
||||
<template #navigation>
|
||||
<slot name="navigation" />
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="mintel-manager-layout">
|
||||
<div v-if="isEmpty" class="empty-state">
|
||||
<v-info :title="emptyTitle" :icon="emptyIcon" center>
|
||||
<slot name="empty-state" />
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="mintel-header">
|
||||
<div class="header-left">
|
||||
<h1 class="mintel-title">{{ itemTitle }}</h1>
|
||||
<p class="mintel-subtitle">
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="mintel-content">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
itemTitle?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyIcon?: string;
|
||||
notice?: { type: string; message: string } | null;
|
||||
}>();
|
||||
|
||||
defineEmits(['close-notice']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-manager-layout {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mintel-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.mintel-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
color: var(--theme--foreground);
|
||||
}
|
||||
|
||||
.mintel-subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mintel-content {
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
||||
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="mintel-select">
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:placeholder="placeholder"
|
||||
:searchable="searchable"
|
||||
:show-deselect="showDeselect"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
items: {
|
||||
type: Array as () => Array<{ text: string; value: string | number }>,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Auswählen...'
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDeselect: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowAdd: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue', 'add']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mintel-select :deep(.v-select) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="mintel-stat-card" @click="$emit('click')">
|
||||
<div class="stat-icon">
|
||||
<v-icon :name="icon" large />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">{{ label }}</span>
|
||||
<span class="stat-value">{{ value }}</span>
|
||||
</div>
|
||||
<v-icon name="chevron_right" class="arrow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
}>();
|
||||
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-stat-card {
|
||||
background: var(--theme--background-normal);
|
||||
border: 1px solid var(--theme--border);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mintel-stat-card:hover {
|
||||
border-color: var(--theme--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--theme--background-subdued);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme--primary);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--theme--foreground);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.mintel-stat-card:hover .arrow {
|
||||
opacity: 1;
|
||||
color: var(--theme--primary);
|
||||
}
|
||||
</style>
|
||||
3
packages/directus-extension-toolkit/src/index.ts
Normal file
3
packages/directus-extension-toolkit/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MintelSelect } from './MintelSelect.vue';
|
||||
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
|
||||
export { default as MintelStatCard } from './MintelStatCard.vue';
|
||||
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve('src/index.ts'),
|
||||
name: 'MintelDirectusToolkit',
|
||||
fileName: 'index',
|
||||
formats: ['es']
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue', '@directus/extensions-sdk'],
|
||||
output: {
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
@@ -20,7 +17,7 @@
|
||||
"name": "Feedback Commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="card-text">{{ item.text }}</div>
|
||||
<footer class="card-footer">
|
||||
<div class="meta-tags">
|
||||
<v-chip x-small outline>{{ item.project }}</v-chip>
|
||||
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
|
||||
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
||||
</div>
|
||||
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
||||
@@ -142,7 +142,8 @@
|
||||
<TransitionGroup name="thread-list">
|
||||
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
||||
<header class="reply-header">
|
||||
<span class="reply-user">{{ reply.user_name }}</span>
|
||||
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
|
||||
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
|
||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||
</header>
|
||||
<div class="reply-text">{{ reply.text }}</div>
|
||||
@@ -168,8 +169,12 @@
|
||||
<v-card-title>Context</v-card-title>
|
||||
<v-card-text class="meta-list">
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="public" x-small /> Website</label>
|
||||
<strong>{{ selectedItem.project }}</strong>
|
||||
<label><v-icon name="business" x-small /> Organisation / Firma</label>
|
||||
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
|
||||
</div>
|
||||
<div v-if="selectedItem.person" class="meta-item">
|
||||
<label><v-icon name="person" x-small /> Zentrale Person</label>
|
||||
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="link" x-small /> Source Path</label>
|
||||
@@ -238,13 +243,14 @@ const statusOptions = [
|
||||
];
|
||||
|
||||
const projects = computed(() => {
|
||||
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
||||
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
|
||||
return Array.from(projSet).sort();
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => {
|
||||
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
|
||||
const projectName = item.company?.name || item.project;
|
||||
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
|
||||
const status = item.status || 'open';
|
||||
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
||||
return matchProject && matchStatus;
|
||||
@@ -258,7 +264,8 @@ async function fetchData() {
|
||||
const response = await api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300
|
||||
limit: 300,
|
||||
fields: ['*', 'company.*', 'person.*']
|
||||
}
|
||||
});
|
||||
items.value = response.data.data;
|
||||
@@ -278,7 +285,8 @@ async function selectItem(item) {
|
||||
const response = await api.get('/items/visual_feedback_comments', {
|
||||
params: {
|
||||
filter: { feedback_id: { _eq: item.id } },
|
||||
sort: '-date_created,-id'
|
||||
sort: '-date_created,-id',
|
||||
fields: ['*', 'person.*']
|
||||
}
|
||||
});
|
||||
comments.value = response.data.data;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
|
||||
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
@@ -54,15 +56,17 @@ export async function GET(req: NextRequest) {
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
console.log(`[Verify] Legacy password match`);
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
console.log(`[Verify] Identity verified: ${identity}`);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or old format
|
||||
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const email = (formData.get("email") as string || "").trim();
|
||||
const password = (formData.get("password") as string || "").trim();
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps
|
||||
if (password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 2. Check Global Admin (from ENV)
|
||||
else if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
email === adminEmail.trim() &&
|
||||
password === adminPassword.trim()
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
@@ -116,6 +116,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
@@ -126,6 +127,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
@@ -136,6 +139,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -43,8 +43,15 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
done
|
||||
|
||||
# 2. Run Garbage Collection
|
||||
echo "♻️ Running Registry Garbage Collection..."
|
||||
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
||||
echo "♻️ Detecting Registry Container..."
|
||||
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
|
||||
|
||||
if [ -n "$REGISTRY_CONTAINER" ]; then
|
||||
echo "♻️ Running Registry Garbage Collection on $REGISTRY_CONTAINER..."
|
||||
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
||||
else
|
||||
echo "⚠️ Registry container not found. Skipping GC."
|
||||
fi
|
||||
|
||||
# 3. Prune Host Docker resources (Shorter window: 24h)
|
||||
echo "🧹 Pruning Host Docker resources..."
|
||||
|
||||
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# wait-for-upstream.sh
|
||||
# Usage: ./wait-for-upstream.sh <org/repo> <version_tag> [poll_interval_sec]
|
||||
|
||||
REPO=$1
|
||||
TAG=$2
|
||||
INTERVAL=${3:-30}
|
||||
MAX_RETRIES=40 # ~20 minutes default
|
||||
|
||||
if [[ -z "$REPO" || -z "$TAG" ]]; then
|
||||
echo "❌ Error: REPO and TAG are required."
|
||||
echo "Usage: $0 <org/repo> <version_tag>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
echo "❌ Error: GITEA_TOKEN is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITEA_API="https://git.infra.mintel.me/api/v1"
|
||||
|
||||
echo "🔎 Searching for upstream release $TAG in $REPO..."
|
||||
|
||||
# 1. Get the SHA of the tag to be more precise
|
||||
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||
|
||||
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||
echo "⚠️ Warning: Tag $TAG not found yet. Upstream might be lagging."
|
||||
echo " Waiting 15s for tag to appear..."
|
||||
sleep 15
|
||||
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||
|
||||
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||
echo "❌ Error: Tag $TAG does not exist in $REPO."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Target SHA for $TAG is $TARGET_SHA"
|
||||
|
||||
# 2. Find the run for the specific SHA
|
||||
# We list recent runs and filter by head_sha
|
||||
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||
|
||||
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||
echo "ℹ️ No recent action run found for SHA $TARGET_SHA yet."
|
||||
echo " Checking if we should wait or if it was already successful..."
|
||||
|
||||
# Fallback: wait a bit more for new tags
|
||||
echo "⏳ waiting for run to appear..."
|
||||
sleep 20
|
||||
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||
|
||||
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||
echo "✅ No run found but Tag exists. Assuming manual release or already completed. Proceeding."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "⏳ Waiting for upstream run $RUN_ID status..."
|
||||
|
||||
RETRY_COUNT=0
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
STATUS_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs/$RUN_ID")
|
||||
STATUS=$(echo "$STATUS_QUERY" | jq -r '.status')
|
||||
CONCLUSION=$(echo "$STATUS_QUERY" | jq -r '.conclusion')
|
||||
|
||||
echo " - Current Status: $STATUS (Conclusion: $CONCLUSION)"
|
||||
|
||||
if [[ "$STATUS" == "success" || "$CONCLUSION" == "success" ]]; then
|
||||
echo "✅ Upstream release $TAG is READY."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" == "failure" || "$CONCLUSION" == "failure" || "$CONCLUSION" == "cancelled" ]]; then
|
||||
echo "❌ Error: Upstream release $TAG FAILED or was CANCELLED."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " - Still working... waiting $INTERVAL seconds (Attempt $((RETRY_COUNT+1))/$MAX_RETRIES)"
|
||||
sleep $INTERVAL
|
||||
RETRY_COUNT=$((RETRY_COUNT+1))
|
||||
done
|
||||
|
||||
echo "❌ Error: Timeout waiting for upstream release $TAG."
|
||||
exit 1
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -22,3 +22,6 @@ export * from "./layouts/ClientLayout";
|
||||
// Export Templates
|
||||
export * from "./templates/ContactFormNotification";
|
||||
export * from "./templates/ConfirmationMessage";
|
||||
export * from "./templates/FollowUpTemplate";
|
||||
export * from "./templates/ProjectEstimateTemplate";
|
||||
export * from "./templates/SiteAuditTemplate";
|
||||
|
||||
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text, Button } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface FollowUpTemplateProps {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const FollowUpTemplate = ({
|
||||
companyName,
|
||||
}: FollowUpTemplateProps) => {
|
||||
const preview = `Kurzes Follow-up: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Kurzes Follow-up</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo noch einmal,<br /><br />
|
||||
ich wollte mich nur kurz erkundigen, ob Sie bereits Zeit hatten,
|
||||
einen Blick auf das Audit Ihrer Website zu werfen, das ich Ihnen
|
||||
vor ein paar Tagen gesendet habe.
|
||||
</Text>
|
||||
|
||||
<Text style={bodyText}>
|
||||
Vielleicht passt es ja diese Woche für ein kurzes, unverbindliches
|
||||
Telefonat, um die Punkte gemeinsam durchzugehen?
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
style={button}
|
||||
href="https://calendly.com/mintel-me/intro"
|
||||
>
|
||||
Treffen vereinbaren
|
||||
</Button>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong>
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowUpTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 24px",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#333333",
|
||||
borderRadius: "0",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "inline-block",
|
||||
padding: "12px 24px",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
border: "1px solid #444444",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
marginTop: "48px",
|
||||
};
|
||||
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text, Section } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface ProjectEstimateTemplateProps {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const ProjectEstimateTemplate = ({
|
||||
companyName,
|
||||
}: ProjectEstimateTemplateProps) => {
|
||||
const preview = `Ihre personalisierte Projekt-Schätzung: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Ihre Projekt-Schätzung</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo {companyName},<br /><br />
|
||||
vielen Dank für unser Gespräch. Wie versprochen sende ich Ihnen hiermit
|
||||
die detaillierte Schätzung für Ihre neue digitale Webpräsenz.
|
||||
</Text>
|
||||
|
||||
<Section style={infoBox}>
|
||||
<Text style={infoText}>
|
||||
Im Anhang finden Sie das PDF-Dokument mit allen Positionen,
|
||||
Umfängen und dem strategischen Ausblick.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
Ich freue mich auf Ihr Feedback und stehe für Rückfragen jederzeit
|
||||
zur Verfügung.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong>
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectEstimateTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 24px",
|
||||
};
|
||||
|
||||
const infoBox = {
|
||||
backgroundColor: "#0f172a",
|
||||
padding: "24px",
|
||||
borderLeft: "4px solid #ffffff",
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const infoText = {
|
||||
fontSize: "15px",
|
||||
color: "#ffffff",
|
||||
margin: "0",
|
||||
lineHeight: "1.5",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
marginTop: "48px",
|
||||
};
|
||||
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Section, Text, Button, Link } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface SiteAuditTemplateProps {
|
||||
companyName: string;
|
||||
auditHighlights: string[];
|
||||
websiteUrl: string;
|
||||
}
|
||||
|
||||
export const SiteAuditTemplate = ({
|
||||
companyName,
|
||||
auditHighlights,
|
||||
websiteUrl,
|
||||
}: SiteAuditTemplateProps) => {
|
||||
const preview = `Analyse Ihrer Webpräsenz: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Analyse Ihrer Webpräsenz</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo {companyName},<br /><br />
|
||||
ich habe mir Ihre aktuelle Website ({websiteUrl}) im Detail angeschaut und
|
||||
einige technische sowie strategische Potenziale identifiziert.
|
||||
</Text>
|
||||
|
||||
<Section style={auditContainer}>
|
||||
<Heading as="h2" style={h2}>Audit Highlights</Heading>
|
||||
{auditHighlights.map((highlight, i) => (
|
||||
<div key={i} style={highlightRow}>
|
||||
<div style={bullet} />
|
||||
<Text style={highlightText}>{highlight}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
In der heutigen digitalen Landschaft ist eine performante und strategisch
|
||||
ausgerichtete Website kein Luxus mehr, sondern das Fundament für
|
||||
nachhaltiges Wachstum.
|
||||
</Text>
|
||||
|
||||
<Section style={ctaSection}>
|
||||
<Button
|
||||
style={button}
|
||||
href={`mailto:marc@mintel.me?subject=Feedback zum Audit: ${companyName}`}
|
||||
>
|
||||
Audit gemeinsam besprechen
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong><br />
|
||||
Digitaler Architekt
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteAuditTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#444444",
|
||||
margin: "0 0 16px",
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const auditContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "32px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "32px",
|
||||
border: "1px solid #222222",
|
||||
};
|
||||
|
||||
const highlightRow = {
|
||||
display: "flex" as const,
|
||||
alignItems: "flex-start" as const,
|
||||
marginBottom: "12px",
|
||||
};
|
||||
|
||||
const bullet = {
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "#4CAF50",
|
||||
marginTop: "8px",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const highlightText = {
|
||||
fontSize: "15px",
|
||||
color: "#ffffff",
|
||||
margin: "0",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const ctaSection = {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "48px",
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: "0",
|
||||
color: "#000000",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "inline-block",
|
||||
padding: "16px 32px",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@medv/finder": "^4.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.5.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import html2canvas from "html2canvas";
|
||||
import { finder } from "@medv/finder";
|
||||
|
||||
function cn(...inputs: any[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -107,15 +108,16 @@ export function FeedbackOverlay() {
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
if (el.id) return `#${el.id}`;
|
||||
const path = [];
|
||||
let curr: HTMLElement | null = el;
|
||||
while (curr && curr.parentElement) {
|
||||
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
|
||||
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return path.join(" > ");
|
||||
return finder(el, {
|
||||
root: document.body,
|
||||
className: (name) =>
|
||||
!name.startsWith('record-mode-') &&
|
||||
!name.startsWith('feedback-') &&
|
||||
!name.includes('[') &&
|
||||
!name.includes('/') &&
|
||||
!name.match(/^[a-z]-[0-9]/),
|
||||
idName: (name) => !name.startsWith('__next') && !name.includes(':'),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,4 @@ export default defineConfig({
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: "'use client';",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
57
packages/pdf-library/build.mjs
Normal file
57
packages/pdf-library/build.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import { build } from 'esbuild';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoints = [
|
||||
resolve(__dirname, 'src/index.ts'),
|
||||
resolve(__dirname, 'src/server.ts')
|
||||
];
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||
} catch (e) { }
|
||||
|
||||
console.log(`Building entry points...`);
|
||||
|
||||
build({
|
||||
entryPoints: entryPoints,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outdir: resolve(__dirname, 'dist'),
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
loader: {
|
||||
'.tsx': 'tsx',
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||
plugins: [{
|
||||
name: 'mock-canvas',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-jsdom',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}]
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
}).catch((e) => {
|
||||
if (e.errors) {
|
||||
console.error("Build failed with errors:");
|
||||
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
|
||||
} else {
|
||||
console.error("Build failed:", e);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
38
packages/pdf-library/package.json
Normal file
38
packages/pdf-library/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@mintel/pdf",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.js",
|
||||
"default": "./dist/server.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crawlee/cheerio": "^3.16.0",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"cheerio": "^1.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
241
packages/pdf-library/src/components/AgbsPDF.tsx
Normal file
241
packages/pdf-library/src/components/AgbsPDF.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Page as PDFPage,
|
||||
Text as PDFText,
|
||||
View as PDFView,
|
||||
StyleSheet as PDFStyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import {
|
||||
pdfStyles,
|
||||
Header,
|
||||
Footer,
|
||||
FoldingMarks,
|
||||
DocumentTitle,
|
||||
} from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
const localStyles = PDFStyleSheet.create({
|
||||
sectionContainer: {
|
||||
marginTop: 0,
|
||||
},
|
||||
agbSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
marginBottom: 6,
|
||||
},
|
||||
monoNumber: {
|
||||
fontSize: 7,
|
||||
fontWeight: "bold",
|
||||
color: "#94a3b8",
|
||||
letterSpacing: 2,
|
||||
width: 25,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
officialText: {
|
||||
fontSize: 8,
|
||||
lineHeight: 1.5,
|
||||
color: "#334155",
|
||||
textAlign: "justify",
|
||||
paddingLeft: 25,
|
||||
},
|
||||
});
|
||||
|
||||
const AGBSection = ({
|
||||
index,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
index: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<PDFView style={localStyles.agbSection} wrap={false}>
|
||||
<PDFView style={localStyles.labelRow}>
|
||||
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
|
||||
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={localStyles.officialText}>{children}</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
interface AgbsPDFProps {
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
mode?: "estimation" | "full";
|
||||
}
|
||||
|
||||
export const AgbsPDF = ({
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
mode = "full",
|
||||
}: AgbsPDFProps) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65",
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DocumentTitle
|
||||
title="Allgemeine Geschäftsbedingungen"
|
||||
subLines={[`Stand: ${date}`]}
|
||||
/>
|
||||
<PDFView style={localStyles.sectionContainer}>
|
||||
<AGBSection index="01" title="Geltungsbereich">
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge
|
||||
zwischen Marc Mintel (nachfolgend „Auftragnehmer“) und dem jeweiligen
|
||||
Kunden (nachfolgend „Auftraggeber“). Abweichende oder ergänzende
|
||||
Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch
|
||||
wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="02" title="Vertragsgegenstand">
|
||||
Der Auftragnehmer erbringt Dienstleistungen im Bereich:
|
||||
Webentwicklung, technische Umsetzung digitaler Systeme, Funktionen,
|
||||
Schnittstellen und Automatisierungen sowie Hosting, Betrieb und
|
||||
Wartung, sofern ausdrücklich vereinbard. Der Auftragnehmer schuldet
|
||||
ausschließlich die vereinbarte technische Leistung, nicht jedoch einen
|
||||
wirtschaftlichen Erfolg, bestimmte Umsätze, Conversions, Reichweiten,
|
||||
Suchmaschinen-Rankings oder rechtliche Ergebnisse.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
|
||||
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung
|
||||
erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen
|
||||
rechtzeitig, vollständig und korrekt bereitzustellen. Hierzu zählen
|
||||
insbesondere Texte, Bilder, Videos, Produktdaten, Freigaben, Feedback,
|
||||
Zugangsdaten sowie rechtlich erforderliche Inhalte (z. B. Impressum,
|
||||
DSGVO). Verzögerungen oder Unterlassungen führen zu Verschiebungen
|
||||
aller Termine ohne Schadensersatzanspruch.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
|
||||
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine
|
||||
garantierten Fristen. Fixe Termine oder Deadlines gelten nur, wenn sie
|
||||
ausdrücklich schriftlich als verbindlich vereinbart wurden.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="05" title="Abnahme">
|
||||
Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv
|
||||
nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine
|
||||
wesentlichen Mängel angezeigt werden. Optische Abweichungen,
|
||||
Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel
|
||||
dar.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="06" title="Haftung">
|
||||
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder
|
||||
grob fahrlässiger Pflichtverletzung beruhen. Eine Haftung für
|
||||
entgangenen Gewinn, Umsatzausfälle, Datenverlust,
|
||||
Betriebsunterbrechungen, mittelbare oder Folgeschäden ist
|
||||
ausgeschlossen, soweit gesetzlich zulässig.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
|
||||
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine
|
||||
permanente Verfügbarkeit. Wartungsarbeiten, Updates,
|
||||
Sicherheitsmaßnahmen oder externe Störungen können zu zeitweisen
|
||||
Einschränkungen führen und begründen keine Haftungsansprüche.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
|
||||
Die Betriebs- und Pflegeleistung umfasst ausschließlich die
|
||||
Sicherstellung des technischen Betriebs, Wartung, Updates,
|
||||
Fehlerbehebung der bestehenden Systeme sowie Pflege bestehender
|
||||
Datensätze ohne Strukturänderung. Nicht Bestandteil sind die
|
||||
Erstellung neuer Inhalte (Blogartikel, News, Produkte), redaktionelle
|
||||
Tätigkeiten, strategische Planung oder der Aufbau neuer
|
||||
Features/Datenmodelle. Leistungen darüber hinaus gelten als
|
||||
Neuentwicklung.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="08" title="Drittanbieter & externe Systeme">
|
||||
Der Auftragnehmer übernimmt keine Verantwortung für Leistungen,
|
||||
Ausfälle oder Änderungen externer Dienste, APIs, Schnittstellen oder
|
||||
Plattformen Dritter. Eine Funktionsfähigkeit kann nur im Rahmen der
|
||||
jeweils aktuellen externen Schnittstellen gewährleistet werden.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="09" title="Inhalte & Rechtliches">
|
||||
Der Auftraggeber ist allein verantwortlich für Inhalte, rechtliche
|
||||
Konformität (DSGVO, Urheberrecht etc.) sowie bereitgestellte Daten.
|
||||
Der Auftragnehmer übernimmt keine rechtliche Prüfung.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
|
||||
Alle Preise netto zzgl. MwSt. Rechnungen sind innerhalb von 7 Tagen
|
||||
fällig. Bei Zahlungsverzug ist der Auftragnehmer berechtigt,
|
||||
Leistungen auszusetzen, Systeme offline zu nehmen oder laufende
|
||||
Arbeiten zu stoppen.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="11" title="Kündigung laufender Leistungen">
|
||||
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist
|
||||
von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes
|
||||
vereinbart ist.
|
||||
</AGBSection>
|
||||
|
||||
<AGBSection index="12" title="Schlussbestimmungen">
|
||||
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
|
||||
der Sitz des Auftragnehmers. Sollte eine Bestimmung unwirksam sein,
|
||||
bleibt die Wirksamkeit der übrigen Regelungen unberührt.
|
||||
</AGBSection>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
if (mode === "full") {
|
||||
return (
|
||||
<SimpleLayout
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
footerLogo={footerLogo}
|
||||
icon={headerIcon}
|
||||
pageNumber="10"
|
||||
showPageNumber={false}
|
||||
>
|
||||
{content}
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
{content}
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={false}
|
||||
showPageNumber={false}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
79
packages/pdf-library/src/components/CombinedQuotePDF.tsx
Normal file
79
packages/pdf-library/src/components/CombinedQuotePDF.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Document as PDFDocument } from "@react-pdf/renderer";
|
||||
import { EstimationPDF } from "./EstimationPDF.js";
|
||||
import { AgbsPDF } from "./AgbsPDF.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
|
||||
interface CombinedProps {
|
||||
estimationProps: any;
|
||||
showAgbs?: boolean;
|
||||
techDetails?: any[];
|
||||
principles?: any[];
|
||||
maintenanceDetails?: any[];
|
||||
standardsDetails?: any[];
|
||||
}
|
||||
|
||||
export const CombinedQuotePDF = ({
|
||||
estimationProps,
|
||||
showAgbs = true,
|
||||
techDetails,
|
||||
principles,
|
||||
maintenanceDetails,
|
||||
standardsDetails,
|
||||
mode = "full",
|
||||
}: CombinedProps & { mode?: "estimation" | "full" }) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65",
|
||||
};
|
||||
|
||||
const layoutProps = {
|
||||
date,
|
||||
icon: estimationProps.headerIcon,
|
||||
footerLogo: estimationProps.footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFDocument
|
||||
title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}
|
||||
>
|
||||
<EstimationPDF
|
||||
{...estimationProps}
|
||||
mode={mode}
|
||||
techDetails={techDetails}
|
||||
principles={principles}
|
||||
maintenanceDetails={maintenanceDetails}
|
||||
standardsDetails={standardsDetails}
|
||||
/>
|
||||
{showAgbs && (
|
||||
<AgbsPDF
|
||||
mode={mode}
|
||||
headerIcon={estimationProps.headerIcon}
|
||||
footerLogo={estimationProps.footerLogo}
|
||||
/>
|
||||
)}
|
||||
<SimpleLayout {...layoutProps} pageNumber="END" showPageNumber={false}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
95
packages/pdf-library/src/components/EstimationPDF.tsx
Normal file
95
packages/pdf-library/src/components/EstimationPDF.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
|
||||
import { pdfStyles } from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
|
||||
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
|
||||
import { calculatePositions } from "../logic/pricing/calculator.js";
|
||||
|
||||
interface PDFProps {
|
||||
state: any;
|
||||
totalPrice: number;
|
||||
monthlyPrice?: number;
|
||||
totalPagesCount?: number;
|
||||
pricing: any;
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
}
|
||||
|
||||
export const EstimationPDF = ({
|
||||
state,
|
||||
totalPrice,
|
||||
pricing,
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
}: PDFProps) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const positions = calculatePositions(state, pricing);
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
state,
|
||||
date,
|
||||
icon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
};
|
||||
|
||||
let pageCounter = 1;
|
||||
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
|
||||
|
||||
return (
|
||||
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
|
||||
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<BriefingModule state={state} />
|
||||
</SimpleLayout>
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
positions={positions}
|
||||
totalPrice={totalPrice}
|
||||
date={date}
|
||||
/>
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
55
packages/pdf-library/src/components/pdf/DINLayout.tsx
Normal file
55
packages/pdf-library/src/components/pdf/DINLayout.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage } from '@react-pdf/renderer';
|
||||
import { FoldingMarks, Header, Footer, pdfStyles } from './SharedUI';
|
||||
|
||||
interface DINLayoutProps {
|
||||
children: React.ReactNode;
|
||||
sender?: string;
|
||||
recipient?: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
taxId?: string;
|
||||
};
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
bankData: any;
|
||||
showAddress?: boolean;
|
||||
showFooterDetails?: boolean;
|
||||
}
|
||||
|
||||
export const DINLayout = ({
|
||||
children,
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
showAddress = true,
|
||||
showFooterDetails = true
|
||||
}: DINLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header
|
||||
sender={sender}
|
||||
recipient={recipient}
|
||||
icon={icon}
|
||||
showAddress={showAddress}
|
||||
/>
|
||||
{children}
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={showFooterDetails}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
728
packages/pdf-library/src/components/pdf/SharedUI.tsx
Normal file
728
packages/pdf-library/src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,728 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
Image as PDFImage,
|
||||
} from "@react-pdf/renderer";
|
||||
|
||||
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||
export const COLORS = {
|
||||
CHARCOAL: "#0f172a", // Slate 900
|
||||
TEXT_MAIN: "#334155", // Slate 700
|
||||
TEXT_DIM: "#64748b", // Slate 500
|
||||
TEXT_LIGHT: "#94a3b8", // Slate 400
|
||||
DIVIDER: "#cbd5e1", // Slate 300
|
||||
GRID: "#f1f5f9", // Slate 100
|
||||
BLUEPRINT: "#e2e8f0", // Slate 200
|
||||
WHITE: "#ffffff",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
HERO: 24, // Main Page Titles
|
||||
HEADING: 14, // Section Headers
|
||||
BODY: 11, // Standard Content
|
||||
LABEL: 10, // Bold Labels / Keys
|
||||
SMALL: 9, // Descriptions / Footnotes
|
||||
TINY: 8, // Metadata / Unit prices
|
||||
};
|
||||
|
||||
// Mintel Industrial Glyphs (strictly 1px stroke, 12x12px grid)
|
||||
export const IndustrialGlyph = ({
|
||||
type,
|
||||
color = COLORS.TEXT_LIGHT,
|
||||
size = 12,
|
||||
}: {
|
||||
type: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}) => {
|
||||
const stroke = 1;
|
||||
const scale = size / 12;
|
||||
|
||||
switch (type) {
|
||||
case "base": // Skeletal cube base
|
||||
return (
|
||||
<PDFView style={{ width: size, height: size, position: "relative" }}>
|
||||
<PDFView
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2 * scale,
|
||||
left: 2 * scale,
|
||||
width: 8 * scale,
|
||||
height: 8 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 4 * scale,
|
||||
height: 4 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "pages": // Layered rectangles
|
||||
return (
|
||||
<PDFView style={{ width: size, height: size, position: "relative" }}>
|
||||
<PDFView
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 3 * scale,
|
||||
left: 3 * scale,
|
||||
width: 6 * scale,
|
||||
height: 8 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 6 * scale,
|
||||
height: 8 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "modules": // Four small squares grid
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 2 * scale,
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 4 * scale,
|
||||
height: 4 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 4 * scale,
|
||||
height: 4 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 4 * scale,
|
||||
height: 4 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 4 * scale,
|
||||
height: 4 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "logic": // Diamond with center point
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 8 * scale,
|
||||
height: 8 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
transform: "rotate(45deg)",
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 2 * scale,
|
||||
height: 2 * scale,
|
||||
backgroundColor: color,
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "interface": // Three horizontal lines of varying length
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
justifyContent: "center",
|
||||
gap: 2 * scale,
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 10 * scale,
|
||||
height: stroke,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{ width: 6 * scale, height: stroke, backgroundColor: color }}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 10 * scale,
|
||||
height: stroke,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "management": // Framed grid
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
padding: 1 * scale,
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 2 * scale,
|
||||
backgroundColor: color,
|
||||
marginBottom: 1 * scale,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 2 * scale,
|
||||
backgroundColor: color,
|
||||
marginBottom: 1 * scale,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{ width: "100%", height: 2 * scale, backgroundColor: color }}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "reveal": // Ascending bars
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: 1 * scale,
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 2 * scale,
|
||||
height: 4 * scale,
|
||||
backgroundColor: color,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 2 * scale,
|
||||
height: 7 * scale,
|
||||
backgroundColor: color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<PDFView
|
||||
style={{
|
||||
width: 2 * scale,
|
||||
height: 10 * scale,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
case "maintenance": // Circle with vertical notch
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 6 * scale,
|
||||
borderWidth: stroke,
|
||||
borderColor: color,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<PDFView
|
||||
style={{
|
||||
width: stroke,
|
||||
height: 4 * scale,
|
||||
backgroundColor: color,
|
||||
marginTop: 1 * scale,
|
||||
}}
|
||||
/>
|
||||
</PDFView>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<PDFView
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderWidth: stroke,
|
||||
borderColor: COLORS.BLUEPRINT,
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 80, // Safe buffer for absolute footer
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
titlePage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
color: COLORS.CHARCOAL,
|
||||
padding: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: "55%",
|
||||
marginTop: 45,
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
textDecoration: "underline",
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: "40%",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: "#0f172a",
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: COLORS.WHITE,
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
titleInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: FONT_SIZES.HEADING,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 4,
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginTop: 2,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginBottom: 8,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.GRID,
|
||||
paddingTop: 16,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: "auto",
|
||||
objectFit: "contain",
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
asymmetryContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 32,
|
||||
},
|
||||
asymmetryLeft: {
|
||||
width: "32%",
|
||||
},
|
||||
asymmetryRight: {
|
||||
width: "63%",
|
||||
},
|
||||
specRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
},
|
||||
specLabel: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
specValue: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.CHARCOAL,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
blueprintBox: {
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
padding: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.DIVIDER,
|
||||
fontWeight: "bold",
|
||||
marginTop: 8,
|
||||
textAlign: "right",
|
||||
},
|
||||
foldingMark: {
|
||||
position: "absolute",
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: COLORS.DIVIDER,
|
||||
},
|
||||
divider: {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginVertical: 12,
|
||||
},
|
||||
industrialListItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 6,
|
||||
},
|
||||
industrialBulletBox: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginRight: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
industrialTitle: {
|
||||
fontSize: FONT_SIZES.HERO,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const IndustrialListItem = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.industrialListItem}>
|
||||
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||
<PDFView style={[pdfStyles.divider, style]} />
|
||||
);
|
||||
|
||||
export const FoldingMarks = () => (
|
||||
<>
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed />
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed />
|
||||
<PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Footer = ({
|
||||
logo,
|
||||
companyData,
|
||||
bankData,
|
||||
showDetails = true,
|
||||
showPageNumber = true,
|
||||
}: {
|
||||
logo?: string;
|
||||
companyData: any;
|
||||
bankData?: any;
|
||||
showDetails?: boolean;
|
||||
showPageNumber?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.footer}>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
{logo ? (
|
||||
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||
) : (
|
||||
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
|
||||
marc mintel
|
||||
</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
{showDetails && (
|
||||
<>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
<PDFText style={pdfStyles.footerText}>
|
||||
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
|
||||
{"\n"}
|
||||
{companyData.address1}
|
||||
{"\n"}
|
||||
{companyData.address2}
|
||||
{"\n"}UST: {companyData.ustId}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||
{showPageNumber && (
|
||||
<PDFText
|
||||
style={pdfStyles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`${pageNumber} / ${totalPages}`
|
||||
}
|
||||
fixed
|
||||
/>
|
||||
)}
|
||||
</PDFView>
|
||||
</>
|
||||
)}
|
||||
{!showDetails && (
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||
{showPageNumber && (
|
||||
<PDFText
|
||||
style={pdfStyles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`${pageNumber} / ${totalPages}`
|
||||
}
|
||||
fixed
|
||||
/>
|
||||
)}
|
||||
</PDFView>
|
||||
)}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Header = ({
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
showAddress = true,
|
||||
}: {
|
||||
sender?: string;
|
||||
recipient?: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
};
|
||||
icon?: string;
|
||||
showAddress?: boolean;
|
||||
}) => (
|
||||
<PDFView
|
||||
style={[
|
||||
pdfStyles.header,
|
||||
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
|
||||
]}
|
||||
>
|
||||
<PDFView style={pdfStyles.addressBlock}>
|
||||
{showAddress && sender && (
|
||||
<>
|
||||
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||
{recipient && (
|
||||
<PDFView style={pdfStyles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: "bold" }}>
|
||||
{recipient.title}
|
||||
</PDFText>
|
||||
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||
{recipient.address && <PDFText>{recipient.address}</PDFText>}
|
||||
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
|
||||
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||
<PDFView style={pdfStyles.brandIconContainer}>
|
||||
{icon ? (
|
||||
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const DocumentTitle = ({
|
||||
title,
|
||||
subLines,
|
||||
isHero = false,
|
||||
}: {
|
||||
title: string;
|
||||
subLines?: string[];
|
||||
isHero?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.titleInfo}>
|
||||
<PDFText
|
||||
style={[
|
||||
pdfStyles.mainTitle,
|
||||
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</PDFText>
|
||||
{subLines?.map((line, i) => (
|
||||
<PDFText
|
||||
key={i}
|
||||
style={[
|
||||
pdfStyles.subTitle,
|
||||
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
|
||||
]}
|
||||
>
|
||||
{line}
|
||||
</PDFText>
|
||||
))}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const TechnicalSpec = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.specRow}>
|
||||
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
|
||||
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const AsymmetryView = ({
|
||||
left,
|
||||
right,
|
||||
style = {},
|
||||
}: {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
style?: any;
|
||||
}) => (
|
||||
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
|
||||
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
|
||||
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const IndustrialCard = ({
|
||||
title,
|
||||
children,
|
||||
style = {},
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
style?: any;
|
||||
}) => (
|
||||
<PDFView style={[pdfStyles.blueprintBox, { marginBottom: 12 }, style]}>
|
||||
<PDFText
|
||||
style={{
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 6,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</PDFText>
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
67
packages/pdf-library/src/components/pdf/SimpleLayout.tsx
Normal file
67
packages/pdf-library/src/components/pdf/SimpleLayout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { Header, Footer, pdfStyles } from './SharedUI.js';
|
||||
|
||||
const simpleStyles = StyleSheet.create({
|
||||
industrialPage: {
|
||||
padding: 30,
|
||||
paddingTop: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
industrialNumber: {
|
||||
fontSize: 60,
|
||||
fontWeight: 'bold',
|
||||
color: '#f1f5f9',
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
industrialSection: {
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
interface SimpleLayoutProps {
|
||||
children: React.ReactNode;
|
||||
pageNumber?: string;
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
bankData?: any;
|
||||
showPageNumber?: boolean;
|
||||
}
|
||||
|
||||
export const SimpleLayout = ({
|
||||
children,
|
||||
pageNumber,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
showPageNumber = true
|
||||
}: SimpleLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||
<Header icon={icon} showAddress={false} />
|
||||
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||
<PDFView style={simpleStyles.industrialSection}>
|
||||
<PDFView style={{ width: '100%' }}>
|
||||
{children}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
showDetails={false}
|
||||
showPageNumber={showPageNumber}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import {
|
||||
DocumentTitle,
|
||||
IndustrialListItem,
|
||||
IndustrialCard,
|
||||
Divider,
|
||||
COLORS,
|
||||
FONT_SIZES,
|
||||
} from "../SharedUI";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
industrialTextLead: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
industrialText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
industrialGrid2: { flexDirection: "row" },
|
||||
industrialCol: { width: "46%" },
|
||||
});
|
||||
|
||||
export const AboutModule = () => (
|
||||
<>
|
||||
<DocumentTitle
|
||||
title="Expertise & Profil"
|
||||
subLines={["Entwicklung & Technischer Partner für den Mittelstand"]}
|
||||
isHero={true}
|
||||
/>
|
||||
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||
|
||||
<PDFView style={{ marginTop: 24 }}>
|
||||
<PDFText style={styles.industrialTextLead}>
|
||||
Begleitung mittelständischer Unternehmen und Agenturen bei der
|
||||
Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer
|
||||
mit over 15 Jahren Erfahrung wird das gesamte technische Spektrum
|
||||
abgedeckt – von der Architektur bis zum fertigen Produkt.
|
||||
</PDFText>
|
||||
|
||||
<PDFView style={[styles.industrialGrid2, { marginTop: 20 }]}>
|
||||
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.industrialText,
|
||||
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
|
||||
]}
|
||||
>
|
||||
Erfahrung & Substanz
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Der Werdegang umfasst alle Ebenen der Webentwicklung: von der
|
||||
Teamleitung in Kreativagenturen bis zur Softwareentwicklung für
|
||||
internationale Konzerne.
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität
|
||||
kombiniert, die im Mittelstand gefordert ist. Dieses Wissen
|
||||
ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit
|
||||
Konzern-Standards sind, jedoch ohne unnötigen bürokratischen
|
||||
Overhead auskommen.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.industrialCol}>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.industrialText,
|
||||
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
|
||||
]}
|
||||
>
|
||||
Fokus Einzelentwicklung
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler.
|
||||
Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege
|
||||
und volle technologische Verantwortung.
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Als direkter technischer Sparringspartner bleibt die Codebasis von
|
||||
der ersten bis zur letzten Zeile transparent und wartbar. Diese
|
||||
Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch
|
||||
sauber als auch wirtschaftlich sinnvoll realisiert werden.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
|
||||
<PDFView
|
||||
style={{
|
||||
marginTop: 32,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.GRID,
|
||||
}}
|
||||
>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.industrialText,
|
||||
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||
]}
|
||||
>
|
||||
Infrastruktur & Souveränität
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Es wird keine instabile Prototyp-Software geliefert, sondern
|
||||
produktionsreife Systeme, die technisch skalierbar bleiben. Die
|
||||
Codebasis folgt modernen Standards – bei wachsenden Ansprüchen oder
|
||||
dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos
|
||||
übernommen und weitergeführt werden.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
|
||||
export const CrossSellModule = ({ state }: any) => {
|
||||
const isWebsite = state.projectType === "website";
|
||||
const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme";
|
||||
const subtitle = isWebsite
|
||||
? "Automatisierung und Prozessoptimierung"
|
||||
: "Technische Infrastruktur ohne Kompromisse";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title={title} subLines={[subtitle]} isHero={true} />
|
||||
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||
<PDFView style={[styles.industrialGrid2, { marginTop: 16 }]}>
|
||||
{isWebsite ? (
|
||||
<>
|
||||
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
|
||||
<PDFText style={styles.industrialTextLead}>
|
||||
Über die klassische Webpräsenz hinaus werden maßgeschneiderte
|
||||
Lösungen zur Automatisierung von Routine-Prozessen angeboten.
|
||||
Dies ermöglicht eine signifikante Effizienzsteigerung im
|
||||
Tagesgeschäft.
|
||||
</PDFText>
|
||||
<PDFText style={[styles.industrialText, { fontWeight: "bold" }]}>
|
||||
Keine Abos. Keine komplexen neuen Systeme. Gezielte
|
||||
Zeitersparnis.
|
||||
</PDFText>
|
||||
<PDFView
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
backgroundColor: "#f8fafc",
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.GRID,
|
||||
}}
|
||||
>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.industrialText,
|
||||
{
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Individuelle Analyse
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Spezifische Prozesse werden auf technisches
|
||||
Automatisierungspotenzial untersucht. Das Ergebnis liefert
|
||||
Klarheit über die wirtschaftliche Sinnhaftigkeit einer
|
||||
Umsetzung.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<PDFView style={styles.industrialCol}>
|
||||
<IndustrialCard title="DOKUMENT-AUTOMATION">
|
||||
<PDFText style={styles.industrialText}>
|
||||
Erstellung von PDF-Angeboten, Berichten oder Protokollen in
|
||||
Sekunden statt Stunden.
|
||||
</PDFText>
|
||||
</IndustrialCard>
|
||||
<IndustrialCard title="EXCEL-LOGIK">
|
||||
<PDFText style={styles.industrialText}>
|
||||
Intelligente Tabellen und automatisierte Auswertungen
|
||||
bestehender Datensätze.
|
||||
</PDFText>
|
||||
</IndustrialCard>
|
||||
<IndustrialCard title="KI-ASSISTENZ">
|
||||
<PDFText style={styles.industrialText}>
|
||||
Effiziente Verarbeitung von analogen Dokumenten oder
|
||||
handschriftlichen Notizen mittels KI.
|
||||
</PDFText>
|
||||
</IndustrialCard>
|
||||
</PDFView>
|
||||
</>
|
||||
) : (
|
||||
<PDFView style={{ width: "100%" }}>
|
||||
<PDFText style={styles.industrialTextLead}>
|
||||
Bereitstellung einer stabilen technischen Basis ohne
|
||||
Abhängigkeiten von Baukasten-Systemen oder Agenturen.
|
||||
</PDFText>
|
||||
<PDFText style={styles.industrialText}>
|
||||
Entwicklung performanter Frontends und skalierbarer Backends. Die
|
||||
Auslieferung erfolgt als kontrollierbarer und nachhaltiger
|
||||
Quellcode.
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
visionText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.4,
|
||||
textAlign: "justify",
|
||||
},
|
||||
});
|
||||
|
||||
export const BriefingModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Projektdetails" isHero={true} />
|
||||
{state.briefingSummary && (
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||
<PDFText
|
||||
style={{
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.6,
|
||||
textAlign: "justify",
|
||||
}}
|
||||
>
|
||||
{state.briefingSummary}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
{state.designVision && (
|
||||
<PDFView
|
||||
style={[
|
||||
styles.section,
|
||||
{
|
||||
padding: 12,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
backgroundColor: COLORS.GRID,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||
]}
|
||||
>
|
||||
Strategische Vision
|
||||
</PDFText>
|
||||
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import {
|
||||
DocumentTitle,
|
||||
COLORS,
|
||||
FONT_SIZES,
|
||||
} from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
moduleLabel: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
moduleDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
ledgerRow: {
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
ledgerPrice: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
ledgerUnit: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginLeft: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const ClosingModule = () => (
|
||||
<>
|
||||
<DocumentTitle title="Abschluss & Kontakt" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.moduleLabel,
|
||||
{ fontSize: FONT_SIZES.HEADING, marginBottom: 12 },
|
||||
]}
|
||||
>
|
||||
Vielen Dank für Ihr Interesse!
|
||||
</PDFText>
|
||||
<PDFText style={styles.moduleDesc}>
|
||||
Die aufgeführten Positionen stellen eine detaillierte Schätzung auf
|
||||
Basis unseres aktuellen Stands dar. Sollten sich Anforderungen ändern
|
||||
oder Sie Fragen zu einzelnen Details haben, lassen Sie uns die
|
||||
Positionen gerne gemeinsam besprechen.
|
||||
</PDFText>
|
||||
<PDFView
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
}}
|
||||
>
|
||||
<PDFText style={[styles.moduleLabel, { marginBottom: 8 }]}>
|
||||
Haben Sie Fragen?
|
||||
</PDFText>
|
||||
<PDFText style={styles.moduleDesc}>
|
||||
Ich erkläre Ihnen gerne noch einmal persönlich, was die technische
|
||||
Umsetzung für Ihr Projekt bedeutet und wie wir die nächsten Schritte
|
||||
gemeinsam gehen können.
|
||||
</PDFText>
|
||||
<PDFView style={{ marginTop: 16 }}>
|
||||
<PDFText style={styles.moduleLabel}>Kontakt:</PDFText>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.moduleDesc,
|
||||
{ color: COLORS.CHARCOAL, fontWeight: "bold" },
|
||||
]}
|
||||
>
|
||||
Marc Mintel – marc@mintel.me
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
table: { marginTop: 12 },
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.CHARCOAL,
|
||||
marginBottom: 12,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
colPos: { width: "8%" },
|
||||
colDesc: { width: "62%" },
|
||||
colQty: { width: "10%", textAlign: "center" },
|
||||
colPrice: { width: "20%", textAlign: "right" },
|
||||
headerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
posText: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT },
|
||||
itemTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
priceText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
summaryContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.CHARCOAL,
|
||||
paddingTop: 8,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingVertical: 4,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
fontWeight: "bold",
|
||||
marginRight: 12,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
width: 100,
|
||||
textAlign: "right",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: 12,
|
||||
marginTop: 8,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: COLORS.CHARCOAL,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
});
|
||||
|
||||
export const EstimationModule = ({
|
||||
state,
|
||||
positions,
|
||||
totalPrice,
|
||||
date,
|
||||
}: any) => (
|
||||
<>
|
||||
<DocumentTitle
|
||||
title="Kostenschätzung"
|
||||
subLines={[
|
||||
`Datum: ${date}`,
|
||||
`Projekt: ${state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
]}
|
||||
isHero={true}
|
||||
/>
|
||||
<PDFView style={styles.table}>
|
||||
<PDFView style={styles.tableHeader}>
|
||||
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colDesc]}>
|
||||
Beschreibung
|
||||
</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||
</PDFView>
|
||||
{positions.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||
<PDFText style={[styles.posText, styles.colPos]}>
|
||||
{item.pos.toString().padStart(2, "0")}
|
||||
</PDFText>
|
||||
<PDFView style={styles.colDesc}>
|
||||
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
||||
<PDFText style={styles.itemDesc}>
|
||||
{state.positionDescriptions?.[item.title] || item.desc}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||
{item.price > 0
|
||||
? `${item.price.toLocaleString("de-DE")} €`
|
||||
: "n. A."}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryContainer} wrap={false}>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Nettobetrag</PDFText>
|
||||
<PDFText style={styles.summaryValue}>
|
||||
{totalPrice.toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText>
|
||||
<PDFText style={styles.summaryValue}>
|
||||
{(totalPrice * 0.19).toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.totalRow}>
|
||||
<PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText>
|
||||
<PDFText
|
||||
style={[styles.summaryValue, { fontSize: FONT_SIZES.HEADING }]}
|
||||
>
|
||||
{(totalPrice * 1.19).toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
Image as PDFImage,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titlePage: {
|
||||
flex: 1,
|
||||
padding: 60,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: COLORS.WHITE,
|
||||
},
|
||||
titleBrandIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
borderRadius: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 40,
|
||||
},
|
||||
brandIconText: {
|
||||
fontSize: 40,
|
||||
color: COLORS.WHITE,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
titleProjectName: {
|
||||
fontSize: FONT_SIZES.HERO,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 16,
|
||||
textAlign: "center",
|
||||
maxWidth: "85%",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
titleDate: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginTop: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export const FrontPageModule = ({ state, headerIcon, date }: any) => {
|
||||
const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`;
|
||||
const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
|
||||
|
||||
return (
|
||||
<PDFView style={styles.titlePage}>
|
||||
<PDFView style={styles.titleBrandIcon}>
|
||||
{headerIcon ? (
|
||||
<PDFImage src={headerIcon} style={{ width: 40, height: 40 }} />
|
||||
) : (
|
||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFText style={[styles.titleProjectName, { fontSize }]}>
|
||||
{fullTitle}
|
||||
</PDFText>
|
||||
<PDFView style={{ marginBottom: 40 }} />
|
||||
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 32 },
|
||||
intro: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 24,
|
||||
textAlign: "justify",
|
||||
},
|
||||
sitemapTree: { marginTop: 8 },
|
||||
rootNode: {
|
||||
padding: 12,
|
||||
backgroundColor: COLORS.GRID,
|
||||
marginBottom: 20,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.CHARCOAL,
|
||||
},
|
||||
rootTitle: {
|
||||
fontSize: FONT_SIZES.HEADING,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
categorySection: { marginBottom: 20 },
|
||||
categoryHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingBottom: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.BLUEPRINT,
|
||||
marginBottom: 10,
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderInlineWidth: 1,
|
||||
borderColor: COLORS.DIVIDER,
|
||||
marginRight: 10,
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
pagesGrid: { flexDirection: "row", flexWrap: "wrap" },
|
||||
pageCard: {
|
||||
width: "48%",
|
||||
marginRight: "2%",
|
||||
marginBottom: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_MAIN,
|
||||
marginBottom: 4,
|
||||
},
|
||||
pageDesc: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
});
|
||||
|
||||
export const SitemapModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Informationsarchitektur" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.intro}>
|
||||
Die folgende Struktur definiert die logische Hierarchie und
|
||||
Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und
|
||||
stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv
|
||||
auffindbar sind.
|
||||
</PDFText>
|
||||
|
||||
<PDFView style={styles.sitemapTree}>
|
||||
<PDFView style={styles.rootNode}>
|
||||
<PDFText style={styles.rootTitle}>Seitenstruktur</PDFText>
|
||||
</PDFView>
|
||||
|
||||
{state.sitemap?.map((cat: any, i: number) => (
|
||||
<PDFView key={i} style={styles.categorySection} wrap={false}>
|
||||
<PDFView style={styles.categoryHeader}>
|
||||
<PDFView style={styles.categoryIcon} />
|
||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||
</PDFView>
|
||||
|
||||
<PDFView style={styles.pagesGrid}>
|
||||
{cat.pages.map((p: any, j: number) => (
|
||||
<PDFView
|
||||
key={j}
|
||||
style={[
|
||||
styles.pageCard,
|
||||
j % 2 === 1 ? { marginRight: 0 } : {},
|
||||
]}
|
||||
>
|
||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||
{p.desc && (
|
||||
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
ledgerRow: {
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
moduleLabel: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
moduleDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
ledgerPrice: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
ledgerUnit: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginLeft: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const TransparenzModule = ({ pricing }: any) => {
|
||||
const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title="Preis-Transparenz & Modell" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFView style={{ borderTopWidth: 1, borderTopColor: COLORS.CHARCOAL }}>
|
||||
{[
|
||||
{
|
||||
l: "Fundament",
|
||||
d: "Bereitstellung der techn. Infrastruktur & System-Umgebung.",
|
||||
p: pricing.BASE_WEBSITE,
|
||||
},
|
||||
{
|
||||
l: "Einzelseiten",
|
||||
d: "Individuelle Gestaltung, Layout & responsive Struktur.",
|
||||
p: pricing.PAGE,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Core Features",
|
||||
d: "Geschlossene Datensysteme mit eigener Datenstruktur.",
|
||||
p: pricing.FEATURE,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Logik & Funktionen",
|
||||
d: "Interaktive Funktions-Bausteine & Prozess-Logik.",
|
||||
p: pricing.FUNCTION,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Schnittstellen",
|
||||
d: "Synchronisation mit externen Zielsystemen.",
|
||||
p: pricing.API_INTEGRATION,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Sprachversionen",
|
||||
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
|
||||
p: "+20%",
|
||||
},
|
||||
{
|
||||
l: "Initial-Pflege",
|
||||
d: "Konvertierung & Aufbereitung von Bestandsdaten.",
|
||||
p: pricing.NEW_DATASET,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Sorglos Betrieb",
|
||||
d: "Hosting, Instandhaltung, Security & techn. Support.",
|
||||
p: sorglosPrice,
|
||||
unit: "/ Jahr",
|
||||
},
|
||||
].map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.ledgerRow}>
|
||||
<PDFView style={{ width: "25%" }}>
|
||||
<PDFText style={styles.moduleLabel}>
|
||||
{item.l.toUpperCase()}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={{ width: "50%" }}>
|
||||
<PDFText style={styles.moduleDesc}>{item.d}</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={{ width: "25%", alignItems: "flex-end" }}>
|
||||
<PDFText style={styles.ledgerPrice}>
|
||||
{typeof item.p === "number"
|
||||
? `${item.p.toLocaleString("de-DE")} €`
|
||||
: item.p}
|
||||
{item.unit && (
|
||||
<PDFText style={styles.ledgerUnit}> {item.unit}</PDFText>
|
||||
)}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
15
packages/pdf-library/src/index.ts
Normal file
15
packages/pdf-library/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from "./logic/pricing/types.js";
|
||||
export * from "./logic/pricing/constants.js";
|
||||
export * from "./logic/pricing/calculator.js";
|
||||
export * from "./components/EstimationPDF.js";
|
||||
export * from "./components/pdf/SimpleLayout.js";
|
||||
export * from "./components/pdf/SharedUI.js";
|
||||
export * from "./components/pdf/modules/FrontPageModule.js";
|
||||
export * from "./components/pdf/modules/BriefingModule.js";
|
||||
export * from "./components/pdf/modules/SitemapModule.js";
|
||||
export * from "./components/pdf/modules/EstimationModule.js";
|
||||
export * from "./components/pdf/modules/CommonModules.js";
|
||||
export * from "./components/pdf/modules/BrandingModules.js";
|
||||
export * from "./components/pdf/modules/TransparenzModule.js";
|
||||
export * from "./components/AgbsPDF.js";
|
||||
export * from "./components/CombinedQuotePDF.js";
|
||||
224
packages/pdf-library/src/logic/pricing/calculator.ts
Normal file
224
packages/pdf-library/src/logic/pricing/calculator.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { FormState, Position, Totals } from "./types.js";
|
||||
import {
|
||||
FEATURE_LABELS,
|
||||
FUNCTION_LABELS,
|
||||
API_LABELS,
|
||||
PAGE_LABELS,
|
||||
} from "./constants.js";
|
||||
|
||||
export function calculateTotals(state: FormState, pricing: any): Totals {
|
||||
if (state.projectType !== "website") {
|
||||
return {
|
||||
totalPrice: 0,
|
||||
monthlyPrice: 0,
|
||||
totalPagesCount: 0,
|
||||
totalFeatures: 0,
|
||||
totalFunctions: 0,
|
||||
totalApis: 0,
|
||||
languagesCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sitemapPagesCount =
|
||||
state.sitemap?.reduce(
|
||||
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const totalPagesCount = Math.max(
|
||||
(state.selectedPages?.length || 0) +
|
||||
(state.otherPages?.length || 0) +
|
||||
(state.otherPagesCount || 0),
|
||||
sitemapPagesCount,
|
||||
);
|
||||
|
||||
const totalFeatures =
|
||||
(state.features?.length || 0) +
|
||||
(state.otherFeatures?.length || 0) +
|
||||
(state.otherFeaturesCount || 0);
|
||||
const totalFunctions =
|
||||
(state.functions?.length || 0) +
|
||||
(state.otherFunctions?.length || 0) +
|
||||
(state.otherFunctionsCount || 0);
|
||||
const totalApis =
|
||||
(state.apiSystems?.length || 0) +
|
||||
(state.otherTech?.length || 0) +
|
||||
(state.otherTechCount || 0);
|
||||
|
||||
let total = pricing.BASE_WEBSITE;
|
||||
total += totalPagesCount * pricing.PAGE;
|
||||
total += totalFeatures * pricing.FEATURE;
|
||||
total += totalFunctions * pricing.FUNCTION;
|
||||
total += totalApis * pricing.API_INTEGRATION;
|
||||
total += (state.newDatasets || 0) * pricing.NEW_DATASET;
|
||||
|
||||
if (state.cmsSetup) {
|
||||
total += Math.max(1, totalFeatures) * pricing.CMS_CONNECTION_PER_FEATURE;
|
||||
}
|
||||
|
||||
const languagesCount = state.languagesList?.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
total *= 1 + (languagesCount - 1) * 0.2;
|
||||
}
|
||||
|
||||
const monthlyPrice =
|
||||
pricing.HOSTING_MONTHLY +
|
||||
(state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||
|
||||
return {
|
||||
totalPrice: Math.round(total),
|
||||
monthlyPrice: Math.round(monthlyPrice),
|
||||
totalPagesCount,
|
||||
totalFeatures,
|
||||
totalFunctions,
|
||||
totalApis,
|
||||
languagesCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||
const positions: Position[] = [];
|
||||
let pos = 1;
|
||||
|
||||
if (state.projectType === "website") {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Das technische Fundament",
|
||||
desc: "Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.",
|
||||
qty: 1,
|
||||
price: pricing.BASE_WEBSITE,
|
||||
});
|
||||
|
||||
const sitemapPagesCount =
|
||||
state.sitemap?.reduce(
|
||||
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const totalPagesCount = Math.max(
|
||||
(state.selectedPages?.length || 0) +
|
||||
(state.otherPages?.length || 0) +
|
||||
(state.otherPagesCount || 0),
|
||||
sitemapPagesCount,
|
||||
);
|
||||
|
||||
const allPages = [
|
||||
...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p),
|
||||
...(state.otherPages || []),
|
||||
...(state.sitemap?.flatMap((cat: any) =>
|
||||
cat.pages?.map((p: any) => p.title),
|
||||
) || []),
|
||||
];
|
||||
|
||||
// Deduplicate labels
|
||||
const uniquePages = Array.from(new Set(allPages));
|
||||
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Individuelle Seiten",
|
||||
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(", ")}).`,
|
||||
qty: totalPagesCount,
|
||||
price: totalPagesCount * pricing.PAGE,
|
||||
});
|
||||
|
||||
if ((state.features?.length || 0) > 0 || (state.otherFeatures?.length || 0) > 0) {
|
||||
const allFeatures = [
|
||||
...(state.features || []).map((f: string) => FEATURE_LABELS[f] || f),
|
||||
...(state.otherFeatures || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "System-Module (Features)",
|
||||
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(", ")}. Inklusive Datenstruktur und Darstellung.`,
|
||||
qty: allFeatures.length,
|
||||
price: allFeatures.length * pricing.FEATURE,
|
||||
});
|
||||
}
|
||||
|
||||
if ((state.functions?.length || 0) > 0 || (state.otherFunctions?.length || 0) > 0) {
|
||||
const allFunctions = [
|
||||
...(state.functions || []).map((f: string) => FUNCTION_LABELS[f] || f),
|
||||
...(state.otherFunctions || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Logik-Funktionen",
|
||||
desc: `Implementierung technischer Logik: ${allFunctions.join(", ")}.`,
|
||||
qty: allFunctions.length,
|
||||
price: allFunctions.length * pricing.FUNCTION,
|
||||
});
|
||||
}
|
||||
|
||||
if ((state.apiSystems?.length || 0) > 0 || (state.otherTech?.length || 0) > 0) {
|
||||
const allApis = [
|
||||
...(state.apiSystems || []).map((a: string) => API_LABELS[a] || a),
|
||||
...(state.otherTech || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Schnittstellen (API)",
|
||||
desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(", ")}.`,
|
||||
qty: allApis.length,
|
||||
price: allApis.length * pricing.API_INTEGRATION,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.cmsSetup) {
|
||||
const totalFeatures =
|
||||
(state.features?.length || 0) +
|
||||
(state.otherFeatures?.length || 0) +
|
||||
(state.otherFeaturesCount || 0);
|
||||
const qty = Math.max(1, totalFeatures);
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Inhalts-Verwaltung",
|
||||
desc: "Anbindung der System-Module an das Redaktions-System zur eigenständigen Pflege von Inhalten.",
|
||||
qty: qty,
|
||||
price: qty * pricing.CMS_CONNECTION_PER_FEATURE,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.newDatasets > 0) {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Inhaltliche Initial-Pflege",
|
||||
desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`,
|
||||
qty: state.newDatasets,
|
||||
price: state.newDatasets * pricing.NEW_DATASET,
|
||||
});
|
||||
}
|
||||
|
||||
const languagesCount = state.languagesList?.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
const subtotal = positions.reduce((sum, p) => sum + p.price, 0);
|
||||
const factorPrice = subtotal * ((languagesCount - 1) * 0.2);
|
||||
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Mehrsprachigkeit",
|
||||
desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`,
|
||||
qty: languagesCount,
|
||||
price: Math.round(factorPrice),
|
||||
});
|
||||
}
|
||||
|
||||
const monthlyRate =
|
||||
pricing.HOSTING_MONTHLY +
|
||||
state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Sorglos Betrieb (1 Jahr)",
|
||||
desc: `Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a.`,
|
||||
qty: 1,
|
||||
price: monthlyRate * 12,
|
||||
});
|
||||
} else {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Web App / Software Entwicklung",
|
||||
desc: "Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.",
|
||||
qty: 1,
|
||||
price: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
332
packages/pdf-library/src/logic/pricing/constants.ts
Normal file
332
packages/pdf-library/src/logic/pricing/constants.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { FormState } from "./types.js";
|
||||
|
||||
export const PRICING = {
|
||||
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
|
||||
PAGE: 600,
|
||||
FEATURE: 1500,
|
||||
FUNCTION: 800,
|
||||
NEW_DATASET: 450,
|
||||
HOSTING_MONTHLY: 250,
|
||||
STORAGE_EXPANSION_MONTHLY: 10,
|
||||
CMS_SETUP: 1500,
|
||||
CMS_CONNECTION_PER_FEATURE: 1500,
|
||||
API_INTEGRATION: 800,
|
||||
APP_HOURLY: 120,
|
||||
};
|
||||
|
||||
export const initialState: FormState = {
|
||||
projectType: "website",
|
||||
// Company
|
||||
companyName: "",
|
||||
employeeCount: "",
|
||||
// Existing Presence
|
||||
existingWebsite: "",
|
||||
socialMedia: [],
|
||||
socialMediaUrls: {},
|
||||
existingDomain: "",
|
||||
wishedDomain: "",
|
||||
// Project
|
||||
websiteTopic: "",
|
||||
selectedPages: ["Home"],
|
||||
otherPages: [],
|
||||
otherPagesCount: 0,
|
||||
features: [],
|
||||
otherFeatures: [],
|
||||
otherFeaturesCount: 0,
|
||||
functions: [],
|
||||
otherFunctions: [],
|
||||
otherFunctionsCount: 0,
|
||||
apiSystems: [],
|
||||
otherTech: [],
|
||||
otherTechCount: 0,
|
||||
assets: [],
|
||||
otherAssets: [],
|
||||
otherAssetsCount: 0,
|
||||
newDatasets: 0,
|
||||
cmsSetup: false,
|
||||
storageExpansion: 0,
|
||||
name: "",
|
||||
email: "",
|
||||
role: "",
|
||||
message: "",
|
||||
sitemapFile: null,
|
||||
contactFiles: [],
|
||||
// Design
|
||||
designVibe: "minimal",
|
||||
colorScheme: ["#ffffff", "#f8fafc", "#0f172a"],
|
||||
references: [],
|
||||
designWishes: "",
|
||||
// Maintenance
|
||||
expectedAdjustments: "low",
|
||||
languagesList: ["Deutsch"],
|
||||
personName: "",
|
||||
// Timeline
|
||||
deadline: "flexible",
|
||||
// Web App specific
|
||||
targetAudience: "internal",
|
||||
userRoles: [],
|
||||
dataSensitivity: "standard",
|
||||
platformType: "web-only",
|
||||
// Meta
|
||||
dontKnows: [],
|
||||
visualStaging: "standard",
|
||||
complexInteractions: "standard",
|
||||
// AI generated / Post-processed
|
||||
briefingSummary: "",
|
||||
designVision: "",
|
||||
positionDescriptions: {},
|
||||
taxId: "",
|
||||
sitemap: [],
|
||||
};
|
||||
|
||||
export const PAGE_SAMPLES = [
|
||||
{ id: "Home", label: "Startseite", desc: "Der erste Eindruck Ihrer Marke." },
|
||||
{ id: "About", label: "Über uns", desc: "Ihre Geschichte und Ihr Team." },
|
||||
{ id: "Services", label: "Leistungen", desc: "Übersicht Ihres Angebots." },
|
||||
{ id: "Contact", label: "Kontakt", desc: "Anlaufstelle für Ihre Kunden." },
|
||||
{
|
||||
id: "Landing",
|
||||
label: "Landingpage",
|
||||
desc: "Optimiert für Marketing-Kampagnen.",
|
||||
},
|
||||
{ id: "Legal", label: "Rechtliches", desc: "Impressum & Datenschutz." },
|
||||
];
|
||||
|
||||
export const FEATURE_OPTIONS = [
|
||||
{
|
||||
id: "blog_news",
|
||||
label: "Blog / News",
|
||||
desc: "Ein Bereich für aktuelle Beiträge und Neuigkeiten.",
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
label: "Produktbereich",
|
||||
desc: "Katalog Ihrer Leistungen oder Produkte.",
|
||||
},
|
||||
{
|
||||
id: "jobs",
|
||||
label: "Karriere / Jobs",
|
||||
desc: "Stellenanzeigen und Bewerbungsoptionen.",
|
||||
},
|
||||
{
|
||||
id: "refs",
|
||||
label: "Referenzen / Cases",
|
||||
desc: "Präsentation Ihrer Projekte.",
|
||||
},
|
||||
{ id: "events", label: "Events / Termine", desc: "Veranstaltungskalender." },
|
||||
];
|
||||
|
||||
export const FUNCTION_OPTIONS = [
|
||||
{ id: "search", label: "Suche", desc: "Volltextsuche über alle Inhalte." },
|
||||
{
|
||||
id: "filter",
|
||||
label: "Filter-Systeme",
|
||||
desc: "Kategorisierung und Sortierung.",
|
||||
},
|
||||
{ id: "pdf", label: "PDF-Export", desc: "Automatisierte PDF-Erstellung." },
|
||||
{
|
||||
id: "forms",
|
||||
label: "Individuelle Formular-Logik",
|
||||
desc: "Smarte Validierung & mehrstufige Prozesse.",
|
||||
},
|
||||
];
|
||||
|
||||
export const API_OPTIONS = [
|
||||
{
|
||||
id: "crm",
|
||||
label: "CRM System",
|
||||
desc: "HubSpot, Salesforce, Pipedrive etc.",
|
||||
},
|
||||
{
|
||||
id: "erp",
|
||||
label: "ERP / Warenwirtschaft",
|
||||
desc: "SAP, Microsoft Dynamics, Xentral etc.",
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
label: "Stripe / Payment",
|
||||
desc: "Zahlungsabwicklung und Abonnements.",
|
||||
},
|
||||
{
|
||||
id: "newsletter",
|
||||
label: "Newsletter / Marketing",
|
||||
desc: "Mailchimp, Brevo, ActiveCampaign etc.",
|
||||
},
|
||||
{
|
||||
id: "ecommerce",
|
||||
label: "E-Commerce / Shop",
|
||||
desc: "Shopify, WooCommerce, Shopware Sync.",
|
||||
},
|
||||
{
|
||||
id: "hr",
|
||||
label: "HR / Recruiting",
|
||||
desc: "Personio, Workday, Recruitee etc.",
|
||||
},
|
||||
{
|
||||
id: "realestate",
|
||||
label: "Immobilien",
|
||||
desc: "OpenImmo, FlowFact, Immowelt Sync.",
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
label: "Termine / Booking",
|
||||
desc: "Calendly, Shore, Doctolib etc.",
|
||||
},
|
||||
{
|
||||
id: "social",
|
||||
label: "Social Media Sync",
|
||||
desc: "Automatisierte Posts oder Feeds.",
|
||||
},
|
||||
{
|
||||
id: "maps",
|
||||
label: "Google Maps / Places",
|
||||
desc: "Standortsuche und Kartenintegration.",
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "Custom Analytics",
|
||||
desc: "Anbindung an spezialisierte Tracking-Tools.",
|
||||
},
|
||||
];
|
||||
|
||||
export const ASSET_OPTIONS = [
|
||||
{
|
||||
id: "existing_website",
|
||||
label: "Bestehende Website",
|
||||
desc: "Inhalte oder Struktur können übernommen werden.",
|
||||
},
|
||||
{ id: "logo", label: "Logo", desc: "Vektordatei Ihres Logos." },
|
||||
{
|
||||
id: "styleguide",
|
||||
label: "Styleguide",
|
||||
desc: "Farben, Schriften, Design-Vorgaben.",
|
||||
},
|
||||
{
|
||||
id: "content_concept",
|
||||
label: "Inhalts-Konzept",
|
||||
desc: "Struktur und Texte sind bereits geplant.",
|
||||
},
|
||||
{
|
||||
id: "media",
|
||||
label: "Bild/Video-Material",
|
||||
desc: "Professionelles Bildmaterial vorhanden.",
|
||||
},
|
||||
{ id: "icons", label: "Icons", desc: "Eigene Icon-Sets vorhanden." },
|
||||
{
|
||||
id: "illustrations",
|
||||
label: "Illustrationen",
|
||||
desc: "Eigene Illustrationen vorhanden.",
|
||||
},
|
||||
{
|
||||
id: "fonts",
|
||||
label: "Fonts",
|
||||
desc: "Lizenzen für Hausschriften vorhanden.",
|
||||
},
|
||||
];
|
||||
|
||||
export const DESIGN_OPTIONS = [
|
||||
{
|
||||
id: "minimal",
|
||||
label: "Minimalistisch",
|
||||
desc: "Viel Weißraum, klare Typografie.",
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Mutig & Laut",
|
||||
desc: "Starke Kontraste, große Schriften.",
|
||||
},
|
||||
{
|
||||
id: "nature",
|
||||
label: "Natürlich",
|
||||
desc: "Sanfte Erdtöne, organische Formen.",
|
||||
},
|
||||
{ id: "tech", label: "Technisch", desc: "Präzise Linien, dunkle Akzente." },
|
||||
];
|
||||
|
||||
export const EMPLOYEE_OPTIONS = [
|
||||
{ id: "1-5", label: "1-5 Mitarbeiter" },
|
||||
{ id: "6-20", label: "6-20 Mitarbeiter" },
|
||||
{ id: "21-100", label: "21-100 Mitarbeiter" },
|
||||
{ id: "100+", label: "100+ Mitarbeiter" },
|
||||
];
|
||||
|
||||
export const SOCIAL_MEDIA_OPTIONS = [
|
||||
{ id: "instagram", label: "Instagram" },
|
||||
{ id: "linkedin", label: "LinkedIn" },
|
||||
{ id: "facebook", label: "Facebook" },
|
||||
{ id: "twitter", label: "Twitter / X" },
|
||||
{ id: "tiktok", label: "TikTok" },
|
||||
{ id: "youtube", label: "YouTube" },
|
||||
];
|
||||
|
||||
export const VIBE_LABELS: Record<string, string> = {
|
||||
minimal: "Minimalistisch",
|
||||
bold: "Mutig & Laut",
|
||||
nature: "Natürlich",
|
||||
tech: "Technisch",
|
||||
};
|
||||
|
||||
export const DEADLINE_LABELS: Record<string, string> = {
|
||||
asap: "So schnell wie möglich",
|
||||
"2-3-months": "In 2-3 Monaten",
|
||||
"3-6-months": "In 3-6 Monaten",
|
||||
flexible: "Flexibel",
|
||||
};
|
||||
|
||||
export const ASSET_LABELS: Record<string, string> = {
|
||||
existing_website: "Bestehende Website",
|
||||
logo: "Logo",
|
||||
styleguide: "Styleguide",
|
||||
content_concept: "Inhalts-Konzept",
|
||||
media: "Bild/Video-Material",
|
||||
icons: "Icons",
|
||||
illustrations: "Illustrationen",
|
||||
fonts: "Fonts",
|
||||
};
|
||||
|
||||
export const FEATURE_LABELS: Record<string, string> = {
|
||||
blog_news: "Blog / News",
|
||||
products: "Produktbereich",
|
||||
jobs: "Karriere / Jobs",
|
||||
refs: "Referenzen / Cases",
|
||||
events: "Events / Termine",
|
||||
};
|
||||
|
||||
export const FUNCTION_LABELS: Record<string, string> = {
|
||||
search: "Suche",
|
||||
filter: "Filter-Systeme",
|
||||
pdf: "PDF-Export",
|
||||
forms: "Individuelle Formular-Logik",
|
||||
members: "Mitgliederbereich",
|
||||
calendar: "Event-Kalender",
|
||||
multilang: "Mehrsprachigkeit",
|
||||
chat: "Echtzeit-Chat",
|
||||
};
|
||||
|
||||
export const API_LABELS: Record<string, string> = {
|
||||
crm_erp: "CRM / ERP",
|
||||
payment: "Payment",
|
||||
marketing: "Marketing",
|
||||
ecommerce: "E-Commerce",
|
||||
maps: "Google Maps / Places",
|
||||
social: "Social Media Sync",
|
||||
analytics: "Custom Analytics",
|
||||
};
|
||||
|
||||
export const SOCIAL_LABELS: Record<string, string> = {
|
||||
instagram: "Instagram",
|
||||
linkedin: "LinkedIn",
|
||||
facebook: "Facebook",
|
||||
twitter: "Twitter / X",
|
||||
tiktok: "TikTok",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
export const PAGE_LABELS: Record<string, string> = {
|
||||
Home: "Startseite",
|
||||
About: "Über uns",
|
||||
Services: "Leistungen",
|
||||
Contact: "Kontakt",
|
||||
Landing: "Landingpage",
|
||||
Legal: "Impressum & Datenschutz",
|
||||
};
|
||||
89
packages/pdf-library/src/logic/pricing/types.ts
Normal file
89
packages/pdf-library/src/logic/pricing/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export type ProjectType = 'website' | 'web-app';
|
||||
|
||||
export interface FormState {
|
||||
projectType: ProjectType;
|
||||
// Company
|
||||
companyName: string;
|
||||
employeeCount: string;
|
||||
// Existing Presence
|
||||
existingWebsite: string;
|
||||
socialMedia: string[];
|
||||
socialMediaUrls: Record<string, string>;
|
||||
existingDomain: string;
|
||||
wishedDomain: string;
|
||||
// Project
|
||||
websiteTopic: string;
|
||||
selectedPages: string[];
|
||||
otherPages: string[];
|
||||
otherPagesCount: number;
|
||||
features: string[];
|
||||
otherFeatures: string[];
|
||||
otherFeaturesCount: number;
|
||||
functions: string[];
|
||||
otherFunctions: string[];
|
||||
otherFunctionsCount: number;
|
||||
apiSystems: string[];
|
||||
otherTech: string[];
|
||||
otherTechCount: number;
|
||||
assets: string[];
|
||||
otherAssets: string[];
|
||||
otherAssetsCount: number;
|
||||
newDatasets: number;
|
||||
cmsSetup: boolean;
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: any;
|
||||
contactFiles: any[];
|
||||
// Design
|
||||
designVibe: string;
|
||||
colorScheme: string[];
|
||||
references: string[];
|
||||
designWishes: string;
|
||||
// Maintenance
|
||||
expectedAdjustments: string;
|
||||
languagesList: string[];
|
||||
// Timeline
|
||||
deadline: string;
|
||||
// Web App specific
|
||||
targetAudience: string;
|
||||
userRoles: string[];
|
||||
dataSensitivity: string;
|
||||
platformType: string;
|
||||
// Meta
|
||||
dontKnows: string[];
|
||||
visualStaging: string;
|
||||
complexInteractions: string;
|
||||
gridDontKnows?: Record<string, string>;
|
||||
briefingSummary?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
personName?: string;
|
||||
taxId?: string;
|
||||
designVision?: string;
|
||||
positionDescriptions?: Record<string, string>;
|
||||
sitemap?: {
|
||||
category: string;
|
||||
pages: { title: string; desc: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
pos: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
export interface Totals {
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
totalFeatures: number;
|
||||
totalFunctions: number;
|
||||
totalApis: number;
|
||||
languagesCount: number;
|
||||
}
|
||||
3
packages/pdf-library/src/server.ts
Normal file
3
packages/pdf-library/src/server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./index.js";
|
||||
export * from "./services/AcquisitionService.js";
|
||||
export * from "./services/PdfEngine.js";
|
||||
153
packages/pdf-library/src/services/AcquisitionService.ts
Normal file
153
packages/pdf-library/src/services/AcquisitionService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { CheerioCrawler } from "@crawlee/cheerio";
|
||||
import axios from "axios";
|
||||
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
||||
import { initialState } from "../logic/pricing/constants.js";
|
||||
import { FormState } from "../logic/pricing/types.js";
|
||||
|
||||
export interface AcquisitionResult {
|
||||
state: FormState;
|
||||
usage: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AcquisitionService {
|
||||
private cache: FileCacheAdapter;
|
||||
private openRouterKey: string;
|
||||
|
||||
constructor(openRouterKey: string) {
|
||||
this.openRouterKey = openRouterKey;
|
||||
this.cache = new FileCacheAdapter({ prefix: "acq_" });
|
||||
}
|
||||
|
||||
async runFullSequence(url: string, briefing: string, comments?: string): Promise<AcquisitionResult> {
|
||||
console.log(`🚀 Starting Acquisition Sequence for: ${url}`);
|
||||
|
||||
// 1. Crawl
|
||||
const crawlData = await this.performCrawl(url);
|
||||
|
||||
// 2. Distill
|
||||
const distilledContext = await this.distillCrawlContext(crawlData);
|
||||
|
||||
// 3. AI Estimation (using parts of the original ai-estimate logic)
|
||||
// For brevity in this initial port, I'll implement a combined prompt strategy
|
||||
// or keep the multi-pass if needed.
|
||||
|
||||
const result = await this.getAiEstimation(briefing, distilledContext, comments || null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performCrawl(url: string): Promise<string> {
|
||||
const pages: any[] = [];
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
const crawler = new CheerioCrawler({
|
||||
maxRequestsPerCrawl: 15,
|
||||
async requestHandler({ $, request, enqueueLinks }) {
|
||||
const title = $("title").text();
|
||||
const bodyText = $("body").text().replace(/\s+/g, " ").substring(0, 10000);
|
||||
|
||||
pages.push({
|
||||
url: request.url,
|
||||
content: `Title: ${title}\nText: ${bodyText}`,
|
||||
});
|
||||
|
||||
await enqueueLinks({
|
||||
limit: 10,
|
||||
transformRequestFunction: (req) => {
|
||||
try {
|
||||
const reqUrl = new URL(req.url);
|
||||
if (reqUrl.origin !== origin) return false;
|
||||
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
|
||||
return req;
|
||||
} catch (_error) {
|
||||
// Ignored - malformed URL in enqueueLinks
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run([url]);
|
||||
return pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n");
|
||||
}
|
||||
|
||||
private async distillCrawlContext(rawCrawl: string): Promise<string> {
|
||||
const systemPrompt = `
|
||||
You are a context distiller. Extract the "Company DNA" in 5-8 bullet points (GERMAN).
|
||||
Focus on: Services, USPs, Target Audience, Tone.
|
||||
`;
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `RAW_CRAWL:\n${rawCrawl.substring(0, 20000)}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||
}
|
||||
);
|
||||
|
||||
return resp.data.choices[0].message.content;
|
||||
}
|
||||
|
||||
private async getAiEstimation(briefing: string, context: string, comments: string | null): Promise<AcquisitionResult> {
|
||||
// Porting a simplified version of Pass 1 and Pass 3 together for the "Audit"
|
||||
const systemPrompt = `
|
||||
You are a Digital Architect. Analyze the briefing and crawl context.
|
||||
Generate a JSON state for a project estimation.
|
||||
Language: GERMAN.
|
||||
Format: ROOT LEVEL JSON matching FormState interface.
|
||||
|
||||
### PRICING RULES:
|
||||
- Base: 5440 €
|
||||
- Page: 600 €
|
||||
- Feature: 1500 €
|
||||
- Function/API: 800 €
|
||||
|
||||
Return ONLY the JSON.
|
||||
`;
|
||||
const userPrompt = `BRIEFING: ${briefing}\n\nCONTEXT: ${context}\n\nCOMMENTS: ${comments}`;
|
||||
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||
}
|
||||
);
|
||||
|
||||
let state: FormState;
|
||||
try {
|
||||
state = JSON.parse(resp.data.choices[0].message.content);
|
||||
} catch (_error) {
|
||||
console.error("Failed to parse AI estimation JSON, returning initial state.");
|
||||
state = initialState;
|
||||
}
|
||||
// Ensure it matches FormState defaults
|
||||
const finalState = { ...initialState, ...state };
|
||||
|
||||
return {
|
||||
state: finalState,
|
||||
usage: {
|
||||
prompt: resp.data.usage?.prompt_tokens || 0,
|
||||
completion: resp.data.usage?.completion_tokens || 0,
|
||||
cost: resp.data.usage?.cost || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/pdf-library/src/services/PdfEngine.ts
Normal file
24
packages/pdf-library/src/services/PdfEngine.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { renderToFile } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { EstimationPDF } from "../components/EstimationPDF.js";
|
||||
import { PRICING } from "../logic/pricing/constants.js";
|
||||
import { calculateTotals } from "../logic/pricing/calculator.js";
|
||||
|
||||
export class PdfEngine {
|
||||
constructor() { }
|
||||
|
||||
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
|
||||
await renderToFile(
|
||||
createElement(EstimationPDF as any, {
|
||||
state,
|
||||
totalPrice: totals.totalPrice,
|
||||
pricing: PRICING,
|
||||
} as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
78
packages/pdf-library/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
78
packages/pdf-library/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
export class FileCacheAdapter {
|
||||
private cacheDir: string;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config?: { cacheDir?: string; prefix?: string; defaultTTL?: number }) {
|
||||
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
|
||||
this.prefix = config?.prefix || '';
|
||||
this.defaultTTL = config?.defaultTTL || 3600;
|
||||
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(key: string): string {
|
||||
const clean = key.replace(/[^a-z0-9]/gi, '_');
|
||||
if (clean.length > 64) {
|
||||
return crypto.createHash('md5').update(key).digest('hex');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
|
||||
return path.join(this.cacheDir, `${safeKey}.json`);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.expiry && Date.now() > data.expiry) {
|
||||
await this.del(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (_error) {
|
||||
return null; // Keeping original return type Promise<T | null>
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
|
||||
const data = {
|
||||
value,
|
||||
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
} catch (_error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignored - best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/pdf-library/tsconfig.json
Normal file
24
packages/pdf-library/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"declarationDir": "dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build.mjs"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"id": "people-manager",
|
||||
"description": "Custom High-Fidelity People Management for Directus",
|
||||
"icon": "person",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"id": "people-manager",
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
@@ -20,7 +19,7 @@
|
||||
"name": "People Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,18 +1,356 @@
|
||||
<template>
|
||||
<private-view title="People Manager">
|
||||
<div class="people-manager">
|
||||
<h1>People Manager</h1>
|
||||
<p>Modern Industrial People Management Interface</p>
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateDrawer" clickable>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="add" color="var(--theme--primary)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Person anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
:active="selectedPerson?.id === person.id"
|
||||
class="person-item"
|
||||
clickable
|
||||
@click="selectPerson(person)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="person" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
|
||||
{{ feedback.message }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedPerson" class="empty-state">
|
||||
<v-info title="Person auswählen" icon="person" center>
|
||||
Wähle eine Person in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
|
||||
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">Organisation / Firma</span>
|
||||
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Telefon</span>
|
||||
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Drawer -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<template #default>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="form.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Zentrale Firma</span>
|
||||
<v-select
|
||||
v-model="form.company"
|
||||
:items="companyOptions"
|
||||
placeholder="Bestehende Firma auswählen..."
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Firma (Legacy / Neu)</span>
|
||||
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Telefon</span>
|
||||
<v-input v-model="form.phone" placeholder="+49 ..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="savePerson">
|
||||
Person speichern
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Logic will be added here
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const people = ref([]);
|
||||
const companies = ref([]);
|
||||
const selectedPerson = ref(null);
|
||||
const feedback = ref(null);
|
||||
const saving = ref(false);
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
company: null,
|
||||
company_name: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(c => ({
|
||||
text: c.name,
|
||||
value: c.id
|
||||
}))
|
||||
);
|
||||
|
||||
function getCompanyName(person: any) {
|
||||
if (!person) return '---';
|
||||
if (person.company) {
|
||||
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || person.company_name);
|
||||
}
|
||||
return person.company_name || '---';
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [peopleResp, companiesResp] = await Promise.all([
|
||||
api.get('/items/people', {
|
||||
params: {
|
||||
sort: 'last_name',
|
||||
fields: '*.*'
|
||||
}
|
||||
}),
|
||||
api.get('/items/companies', {
|
||||
params: { sort: 'name' }
|
||||
})
|
||||
]);
|
||||
people.value = peopleResp.data.data;
|
||||
companies.value = companiesResp.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person) {
|
||||
selectedPerson.value = person;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = {
|
||||
id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
company: null,
|
||||
company_name: '',
|
||||
phone: ''
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
isEditing.value = true;
|
||||
const person = selectedPerson.value;
|
||||
let companyId = null;
|
||||
let companyName = person.company_name || '';
|
||||
|
||||
if (person.company) {
|
||||
if (typeof person.company === 'object') {
|
||||
companyId = person.company.id;
|
||||
} else if (person.company.length === 36) { // Assume UUID
|
||||
companyId = person.company;
|
||||
} else {
|
||||
companyName = person.company;
|
||||
}
|
||||
}
|
||||
|
||||
form.value = {
|
||||
...person,
|
||||
company: companyId,
|
||||
company_name: companyName
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function savePerson() {
|
||||
if (!form.value.first_name || !form.value.last_name) {
|
||||
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.patch(`/items/people/${form.value.id}`, form.value);
|
||||
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/people', form.value);
|
||||
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchData();
|
||||
if (isEditing.value) {
|
||||
selectedPerson.value = people.value.find(p => p.id === form.value.id);
|
||||
}
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePerson() {
|
||||
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
||||
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
||||
selectedPerson.value = null;
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.people-manager {
|
||||
padding: 20px;
|
||||
.content-wrapper {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/tsconfig",
|
||||
"version": "1.7.9",
|
||||
"version": "1.8.2",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
27
packages/unified-dashboard/package.json
Normal file
27
packages/unified-dashboard/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "unified-dashboard",
|
||||
"description": "Unified Infrastructure Dashboard for Directus",
|
||||
"icon": "dashboard",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Unified Dashboard"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
2083
pnpm-lock.yaml
generated
2083
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user