feat: Centralize pricing calculation logic into a new module and add a script for generating quotes.
This commit is contained in:
BIN
din_test_final.pdf
Normal file
BIN
din_test_final.pdf
Normal file
Binary file not shown.
BIN
din_test_final_v2.pdf
Normal file
BIN
din_test_final_v2.pdf
Normal file
Binary file not shown.
BIN
din_test_v2.pdf
Normal file
BIN
din_test_v2.pdf
Normal file
Binary file not shown.
@@ -14,6 +14,7 @@
|
|||||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||||
"clone-website": "tsx ./scripts/clone-recursive.ts",
|
"clone-website": "tsx ./scripts/clone-recursive.ts",
|
||||||
"clone-page": "tsx ./scripts/clone-page.ts",
|
"clone-page": "tsx ./scripts/clone-page.ts",
|
||||||
|
"generate-quote": "tsx ./scripts/generate-quote.ts",
|
||||||
"video:preview": "remotion preview video/index.ts",
|
"video:preview": "remotion preview video/index.ts",
|
||||||
"video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4",
|
"video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4",
|
||||||
"video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
"video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||||
@@ -65,4 +66,4 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
167
scripts/generate-quote.ts
Normal file
167
scripts/generate-quote.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { renderToFile } from '@react-pdf/renderer';
|
||||||
|
import { EstimationPDF } from '../src/components/EstimationPDF';
|
||||||
|
import {
|
||||||
|
PRICING,
|
||||||
|
initialState,
|
||||||
|
PAGE_SAMPLES,
|
||||||
|
FEATURE_OPTIONS,
|
||||||
|
FUNCTION_OPTIONS,
|
||||||
|
API_OPTIONS,
|
||||||
|
DESIGN_OPTIONS,
|
||||||
|
DEADLINE_LABELS,
|
||||||
|
ASSET_OPTIONS,
|
||||||
|
EMPLOYEE_OPTIONS,
|
||||||
|
calculatePositions,
|
||||||
|
FormState
|
||||||
|
} from '../src/logic/pricing';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { parseArgs } from 'util';
|
||||||
|
import * as readline from 'readline/promises';
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ask(question: string, defaultValue?: string): Promise<string> {
|
||||||
|
const answer = await rl.question(`${question}${defaultValue ? ` [${defaultValue}]` : ''}: `);
|
||||||
|
return answer.trim() || defaultValue || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectOne(question: string, options: { id: string; label: string }[], defaultValue?: string): Promise<string> {
|
||||||
|
console.log(`\n${question}`);
|
||||||
|
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
|
||||||
|
const answer = await ask('Select number');
|
||||||
|
const index = parseInt(answer) - 1;
|
||||||
|
return options[index]?.id || defaultValue || options[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMany(question: string, options: { id: string; label: string }[]): Promise<string[]> {
|
||||||
|
console.log(`\n${question} (comma separated numbers, e.g. 1,2,4)`);
|
||||||
|
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
|
||||||
|
const answer = await ask('Selection');
|
||||||
|
if (!answer) return [];
|
||||||
|
return answer.split(',')
|
||||||
|
.map(n => parseInt(n.trim()) - 1)
|
||||||
|
.filter(i => options[i])
|
||||||
|
.map(i => options[i].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: {
|
||||||
|
input: { type: 'string', short: 'i' },
|
||||||
|
output: { type: 'string', short: 'o' },
|
||||||
|
interactive: { type: 'boolean', short: 'I', default: false },
|
||||||
|
name: { type: 'string' },
|
||||||
|
company: { type: 'string' },
|
||||||
|
email: { type: 'string' },
|
||||||
|
'project-type': { type: 'string' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let state: FormState = { ...initialState };
|
||||||
|
|
||||||
|
// Apply command line flags first
|
||||||
|
if (values.name) state.name = values.name as string;
|
||||||
|
if (values.company) state.companyName = values.company as string;
|
||||||
|
if (values.email) state.email = values.email as string;
|
||||||
|
if (values['project-type']) state.projectType = values['project-type'] as 'website' | 'web-app';
|
||||||
|
|
||||||
|
if (values.input) {
|
||||||
|
const inputPath = path.resolve(process.cwd(), values.input);
|
||||||
|
if (fs.existsSync(inputPath)) {
|
||||||
|
const fileData = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
|
||||||
|
state = { ...state, ...fileData };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.interactive) {
|
||||||
|
console.log('--- Mintel Quote Generator Wizard ---');
|
||||||
|
|
||||||
|
state.name = await ask('Client Name', state.name);
|
||||||
|
state.companyName = await ask('Company Name', state.companyName);
|
||||||
|
state.email = await ask('Client Email', state.email);
|
||||||
|
|
||||||
|
state.projectType = (await selectOne('Project Type', [
|
||||||
|
{ id: 'website', label: 'Website' },
|
||||||
|
{ id: 'web-app', label: 'Web App' }
|
||||||
|
], state.projectType)) as 'website' | 'web-app';
|
||||||
|
|
||||||
|
if (state.projectType === 'website') {
|
||||||
|
state.websiteTopic = await ask('Website Topic', state.websiteTopic);
|
||||||
|
state.selectedPages = await selectMany('Pages', PAGE_SAMPLES);
|
||||||
|
state.features = await selectMany('Features', FEATURE_OPTIONS);
|
||||||
|
state.functions = await selectMany('Functions', FUNCTION_OPTIONS);
|
||||||
|
state.apiSystems = await selectMany('APIs', API_OPTIONS);
|
||||||
|
|
||||||
|
const cms = await ask('CMS Setup? (y/n)', state.cmsSetup ? 'y' : 'n');
|
||||||
|
state.cmsSetup = cms.toLowerCase() === 'y';
|
||||||
|
|
||||||
|
const langs = await ask('Languages (comma separated)', state.languagesList.join(', '));
|
||||||
|
state.languagesList = langs.split(',').map(l => l.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
state.targetAudience = await selectOne('Target Audience', [
|
||||||
|
{ id: 'internal', label: 'Internal Tool' },
|
||||||
|
{ id: 'customers', label: 'Customer Portal' }
|
||||||
|
], state.targetAudience);
|
||||||
|
|
||||||
|
state.platformType = await selectOne('Platform', [
|
||||||
|
{ id: 'web-only', label: 'Web Only' },
|
||||||
|
{ id: 'cross-platform', label: 'Cross Platform' }
|
||||||
|
], state.platformType);
|
||||||
|
|
||||||
|
state.dataSensitivity = await selectOne('Data Sensitivity', [
|
||||||
|
{ id: 'standard', label: 'Standard' },
|
||||||
|
{ id: 'high', label: 'High / Sensitive' }
|
||||||
|
], state.dataSensitivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.employeeCount = await selectOne('Company Size', EMPLOYEE_OPTIONS, state.employeeCount);
|
||||||
|
state.designVibe = await selectOne('Design Vibe', DESIGN_OPTIONS, state.designVibe);
|
||||||
|
state.deadline = await selectOne('Timeline', Object.entries(DEADLINE_LABELS).map(([id, label]) => ({ id, label })), state.deadline);
|
||||||
|
state.assets = await selectMany('Available Assets', ASSET_OPTIONS);
|
||||||
|
|
||||||
|
state.message = await ask('Additional Message / Remarks', state.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
const totalPagesCount = state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||||
|
const positions = calculatePositions(state, PRICING);
|
||||||
|
const totalPrice = positions.reduce((sum, p) => sum + p.price, 0);
|
||||||
|
const monthlyPrice = PRICING.HOSTING_MONTHLY + (state.storageExpansion * PRICING.STORAGE_EXPANSION_MONTHLY);
|
||||||
|
|
||||||
|
const outputPath = values.output
|
||||||
|
? path.resolve(process.cwd(), values.output)
|
||||||
|
: path.resolve(process.cwd(), `quote_${state.companyName.replace(/\s+/g, '_') || 'client'}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||||
|
|
||||||
|
console.log(`\nGenerating PDF for ${state.companyName || state.name || 'Unknown Client'}...`);
|
||||||
|
|
||||||
|
const headerIcon = path.resolve(process.cwd(), 'src/assets/logo/Icon White Transparent.png');
|
||||||
|
const footerLogo = path.resolve(process.cwd(), 'src/assets/logo/Logo Black Transparent.png');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
await renderToFile(
|
||||||
|
React.createElement(EstimationPDF, {
|
||||||
|
state,
|
||||||
|
totalPrice,
|
||||||
|
monthlyPrice,
|
||||||
|
totalPagesCount,
|
||||||
|
pricing: PRICING,
|
||||||
|
headerIcon,
|
||||||
|
footerLogo,
|
||||||
|
}),
|
||||||
|
outputPath
|
||||||
|
);
|
||||||
|
console.log(`Successfully generated: ${outputPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generate();
|
||||||
@@ -9,6 +9,8 @@ import { Info, Download, Share2, RefreshCw } from 'lucide-react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { EstimationPDF } from '../../EstimationPDF';
|
import { EstimationPDF } from '../../EstimationPDF';
|
||||||
|
import IconWhite from '../../../assets/logo/Icon White Transparent.png';
|
||||||
|
import LogoBlack from '../../../assets/logo/Logo Black Transparent.png';
|
||||||
|
|
||||||
// Dynamically import PDF components to avoid SSR issues
|
// Dynamically import PDF components to avoid SSR issues
|
||||||
const PDFDownloadLink = dynamic(
|
const PDFDownloadLink = dynamic(
|
||||||
@@ -26,12 +28,12 @@ interface PriceCalculationProps {
|
|||||||
onShare?: () => void;
|
onShare?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriceCalculation({
|
export function PriceCalculation({
|
||||||
state,
|
state,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
monthlyPrice,
|
monthlyPrice,
|
||||||
totalPagesCount,
|
totalPagesCount,
|
||||||
isClient,
|
isClient,
|
||||||
qrCodeData,
|
qrCodeData,
|
||||||
onShare
|
onShare
|
||||||
}: PriceCalculationProps) {
|
}: PriceCalculationProps) {
|
||||||
@@ -45,11 +47,12 @@ export function PriceCalculation({
|
|||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (pdfLoading) return;
|
if (pdfLoading) return;
|
||||||
|
|
||||||
setPdfLoading(true);
|
setPdfLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pdf } = await import('@react-pdf/renderer');
|
const { pdf } = await import('@react-pdf/renderer');
|
||||||
|
|
||||||
const doc = <EstimationPDF
|
const doc = <EstimationPDF
|
||||||
state={state}
|
state={state}
|
||||||
totalPrice={totalPrice}
|
totalPrice={totalPrice}
|
||||||
@@ -57,14 +60,16 @@ export function PriceCalculation({
|
|||||||
totalPagesCount={totalPagesCount}
|
totalPagesCount={totalPagesCount}
|
||||||
pricing={PRICING}
|
pricing={PRICING}
|
||||||
qrCodeData={qrCodeData}
|
qrCodeData={qrCodeData}
|
||||||
|
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
|
||||||
|
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
// Minimum loading time of 2 seconds for better UX
|
// Minimum loading time of 2 seconds for better UX
|
||||||
const [blob] = await Promise.all([
|
const [blob] = await Promise.all([
|
||||||
pdf(doc).toBlob(),
|
pdf(doc).toBlob(),
|
||||||
new Promise(resolve => setTimeout(resolve, 2000))
|
new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -111,7 +116,7 @@ export function PriceCalculation({
|
|||||||
<span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} € / Monat</span>
|
<span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} € / Monat</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 space-y-4">
|
<div className="pt-4 space-y-4">
|
||||||
{isClient && (
|
{isClient && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,250 +1,72 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from './types';
|
import { FormState } from './types';
|
||||||
|
import {
|
||||||
|
PRICING as LOGIC_PRICING,
|
||||||
|
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
|
||||||
|
FEATURE_OPTIONS as LOGIC_FEATURE_OPTIONS,
|
||||||
|
FUNCTION_OPTIONS as LOGIC_FUNCTION_OPTIONS,
|
||||||
|
API_OPTIONS as LOGIC_API_OPTIONS,
|
||||||
|
ASSET_OPTIONS as LOGIC_ASSET_OPTIONS,
|
||||||
|
EMPLOYEE_OPTIONS as LOGIC_EMPLOYEE_OPTIONS,
|
||||||
|
SOCIAL_MEDIA_OPTIONS as LOGIC_SOCIAL_MEDIA_OPTIONS,
|
||||||
|
VIBE_LABELS as LOGIC_VIBE_LABELS,
|
||||||
|
DEADLINE_LABELS as LOGIC_DEADLINE_LABELS,
|
||||||
|
ASSET_LABELS as LOGIC_ASSET_LABELS,
|
||||||
|
FEATURE_LABELS as LOGIC_FEATURE_LABELS,
|
||||||
|
FUNCTION_LABELS as LOGIC_FUNCTION_LABELS,
|
||||||
|
API_LABELS as LOGIC_API_LABELS,
|
||||||
|
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
|
||||||
|
initialState as LOGIC_INITIAL_STATE,
|
||||||
|
DESIGN_OPTIONS
|
||||||
|
} from '../../logic/pricing';
|
||||||
|
|
||||||
export const PRICING = {
|
export const PRICING = LOGIC_PRICING;
|
||||||
BASE_WEBSITE: 6000,
|
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
|
||||||
PAGE: 800,
|
export const FEATURE_OPTIONS = LOGIC_FEATURE_OPTIONS;
|
||||||
FEATURE: 2000,
|
export const FUNCTION_OPTIONS = LOGIC_FUNCTION_OPTIONS;
|
||||||
FUNCTION: 1000,
|
export const API_OPTIONS = LOGIC_API_OPTIONS;
|
||||||
NEW_DATASET: 400,
|
export const ASSET_OPTIONS = LOGIC_ASSET_OPTIONS;
|
||||||
HOSTING_MONTHLY: 120,
|
export const EMPLOYEE_OPTIONS = LOGIC_EMPLOYEE_OPTIONS;
|
||||||
STORAGE_EXPANSION_MONTHLY: 10,
|
export const SOCIAL_MEDIA_OPTIONS = LOGIC_SOCIAL_MEDIA_OPTIONS;
|
||||||
CMS_SETUP: 1500,
|
export const VIBE_LABELS = LOGIC_VIBE_LABELS;
|
||||||
CMS_CONNECTION_PER_FEATURE: 800,
|
export const DEADLINE_LABELS = LOGIC_DEADLINE_LABELS;
|
||||||
API_INTEGRATION: 1000,
|
export const ASSET_LABELS = LOGIC_ASSET_LABELS;
|
||||||
APP_HOURLY: 120,
|
export const FEATURE_LABELS = LOGIC_FEATURE_LABELS;
|
||||||
|
export const FUNCTION_LABELS = LOGIC_FUNCTION_LABELS;
|
||||||
|
export const API_LABELS = LOGIC_API_LABELS;
|
||||||
|
export const SOCIAL_LABELS = LOGIC_SOCIAL_LABELS;
|
||||||
|
export const initialState = LOGIC_INITIAL_STATE;
|
||||||
|
|
||||||
|
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
|
||||||
|
minimal: (
|
||||||
|
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
||||||
|
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
|
||||||
|
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
|
||||||
|
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
bold: (
|
||||||
|
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
||||||
|
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
|
||||||
|
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
nature: (
|
||||||
|
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
||||||
|
<circle cx="30" cy="30" r="20" className="fill-current" />
|
||||||
|
<circle cx="70" cy="30" r="15" className="fill-current" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
tech: (
|
||||||
|
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
||||||
|
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
||||||
|
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialState: FormState = {
|
export const DESIGN_VIBES = DESIGN_OPTIONS.map(opt => ({
|
||||||
projectType: 'website',
|
...opt,
|
||||||
// Company
|
illustration: VIBE_ILLUSTRATIONS[opt.id]
|
||||||
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'],
|
|
||||||
// Timeline
|
|
||||||
deadline: 'flexible',
|
|
||||||
// Web App specific
|
|
||||||
targetAudience: 'internal',
|
|
||||||
userRoles: [],
|
|
||||||
dataSensitivity: 'standard',
|
|
||||||
platformType: 'web-only',
|
|
||||||
// Meta
|
|
||||||
dontKnows: [],
|
|
||||||
visualStaging: 'standard',
|
|
||||||
complexInteractions: 'standard',
|
|
||||||
};
|
|
||||||
|
|
||||||
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: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
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_VIBES = [
|
|
||||||
{
|
|
||||||
id: 'minimal',
|
|
||||||
label: 'Minimalistisch',
|
|
||||||
desc: 'Viel Weißraum, klare Typografie.',
|
|
||||||
illustration: (
|
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
|
||||||
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
|
|
||||||
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
|
|
||||||
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bold',
|
|
||||||
label: 'Mutig & Laut',
|
|
||||||
desc: 'Starke Kontraste, große Schriften.',
|
|
||||||
illustration: (
|
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
|
||||||
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
|
|
||||||
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nature',
|
|
||||||
label: 'Natürlich',
|
|
||||||
desc: 'Sanfte Erdtöne, organische Formen.',
|
|
||||||
illustration: (
|
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
|
||||||
<circle cx="30" cy="30" r="20" className="fill-current" />
|
|
||||||
<circle cx="70" cy="30" r="15" className="fill-current" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tech',
|
|
||||||
label: 'Technisch',
|
|
||||||
desc: 'Präzise Linien, dunkle Akzente.',
|
|
||||||
illustration: (
|
|
||||||
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
|
|
||||||
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
|
||||||
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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> = {
|
|
||||||
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: 'Erweiterte Formulare',
|
|
||||||
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'
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,139 +1,6 @@
|
|||||||
|
import { calculatePositions as logicCalculatePositions } from '../../logic/pricing';
|
||||||
import { FormState } from './types';
|
import { FormState } from './types';
|
||||||
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
|
|
||||||
|
|
||||||
export interface Position {
|
export type { Position } from '../../logic/pricing';
|
||||||
pos: number;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
qty: number;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
export const calculatePositions = (state: FormState, pricing: any) => logicCalculatePositions(state as any, pricing);
|
||||||
const positions: Position[] = [];
|
|
||||||
let pos = 1;
|
|
||||||
|
|
||||||
if (state.projectType === 'website') {
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Basis Website Setup',
|
|
||||||
desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.',
|
|
||||||
qty: 1,
|
|
||||||
price: pricing.BASE_WEBSITE
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPagesCount = state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
|
||||||
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...state.otherPages];
|
|
||||||
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Individuelle Seiten',
|
|
||||||
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${allPages.join(', ')}).`,
|
|
||||||
qty: totalPagesCount,
|
|
||||||
price: totalPagesCount * pricing.PAGE
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.features.length > 0 || state.otherFeatures.length > 0) {
|
|
||||||
const allFeatures = [...state.features.map((f: string) => FEATURE_LABELS[f] || f), ...state.otherFeatures];
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'System-Module',
|
|
||||||
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(', ')}. Inklusive Datenstruktur und Darstellung.`,
|
|
||||||
qty: allFeatures.length,
|
|
||||||
price: allFeatures.length * pricing.FEATURE
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.functions.length > 0 || state.otherFunctions.length > 0) {
|
|
||||||
const allFunctions = [...state.functions.map((f: string) => FUNCTION_LABELS[f] || f), ...state.otherFunctions];
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Logik-Funktionen',
|
|
||||||
desc: `Erweiterte Funktionen: ${allFunctions.join(', ')}.`,
|
|
||||||
qty: allFunctions.length,
|
|
||||||
price: allFunctions.length * pricing.FUNCTION
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.apiSystems.length > 0 || state.otherTech.length > 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 + state.otherFeatures.length + (state.otherFeaturesCount || 0);
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Inhaltsverwaltung (CMS)',
|
|
||||||
desc: 'Einrichtung eines Systems zur eigenständigen Pflege von Inhalten und Datensätzen.',
|
|
||||||
qty: 1,
|
|
||||||
price: pricing.CMS_SETUP + totalFeatures * pricing.CMS_CONNECTION_PER_FEATURE
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.newDatasets > 0) {
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Inhaltspflege (Initial)',
|
|
||||||
desc: `Manuelle Einpflege von ${state.newDatasets} Datensätzen (z.B. Produkte, Blogartikel).`,
|
|
||||||
qty: state.newDatasets,
|
|
||||||
price: state.newDatasets * pricing.NEW_DATASET
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.visualStaging && Number(state.visualStaging) > 0) {
|
|
||||||
const count = Number(state.visualStaging);
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Visuelle Inszenierung',
|
|
||||||
desc: `Umsetzung von ${count} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
|
|
||||||
qty: count,
|
|
||||||
price: count * (pricing.VISUAL_STAGING || 1500)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.complexInteractions && Number(state.complexInteractions) > 0) {
|
|
||||||
const count = Number(state.complexInteractions);
|
|
||||||
positions.push({
|
|
||||||
pos: pos++,
|
|
||||||
title: 'Komplexe Interaktion',
|
|
||||||
desc: `Umsetzung von ${count} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
|
|
||||||
qty: count,
|
|
||||||
price: count * (pricing.COMPLEX_INTERACTION || 2500)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const languagesCount = state.languagesList.length || 1;
|
|
||||||
if (languagesCount > 1) {
|
|
||||||
// This is a bit tricky because the factor applies to the total.
|
|
||||||
// For the PDF we show it as a separate position.
|
|
||||||
// We calculate the subtotal first.
|
|
||||||
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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
Document as PDFDocument,
|
Document as PDFDocument,
|
||||||
Page as PDFPage,
|
Page as PDFPage,
|
||||||
Text as PDFText,
|
Text as PDFText,
|
||||||
View as PDFView,
|
View as PDFView,
|
||||||
StyleSheet as PDFStyleSheet,
|
StyleSheet as PDFStyleSheet,
|
||||||
Image as PDFImage
|
Image as PDFImage
|
||||||
} from '@react-pdf/renderer';
|
} from '@react-pdf/renderer';
|
||||||
import {
|
import {
|
||||||
VIBE_LABELS,
|
VIBE_LABELS,
|
||||||
DEADLINE_LABELS,
|
DEADLINE_LABELS,
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
SOCIAL_LABELS
|
SOCIAL_LABELS,
|
||||||
} from './ContactForm/constants';
|
calculatePositions
|
||||||
import { calculatePositions } from './ContactForm/utils';
|
} from '../logic/pricing';
|
||||||
|
|
||||||
const styles = PDFStyleSheet.create({
|
const styles = PDFStyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
padding: 48,
|
paddingTop: 45, // DIN 5008
|
||||||
|
paddingLeft: 70, // ~25mm
|
||||||
|
paddingRight: 57, // ~20mm
|
||||||
|
paddingBottom: 48,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -29,10 +32,26 @@ const styles = PDFStyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
marginBottom: 64,
|
marginBottom: 40,
|
||||||
borderBottomWidth: 1,
|
minHeight: 120,
|
||||||
borderBottomColor: '#000000',
|
},
|
||||||
paddingBottom: 24,
|
addressBlock: {
|
||||||
|
width: '55%',
|
||||||
|
marginTop: 45, // DIN 5008 positioning for window
|
||||||
|
},
|
||||||
|
senderLine: {
|
||||||
|
fontSize: 7,
|
||||||
|
textDecoration: 'underline',
|
||||||
|
color: '#666666',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
recipientAddress: {
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
brandLogoContainer: {
|
||||||
|
width: '40%',
|
||||||
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
brandIconContainer: {
|
brandIconContainer: {
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -41,6 +60,7 @@ const styles = PDFStyleSheet.create({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
brandIconText: {
|
brandIconText: {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
@@ -48,21 +68,24 @@ const styles = PDFStyleSheet.create({
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
quoteInfo: {
|
quoteInfo: {
|
||||||
textAlign: 'right',
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
quoteTitle: {
|
quoteTitle: {
|
||||||
fontSize: 10,
|
fontSize: 14, // Slightly smaller to prevent wrapping
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
color: '#000000',
|
color: '#000000',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
|
textAlign: 'right',
|
||||||
},
|
},
|
||||||
quoteDate: {
|
quoteDate: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
color: '#666666',
|
color: '#666666',
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: 'right',
|
||||||
},
|
},
|
||||||
|
|
||||||
section: {
|
section: {
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
},
|
},
|
||||||
@@ -72,49 +95,12 @@ const styles = PDFStyleSheet.create({
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
color: '#999999',
|
color: '#999999',
|
||||||
marginBottom: 12,
|
marginBottom: 8,
|
||||||
},
|
|
||||||
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 24,
|
|
||||||
marginBottom: 24,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#eeeeee',
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
color: '#64748b',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
|
|
||||||
recipientGrid: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 32,
|
|
||||||
},
|
|
||||||
recipientItem: {
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
recipientLabel: {
|
|
||||||
fontSize: 7,
|
|
||||||
color: '#999999',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
marginBottom: 4,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
recipientValue: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#000000',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
|
minHeight: 300,
|
||||||
},
|
},
|
||||||
tableHeader: {
|
tableHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -130,11 +116,11 @@ const styles = PDFStyleSheet.create({
|
|||||||
borderBottomColor: '#eeeeee',
|
borderBottomColor: '#eeeeee',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
colPos: { width: '6%' },
|
colPos: { width: '8%' },
|
||||||
colDesc: { width: '64%' },
|
colDesc: { width: '62%' },
|
||||||
colQty: { width: '10%', textAlign: 'center' },
|
colQty: { width: '10%', textAlign: 'center' },
|
||||||
colPrice: { width: '20%', textAlign: 'right' },
|
colPrice: { width: '20%', textAlign: 'right' },
|
||||||
|
|
||||||
headerText: {
|
headerText: {
|
||||||
fontSize: 7,
|
fontSize: 7,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
@@ -153,7 +139,7 @@ const styles = PDFStyleSheet.create({
|
|||||||
marginTop: 32,
|
marginTop: 32,
|
||||||
},
|
},
|
||||||
summaryCard: {
|
summaryCard: {
|
||||||
width: '40%',
|
width: '45%',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
@@ -167,7 +153,7 @@ const styles = PDFStyleSheet.create({
|
|||||||
},
|
},
|
||||||
summaryLabel: { fontSize: 8, color: '#666666' },
|
summaryLabel: { fontSize: 8, color: '#666666' },
|
||||||
summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' },
|
summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' },
|
||||||
|
|
||||||
totalRow: {
|
totalRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -178,7 +164,7 @@ const styles = PDFStyleSheet.create({
|
|||||||
},
|
},
|
||||||
totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
|
totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
|
||||||
totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' },
|
totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' },
|
||||||
|
|
||||||
hostingBox: {
|
hostingBox: {
|
||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
@@ -193,39 +179,19 @@ const styles = PDFStyleSheet.create({
|
|||||||
configGrid: {
|
configGrid: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 24,
|
gap: 20,
|
||||||
},
|
},
|
||||||
configItem: {
|
configItem: {
|
||||||
width: '30%',
|
width: '30%',
|
||||||
marginBottom: 16,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
configLabel: { fontSize: 7, color: '#999999', marginBottom: 4, textTransform: 'uppercase', fontWeight: 'bold' },
|
configLabel: { fontSize: 7, color: '#999999', marginBottom: 4, textTransform: 'uppercase', fontWeight: 'bold' },
|
||||||
configValue: { fontSize: 8, color: '#000000', fontWeight: 'bold' },
|
configValue: { fontSize: 8, color: '#000000', fontWeight: 'bold' },
|
||||||
|
|
||||||
colorGrid: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
colorSwatch: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 4,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#eeeeee',
|
|
||||||
},
|
|
||||||
colorHex: {
|
|
||||||
fontSize: 6,
|
|
||||||
color: '#999999',
|
|
||||||
marginTop: 4,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
|
|
||||||
qrContainer: {
|
qrContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 120,
|
bottom: 110,
|
||||||
right: 48,
|
right: 57,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
qrImage: {
|
qrImage: {
|
||||||
@@ -243,38 +209,48 @@ const styles = PDFStyleSheet.create({
|
|||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 48,
|
bottom: 32,
|
||||||
left: 48,
|
left: 70,
|
||||||
right: 48,
|
right: 57,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#f1f5f9',
|
borderTopColor: '#f1f5f9',
|
||||||
paddingTop: 24,
|
paddingTop: 16,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
footerBrand: {
|
footerColumn: {
|
||||||
fontSize: 16,
|
flex: 1,
|
||||||
fontWeight: 'bold',
|
alignItems: 'flex-start',
|
||||||
letterSpacing: -1,
|
|
||||||
color: '#000000',
|
|
||||||
textTransform: 'lowercase',
|
|
||||||
},
|
},
|
||||||
footerRight: {
|
footerLogo: {
|
||||||
alignItems: 'flex-end',
|
height: 20,
|
||||||
|
width: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
footerContact: {
|
footerText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#94a3b8',
|
color: '#94a3b8',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
footerLabel: {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
textTransform: 'uppercase',
|
color: '#64748b',
|
||||||
letterSpacing: 1,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
pageNumber: {
|
pageNumber: {
|
||||||
fontSize: 7,
|
fontSize: 7,
|
||||||
color: '#cbd5e1',
|
color: '#cbd5e1',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
foldingMark: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
width: 10,
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: '#cbd5e1',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,9 +261,11 @@ interface PDFProps {
|
|||||||
totalPagesCount: number;
|
totalPagesCount: number;
|
||||||
pricing: any;
|
pricing: any;
|
||||||
qrCodeData?: string;
|
qrCodeData?: string;
|
||||||
|
headerIcon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount, pricing, qrCodeData }: PDFProps) => {
|
export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount, pricing, qrCodeData, headerIcon, footerLogo }: PDFProps) => {
|
||||||
const date = new Date().toLocaleDateString('de-DE', {
|
const date = new Date().toLocaleDateString('de-DE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -296,41 +274,73 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
|
|
||||||
const positions = calculatePositions(state, pricing);
|
const positions = calculatePositions(state, pricing);
|
||||||
|
|
||||||
return (
|
const FoldingMarks = () => (
|
||||||
<PDFDocument>
|
<>
|
||||||
<PDFPage size="A4" style={styles.page}>
|
<PDFView style={[styles.foldingMark, { top: 297.6 }]} fixed />
|
||||||
<PDFView style={styles.header}>
|
<PDFView style={[styles.foldingMark, { top: 420.9, width: 15 }]} fixed />
|
||||||
<PDFView style={styles.brandIconContainer}>
|
<PDFView style={[styles.foldingMark, { top: 595.3 }]} fixed />
|
||||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
</>
|
||||||
</PDFView>
|
);
|
||||||
<PDFView style={styles.quoteInfo}>
|
|
||||||
<PDFText style={styles.quoteTitle}>Kostenschätzung</PDFText>
|
|
||||||
<PDFText style={styles.quoteDate}>{date}</PDFText>
|
|
||||||
<PDFText style={[styles.quoteDate, { marginTop: 4, fontWeight: 'bold', color: '#000000' }]}>
|
|
||||||
Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}
|
|
||||||
</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
</PDFView>
|
|
||||||
|
|
||||||
<PDFView style={styles.section}>
|
const Footer = () => (
|
||||||
<PDFText style={styles.sectionTitle}>Ansprechpartner</PDFText>
|
<PDFView style={styles.footer} fixed>
|
||||||
<PDFView style={styles.recipientGrid}>
|
<PDFView style={styles.footerColumn}>
|
||||||
<PDFView style={styles.recipientItem}>
|
{footerLogo ? (
|
||||||
<PDFText style={styles.recipientLabel}>Name</PDFText>
|
<PDFImage src={footerLogo} style={styles.footerLogo} />
|
||||||
<PDFText style={styles.recipientValue}>{state.name || 'Interessent'}</PDFText>
|
) : (
|
||||||
|
<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView style={styles.footerColumn}>
|
||||||
|
<PDFText style={styles.footerText}>
|
||||||
|
<PDFText style={styles.footerLabel}>Marc Mintel</PDFText>{"\n"}
|
||||||
|
Georg-Meistermann-Straße 7{"\n"}
|
||||||
|
54586 Schüller{"\n"}
|
||||||
|
UST: DE367588065
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView style={[styles.footerColumn, { alignItems: 'flex-end' }]}>
|
||||||
|
<PDFText style={[styles.footerText, { textAlign: 'right' }]}>
|
||||||
|
<PDFText style={styles.footerLabel}>N26</PDFText>{"\n"}
|
||||||
|
NTSBDEB1XXX{"\n"}
|
||||||
|
DE50100110012620432865
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFDocument title={`Kostenschätzung - ${state.companyName || state.name}`}>
|
||||||
|
<PDFPage size="A4" style={styles.page}>
|
||||||
|
<FoldingMarks />
|
||||||
|
<PDFView style={styles.header}>
|
||||||
|
<PDFView style={styles.addressBlock}>
|
||||||
|
<PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
|
||||||
|
<PDFView style={styles.recipientAddress}>
|
||||||
|
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
|
||||||
|
{state.companyName && <PDFText>{state.name}</PDFText>}
|
||||||
|
<PDFText>{state.email}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView style={styles.brandLogoContainer}>
|
||||||
|
<PDFView style={styles.brandIconContainer}>
|
||||||
|
{headerIcon ? (
|
||||||
|
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.quoteInfo}>
|
||||||
|
<PDFText style={styles.quoteTitle}>Kostenschätzung</PDFText>
|
||||||
|
<PDFText style={styles.quoteDate}>Datum: {date}</PDFText>
|
||||||
|
<PDFText style={[styles.quoteDate, { fontWeight: 'bold', color: '#000000' }]}>
|
||||||
|
Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}
|
||||||
|
</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
{state.companyName && (
|
|
||||||
<PDFView style={styles.recipientItem}>
|
|
||||||
<PDFText style={styles.recipientLabel}>Unternehmen</PDFText>
|
|
||||||
<PDFText style={styles.recipientValue}>{state.companyName}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
{state.email && (
|
|
||||||
<PDFView style={styles.recipientItem}>
|
|
||||||
<PDFText style={styles.recipientLabel}>E-Mail</PDFText>
|
|
||||||
<PDFText style={styles.recipientValue}>{state.email}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
</PDFView>
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
|
|
||||||
@@ -343,7 +353,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
</PDFView>
|
</PDFView>
|
||||||
|
|
||||||
{positions.map((item, i) => (
|
{positions.map((item, i) => (
|
||||||
<PDFView key={i} style={styles.tableRow}>
|
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||||
<PDFText style={[styles.posText, styles.colPos]}>{item.pos.toString().padStart(2, '0')}</PDFText>
|
<PDFText style={[styles.posText, styles.colPos]}>{item.pos.toString().padStart(2, '0')}</PDFText>
|
||||||
<PDFView style={styles.colDesc}>
|
<PDFView style={styles.colDesc}>
|
||||||
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
||||||
@@ -351,7 +361,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||||
<PDFText style={[styles.priceText, styles.colPrice]}>
|
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||||
{item.price > 0 ? `${item.price.toLocaleString()} €` : 'n. A.'}
|
{item.price > 0 ? `${item.price.toLocaleString('de-DE')} €` : 'n. A.'}
|
||||||
</PDFText>
|
</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
))}
|
))}
|
||||||
@@ -361,11 +371,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
<PDFView style={styles.summaryCard}>
|
<PDFView style={styles.summaryCard}>
|
||||||
<PDFView style={styles.summaryRow}>
|
<PDFView style={styles.summaryRow}>
|
||||||
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
|
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
|
||||||
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString()} €</PDFText>
|
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={styles.totalRow}>
|
<PDFView style={styles.totalRow}>
|
||||||
<PDFText style={styles.totalLabel}>Gesamtsumme</PDFText>
|
<PDFText style={styles.totalLabel}>Gesamtsumme</PDFText>
|
||||||
<PDFText style={styles.totalValue}>{totalPrice.toLocaleString()} €</PDFText>
|
<PDFText style={styles.totalValue}>{totalPrice.toLocaleString('de-DE')} €</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
@@ -373,26 +383,34 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
{state.projectType === 'website' && (
|
{state.projectType === 'website' && (
|
||||||
<PDFView style={styles.hostingBox}>
|
<PDFView style={styles.hostingBox}>
|
||||||
<PDFText style={{ color: '#666666', fontSize: 8, fontWeight: 'bold', textTransform: 'uppercase' }}>Betrieb & Hosting</PDFText>
|
<PDFText style={{ color: '#666666', fontSize: 8, fontWeight: 'bold', textTransform: 'uppercase' }}>Betrieb & Hosting</PDFText>
|
||||||
<PDFText style={{ fontSize: 10, fontWeight: 'bold', color: '#000000' }}>{monthlyPrice.toLocaleString()} € / Monat</PDFText>
|
<PDFText style={{ fontSize: 10, fontWeight: 'bold', color: '#000000' }}>{monthlyPrice.toLocaleString('de-DE')} € / Monat</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PDFView style={styles.footer}>
|
<Footer />
|
||||||
<PDFText style={styles.footerBrand}>marc mintel</PDFText>
|
|
||||||
<PDFView style={styles.footerRight}>
|
|
||||||
<PDFText style={styles.footerContact}>marc@mintel.me</PDFText>
|
|
||||||
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
|
||||||
</PDFView>
|
|
||||||
</PDFView>
|
|
||||||
</PDFPage>
|
</PDFPage>
|
||||||
|
|
||||||
<PDFPage size="A4" style={styles.page}>
|
<PDFPage size="A4" style={styles.page}>
|
||||||
|
<FoldingMarks />
|
||||||
<PDFView style={styles.header}>
|
<PDFView style={styles.header}>
|
||||||
<PDFView style={styles.brandIconContainer}>
|
<PDFView style={styles.addressBlock}>
|
||||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
<PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
|
||||||
|
<PDFView style={styles.recipientAddress}>
|
||||||
|
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
|
||||||
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={styles.quoteInfo}>
|
|
||||||
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText>
|
<PDFView style={styles.brandLogoContainer}>
|
||||||
|
<PDFView style={styles.brandIconContainer}>
|
||||||
|
{headerIcon ? (
|
||||||
|
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.quoteInfo}>
|
||||||
|
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText>
|
||||||
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
|
|
||||||
@@ -411,11 +429,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={[styles.configItem, { width: '100%' }]}>
|
<PDFView style={[styles.configItem, { width: '100%' }]}>
|
||||||
<PDFText style={styles.configLabel}>Farbschema</PDFText>
|
<PDFText style={styles.configLabel}>Farbschema</PDFText>
|
||||||
<PDFView style={styles.colorGrid}>
|
<PDFView style={{ flexDirection: 'row', gap: 12, marginTop: 8 }}>
|
||||||
{state.colorScheme.map((color: string, i: number) => (
|
{state.colorScheme.map((color: string, i: number) => (
|
||||||
<PDFView key={i} style={{ alignItems: 'center' }}>
|
<PDFView key={i} style={{ alignItems: 'center' }}>
|
||||||
<PDFView style={[styles.colorSwatch, { backgroundColor: color }]} />
|
<PDFView style={{ width: 24, height: 24, borderRadius: 4, backgroundColor: color, borderWidth: 1, borderColor: '#eeeeee' }} />
|
||||||
<PDFText style={styles.colorHex}>{color.toUpperCase()}</PDFText>
|
<PDFText style={{ fontSize: 6, color: '#999999', marginTop: 4, fontWeight: 'bold' }}>{color.toUpperCase()}</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
))}
|
))}
|
||||||
</PDFView>
|
</PDFView>
|
||||||
@@ -435,92 +453,26 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
<PDFText style={styles.configLabel}>Sicherheit</PDFText>
|
<PDFText style={styles.configLabel}>Sicherheit</PDFText>
|
||||||
<PDFText style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</PDFText>
|
<PDFText style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Rollen</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.userRoles.join(', ') || 'Keine'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<PDFView style={styles.configItem}>
|
<PDFView style={styles.configItem}>
|
||||||
<PDFText style={styles.configLabel}>Mitarbeiter</PDFText>
|
<PDFText style={styles.configLabel}>Mitarbeiter</PDFText>
|
||||||
<PDFText style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</PDFText>
|
<PDFText style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Bestehende Website</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.existingWebsite || 'Keine'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Bestehende Domain</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.existingDomain || 'Keine'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Wunsch-Domain</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.wishedDomain || 'Keine'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
<PDFView style={styles.configItem}>
|
<PDFView style={styles.configItem}>
|
||||||
<PDFText style={styles.configLabel}>Zeitplan</PDFText>
|
<PDFText style={styles.configLabel}>Zeitplan</PDFText>
|
||||||
<PDFText style={styles.configValue}>{DEADLINE_LABELS[state.deadline] || state.deadline}</PDFText>
|
<PDFText style={styles.configValue}>{DEADLINE_LABELS[state.deadline] || state.deadline}</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Assets vorhanden</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.assets.map((a: string) => ASSET_LABELS[a] || a).join(', ') || 'Keine angegeben'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
{state.otherAssets.length > 0 && (
|
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Weitere Assets</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.otherAssets.join(', ')}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
<PDFView style={styles.configItem}>
|
<PDFView style={styles.configItem}>
|
||||||
<PDFText style={styles.configLabel}>Sprachen</PDFText>
|
<PDFText style={styles.configLabel}>Sprachen</PDFText>
|
||||||
<PDFText style={styles.configValue}>{state.languagesList.length} ({state.languagesList.join(', ')})</PDFText>
|
<PDFText style={styles.configValue}>{state.languagesList.length} ({state.languagesList.join(', ')})</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
{state.projectType === 'website' && (
|
|
||||||
<>
|
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>CMS (Inhaltsverwaltung)</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>{state.cmsSetup ? 'Ja' : 'Nein'}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
<PDFView style={styles.configItem}>
|
|
||||||
<PDFText style={styles.configLabel}>Änderungsfrequenz</PDFText>
|
|
||||||
<PDFText style={styles.configValue}>
|
|
||||||
{state.expectedAdjustments === 'low' ? 'Selten' :
|
|
||||||
state.expectedAdjustments === 'medium' ? 'Regelmäßig' : 'Häufig'}
|
|
||||||
</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PDFView>
|
</PDFView>
|
||||||
|
|
||||||
{state.socialMedia.length > 0 && (
|
|
||||||
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
|
|
||||||
<PDFText style={styles.configLabel}>Social Media Accounts</PDFText>
|
|
||||||
{state.socialMedia.map((id: string) => (
|
|
||||||
<PDFText key={id} style={[styles.configValue, { lineHeight: 1.6, color: '#666666', fontWeight: 'normal' }]}>
|
|
||||||
<PDFText style={{ color: '#000000', fontWeight: 'bold' }}>{SOCIAL_LABELS[id] || id}:</PDFText> {state.socialMediaUrls[id] || 'Keine URL angegeben'}
|
|
||||||
</PDFText>
|
|
||||||
))}
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.designWishes && (
|
|
||||||
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
|
|
||||||
<PDFText style={styles.configLabel}>Design-Vorstellungen</PDFText>
|
|
||||||
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.designWishes}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.references.length > 0 && (
|
|
||||||
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
|
|
||||||
<PDFText style={styles.configLabel}>Referenzen</PDFText>
|
|
||||||
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.references.join('\n')}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.message && (
|
{state.message && (
|
||||||
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
|
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
|
||||||
<PDFText style={styles.configLabel}>Nachricht / Anmerkungen</PDFText>
|
<PDFText style={styles.configLabel}>Nachricht / Anmerkungen</PDFText>
|
||||||
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.message}</PDFText>
|
<PDFText style={{ fontSize: 8, color: '#666666', lineHeight: 1.6 }}>{state.message}</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
)}
|
)}
|
||||||
</PDFView>
|
</PDFView>
|
||||||
@@ -528,17 +480,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
{qrCodeData && (
|
{qrCodeData && (
|
||||||
<PDFView style={styles.qrContainer}>
|
<PDFView style={styles.qrContainer}>
|
||||||
<PDFImage src={qrCodeData} style={styles.qrImage} />
|
<PDFImage src={qrCodeData} style={styles.qrImage} />
|
||||||
<PDFText style={styles.qrText}>Online öffnen</PDFText>
|
<PDFText style={styles.qrText}>Kalkulation online öffnen</PDFText>
|
||||||
</PDFView>
|
</PDFView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PDFView style={styles.footer}>
|
<Footer />
|
||||||
<PDFText style={styles.footerBrand}>marc mintel</PDFText>
|
|
||||||
<PDFView style={styles.footerRight}>
|
|
||||||
<PDFText style={styles.footerContact}>marc@mintel.me</PDFText>
|
|
||||||
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
|
|
||||||
</PDFView>
|
|
||||||
</PDFView>
|
|
||||||
</PDFPage>
|
</PDFPage>
|
||||||
</PDFDocument>
|
</PDFDocument>
|
||||||
);
|
);
|
||||||
|
|||||||
128
src/logic/pricing/calculator.ts
Normal file
128
src/logic/pricing/calculator.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { FormState, Position } from './types';
|
||||||
|
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
|
||||||
|
|
||||||
|
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||||
|
const positions: Position[] = [];
|
||||||
|
let pos = 1;
|
||||||
|
|
||||||
|
if (state.projectType === 'website') {
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Basis Website Setup',
|
||||||
|
desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.',
|
||||||
|
qty: 1,
|
||||||
|
price: pricing.BASE_WEBSITE
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPagesCount = state.selectedPages.length + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
|
||||||
|
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...(state.otherPages || [])];
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Individuelle Seiten',
|
||||||
|
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${allPages.join(', ')}).`,
|
||||||
|
qty: totalPagesCount,
|
||||||
|
price: totalPagesCount * pricing.PAGE
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.features.length > 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',
|
||||||
|
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(', ')}. Inklusive Datenstruktur und Darstellung.`,
|
||||||
|
qty: allFeatures.length,
|
||||||
|
price: allFeatures.length * pricing.FEATURE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.functions.length > 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: `Erweiterte Funktionen: ${allFunctions.join(', ')}.`,
|
||||||
|
qty: allFunctions.length,
|
||||||
|
price: allFunctions.length * pricing.FUNCTION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.apiSystems.length > 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 + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0);
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Inhaltsverwaltung (CMS)',
|
||||||
|
desc: 'Einrichtung eines Systems zur eigenständigen Pflege von Inhalten und Datensätzen.',
|
||||||
|
qty: 1,
|
||||||
|
price: pricing.CMS_SETUP + totalFeatures * pricing.CMS_CONNECTION_PER_FEATURE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.newDatasets > 0) {
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Inhaltspflege (Initial)',
|
||||||
|
desc: `Manuelle Einpflege von ${state.newDatasets} Datensätzen (z.B. Produkte, Blogartikel).`,
|
||||||
|
qty: state.newDatasets,
|
||||||
|
price: state.newDatasets * pricing.NEW_DATASET
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.visualStaging && Number(state.visualStaging) > 0) {
|
||||||
|
const count = Number(state.visualStaging);
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Visuelle Inszenierung',
|
||||||
|
desc: `Umsetzung von ${count} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
|
||||||
|
qty: count,
|
||||||
|
price: count * pricing.VISUAL_STAGING
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.complexInteractions && Number(state.complexInteractions) > 0) {
|
||||||
|
const count = Number(state.complexInteractions);
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: 'Komplexe Interaktion',
|
||||||
|
desc: `Umsetzung von ${count} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
|
||||||
|
qty: count,
|
||||||
|
price: count * pricing.COMPLEX_INTERACTION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
209
src/logic/pricing/constants.ts
Normal file
209
src/logic/pricing/constants.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { FormState } from './types';
|
||||||
|
|
||||||
|
export const PRICING = {
|
||||||
|
BASE_WEBSITE: 6000,
|
||||||
|
PAGE: 800,
|
||||||
|
FEATURE: 2000,
|
||||||
|
FUNCTION: 1000,
|
||||||
|
NEW_DATASET: 400,
|
||||||
|
HOSTING_MONTHLY: 120,
|
||||||
|
STORAGE_EXPANSION_MONTHLY: 10,
|
||||||
|
CMS_SETUP: 1500,
|
||||||
|
CMS_CONNECTION_PER_FEATURE: 800,
|
||||||
|
API_INTEGRATION: 1000,
|
||||||
|
APP_HOURLY: 120,
|
||||||
|
VISUAL_STAGING: 1500,
|
||||||
|
COMPLEX_INTERACTION: 2500,
|
||||||
|
};
|
||||||
|
|
||||||
|
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'],
|
||||||
|
// Timeline
|
||||||
|
deadline: 'flexible',
|
||||||
|
// Web App specific
|
||||||
|
targetAudience: 'internal',
|
||||||
|
userRoles: [],
|
||||||
|
dataSensitivity: 'standard',
|
||||||
|
platformType: 'web-only',
|
||||||
|
// Meta
|
||||||
|
dontKnows: [],
|
||||||
|
visualStaging: 'standard',
|
||||||
|
complexInteractions: 'standard',
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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> = {
|
||||||
|
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: 'Erweiterte Formulare',
|
||||||
|
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'
|
||||||
|
};
|
||||||
3
src/logic/pricing/index.ts
Normal file
3
src/logic/pricing/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './constants';
|
||||||
|
export * from './calculator';
|
||||||
|
export * from './types';
|
||||||
67
src/logic/pricing/types.ts
Normal file
67
src/logic/pricing/types.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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; // Using any for File/null to be CLI-compatible
|
||||||
|
contactFiles: any[]; // Using any[] for File[]
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
pos: number;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user