feat(pdf): rename acquisition-library to pdf-library and update package name to @mintel/pdf
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 13s
Monorepo Pipeline / 🏗️ Build (push) Failing after 11s
Monorepo Pipeline / 🧪 Test (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 13s
Monorepo Pipeline / 🏗️ Build (push) Failing after 11s
Monorepo Pipeline / 🧪 Test (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user