diff --git a/.gitignore b/.gitignore index 08ffb52..89feca6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ tmp/ # Generated static documents /public/pricing.pdf +/public/partner.pdf # Runtime-generated social feed /public/social/ diff --git a/README.md b/README.md index ba1667a..2b916cd 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ This repo is a Next.js app used to generate a static site. The final production ## Development (local only) - Install deps: `npm install` - Run dev server: `npm run dev` then open http://localhost:3000 -> ⚠️ These steps are purely for local development; the production image ships only static assets and never contains a Node/npm runtime. +> ⚠️ These steps are for local development. The production image serves static files and does not include Node or npm. ## Build (production image) - `docker build -t www-website .` -- Runtime is static files only; mount/serve with RO filesystem (e.g., `docker run --read-only --tmpfs /tmp --tmpfs /config --tmpfs /data -p 3000:3000 www-website`). -> ⚠️ The final container maps only the generated `out` directory and static assets; keep it read-only and do not run any Node/npm scripts there. +- The runtime serves static files. Use a read-only filesystem (e.g., `docker run --read-only --tmpfs /tmp --tmpfs /config --tmpfs /data -p 3000:3000 www-website`). +> ⚠️ The final container serves the generated `out` directory. Keep it read-only. Run Node/npm scripts in the build stage or locally. ## Repository size budget - CI fails if the tracked tree exceeds `10 MiB`, `public/` exceeds `8 MiB`, any tracked blob exceeds `1 MiB`, or HEAD Git blob payloads exceed `15 MiB`. @@ -33,6 +33,13 @@ This repo is a Next.js app used to generate a static site. The final production ## Deployment notes - Published image: `ghcr.io/devsh-graphics-programming/www-website` (tags: `latest`, `master`, `sha-*`). -- Blog content is proxied separately; the main image does not include Node/npm or blog runtime. +- Blog content is proxied separately. The main image does not include Node/npm or the blog runtime. - Security - Enable GitHub's push protection/secret scanning (see https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection) so that any accidental secret leaks are flagged before a push finishes. + +## Generated PDFs + +Run `npm run generate:pdfs` to rebuild `public/pricing.pdf` and `public/partner.pdf`. +This also runs with `npm run dev` and `npm run build`. Both PDFs stay out of Git. +Edit prices in `app/data/pricing-rates.json` and partner text in `app/data/partner-content.json`. +Both generators share the fonts, colors and footer in `scripts/lib/pdf-brand.mjs`. diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 92a410b..9bf7f01 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -5,6 +5,7 @@ const quickLinks = [ { label: "Home", href: "/" }, { label: "Services", href: "/services" }, { label: "Pricing", href: "/pricing.pdf" }, + { label: "Referral Partners", href: "/partner.pdf" }, { label: "Nabla", href: "/nabla" }, { label: "Research", href: "/research" }, { label: "Blog", href: "/blog" }, diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 71847ba..04eeb3f 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -19,6 +19,7 @@ const links: Link[] = [ { name: "When to Call", url: "/when-to-call-devsh" }, { name: "Sponsorship", url: "/#sponsorship" }, { name: "Pricing", url: "/pricing.pdf" }, + { name: "Referral Partners", url: "/partner.pdf" }, { name: "Nabla", url: "/nabla" }, { name: "Research", url: "/presentations" }, { name: "Blog", url: "/blog" }, diff --git a/app/data/partner-content.json b/app/data/partner-content.json new file mode 100644 index 0000000..0e41f17 --- /dev/null +++ b/app/data/partner-content.json @@ -0,0 +1,102 @@ +{ + "blocks": [ + { + "type": "title", + "text": "DevSH referral partner" + }, + { + "type": "reward", + "text": "10% success reward" + }, + { + "type": "subtitle", + "text": "We handle the execution, you earn the commission." + }, + { + "type": "bullet", + "text": "\"Our renderer drops frames on large client datasets.\"" + }, + { + "type": "bullet", + "text": "\"We’re stuck on legacy code and can’t leverage modern GPUs.\"" + }, + { + "type": "bullet", + "text": "\"We can't find low-level Vulkan / C++ engineers to save our lives.\"" + }, + { + "type": "bullet", + "text": "\"Our FTEs vibecoded the codebase and now every change requires exponentially more tokens\"" + }, + { + "type": "paragraph", + "text": "If you sit in those conversations, you’re the partner we’re looking for." + }, + { + "type": "heading", + "text": "How it works" + }, + { + "type": "subheading", + "text": "Point us to a project" + }, + { + "type": "paragraph", + "text": "Connect us with teams in CAD, gaming, simulation, visualization, or automotive requiring graphics dev, GPU computing, code audits, or corporate training." + }, + { + "type": "subheading", + "text": "We run the deal" + }, + { + "type": "paragraph", + "text": "DevSH leads the technical discussions, defines the scope of work, and finalizes the service contract. You may choose to stay involved or step back completely." + }, + { + "type": "subheading", + "text": "Earn your fee" + }, + { + "type": "paragraph", + "text": "Earn 10% on all payments we receive across a new client’s projects for 12 months after introduction. No minimum project value or commission cap. For corporate training, earn 10% for its full duration. We pay once client payments are received and no longer subject to offsets, contractual penalties or refunds. When referring your employer or client, tell them immediately about your 10% commission." + }, + { + "type": "heading", + "text": "Who is our ideal partner?" + }, + { + "type": "bullet", + "text": "Industry networks: Existing contacts among CTOs, VPs of engineering, product directors, and R&D leads in gaming, CAD, simulation, automotive, or graphics technology." + }, + { + "type": "bullet", + "text": "Technical familiarity: Ability to recognize high-level technical pain points (such as GPU bottlenecks, rendering issues, or low-level performance needs) in casual or professional conversations." + }, + { + "type": "bullet", + "text": "Professional trust: Established credibility with decision-makers to facilitate warm, effective introductions." + }, + { + "type": "heading", + "text": "How we support you" + }, + { + "type": "bullet", + "text": "Competitive 10% commission: Earn commission on payments we receive from clients you introduce." + }, + { + "type": "bullet", + "text": "Zero operational burden: No cold calling, sales targets, quotas, CRM entry, or required travel." + }, + { + "type": "bullet", + "text": "Complete flexibility: Work on your own schedule from anywhere, simply leveraging your existing network." + }, + { + "type": "contact", + "text": "Ready to turn your network into revenue?", + "prompt": "Contact our team at", + "email": "partner@devsh.eu" + } + ] +} diff --git a/package.json b/package.json index 12aa40c..d77eb38 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,12 @@ "private": true, "scripts": { "dev": "next dev -p 24631 -H 0.0.0.0", + "predev": "npm run generate:pdfs", "generate:pricing-pdf": "node scripts/generate-pricing-pdf.mjs", + "generate:partner-pdf": "node scripts/generate-partner-pdf.mjs", + "generate:pdfs": "npm run generate:pricing-pdf && npm run generate:partner-pdf", "sync:linkedin-feed": "node scripts/sync-linkedin-feed.mjs", - "prebuild": "npm run generate:pricing-pdf", + "prebuild": "npm run generate:pdfs", "build": "next build", "start": "next start -p 24631 -H 0.0.0.0", "lint": "eslint ." diff --git a/scripts/generate-partner-pdf.mjs b/scripts/generate-partner-pdf.mjs new file mode 100644 index 0000000..bdde083 --- /dev/null +++ b/scripts/generate-partner-pdf.mjs @@ -0,0 +1,133 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + rootDir, logoPath, mm, colors, createDocument, finishDocument, drawLine, drawFooter, +} from "./lib/pdf-brand.mjs"; + +const content = JSON.parse(await readFile(path.join(rootDir, "app/data/partner-content.json"), "utf8")); +const outputPath = path.join(rootDir, "public/partner.pdf"); +const contact = content.blocks.find((block) => block.type === "contact"); +const document = createDocument(content.blocks[0].text); +document.on("pageAdded", () => { + throw new Error("Partner PDF must fit on one A4 page"); +}); +const left = mm(22); +const width = document.page.width - left * 2; +const maxFooterTop = mm(257); +const sectionGap = 9; +const space = { + block: 3, + section: sectionGap, + ruleToHeading: 6, + headingToBody: 3, + step: 6, + stepToBody: 2, + titleToReward: 3, + rewardToSubtitle: 6.5, + intro: sectionGap * 2, + quote: 3.5, + quoteEnd: 1.5, +}; +const bodyColor = "#e5eae7"; +const rewardColor = "#92dfd2"; +const bodySize = 9.6; +let y = mm(23); + +const logoSize = mm(14); +const logoY = mm(8); +document.image(logoPath, (document.page.width - logoSize) / 2, logoY, { width: logoSize }); + +function checkBounds(text) { + if (y > maxFooterTop - space.section) { + throw new Error(`Partner content exceeds the A4 layout at ${y.toFixed(1)}pt: ${text.slice(0, 60)}`); + } +} + +function drawText(text, { + size = bodySize, font = "FuturaBook", color = bodyColor, + after = space.block, indent = 0, align = "left", characterSpacing = 0, +} = {}) { + document.font(font).fontSize(size).fillColor(color); + const options = { width: width - indent, lineGap: 1.2, align, characterSpacing }; + const height = document.heightOfString(text, options); + checkBounds(text); + document.text(text, left + indent, y, options); + y += height + after; + checkBounds(text); +} + +function drawBullet(text, nextBlock) { + const indent = 10; + const colon = text.indexOf(":"); + document.save().circle(left + 2, y + 5, 1).fill(colors.accent).restore(); + if (colon < 0) { + const continues = nextBlock?.type === "bullet"; + drawText(text, { indent, after: continues ? space.quote : space.quoteEnd }); + if (!continues) y += space.block; + return; + } + const options = { width: width - indent, lineGap: 1.2 }; + document.font("FuturaDemi").fontSize(bodySize).fillColor(colors.white) + .text(`${text.slice(0, colon + 1)} `, left + indent, y, { ...options, continued: true }); + document.font("FuturaBook").fillColor(bodyColor) + .text(text.slice(colon + 1).trimStart(), { ...options, continued: false }); + y = document.y + space.block; + checkBounds(text); +} + +function drawSectionHeading(text) { + y += space.section; + drawLine(document, left, y, left + width, y, 0.32, 0.45); + y += space.ruleToHeading; + drawText(text, { font: "FuturaDemi", size: 13, color: colors.white, after: space.headingToBody }); +} + +for (const [index, block] of content.blocks.entries()) { + if (block.type === "title") { + const titleSize = 24; + document.font("FuturaDemi").fontSize(titleSize); + if (document.widthOfString(block.text) > width) { + throw new Error("Partner title exceeds the content width"); + } + drawText(block.text, { font: "FuturaDemi", size: titleSize, color: colors.white, align: "center", after: space.titleToReward }); + } else if (block.type === "reward") { + drawText(block.text, { font: "FuturaDemi", size: 24, color: rewardColor, align: "center", characterSpacing: 0.6, after: space.rewardToSubtitle }); + } else if (block.type === "subtitle") { + drawText(block.text, { size: 11.5, color: bodyColor, align: "center", after: space.intro }); + } else if (block.type === "heading") { + drawSectionHeading(block.text); + } else if (block.type === "subheading") { + if (content.blocks[index - 1]?.type !== "heading") y += space.step; + drawText(block.text, { font: "FuturaDemi", size: 10.2, color: colors.white, after: space.stepToBody }); + } else if (block.type === "contact") { + drawSectionHeading(block.text); + const baseline = y + 10.5; + document.font("FuturaBook").fontSize(10.5).fillColor(bodyColor); + const emailX = left + document.widthOfString(block.prompt) + 5; + document.text(block.prompt, left, baseline, { width, baseline: "alphabetic", lineBreak: false }); + document.font("FuturaMedium").fontSize(10.5).fillColor(colors.accent) + .text(block.email, emailX, baseline, { + width: left + width - emailX, baseline: "alphabetic", lineBreak: false, link: `mailto:${block.email}`, + }); + y = baseline + 3; + checkBounds(block.email); + } else if (block.type === "bullet") { + drawBullet(block.text, content.blocks[index + 1]); + } else if (block.type === "paragraph") { + drawText(block.text); + } else { + throw new Error(`Unknown partner content block type: ${block.type}`); + } +} + +const footerTop = y + space.section; +if (footerTop > maxFooterTop) throw new Error("Partner footer exceeds the A4 layout"); +drawFooter(document, footerTop, contact.email, { + colors: { white: "#bdc9c5", muted: "#889692", accent: "#a6bdb5" }, + lineOpacity: 0.28, + legalLineOpacity: 0.20, +}); +await mkdir(path.dirname(outputPath), { recursive: true }); +const pdf = await finishDocument(document); +await writeFile(outputPath, pdf); +console.log(`Generated ${path.relative(rootDir, outputPath)} (${pdf.length} bytes)`); diff --git a/scripts/generate-pricing-pdf.mjs b/scripts/generate-pricing-pdf.mjs index 45db7e9..0f9f2d4 100644 --- a/scripts/generate-pricing-pdf.mjs +++ b/scripts/generate-pricing-pdf.mjs @@ -1,71 +1,15 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import PDFDocument from "pdfkit"; +import { rootDir, logoPath, mm, colors, finishDocument, drawLine, drawCenteredText, drawFooter, createDocument } from "./lib/pdf-brand.mjs"; -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const sourcePath = path.join(rootDir, "app", "data", "pricing-rates.json"); const outputPath = path.join(rootDir, "public", "pricing.pdf"); -const logoPath = path.join(rootDir, "public", "brand", "devsh-logo-glow.png"); -const fontDir = path.join(rootDir, "app", "fonts", "FuturaPT", "pdf"); - const pricing = JSON.parse(await readFile(sourcePath, "utf8")); -const mm = (value) => value * 72 / 25.4; - -const colors = { - background: "#020403", - white: "#f8fffd", - muted: "#9fa9a7", - line: "#bafff6", - accent: "#8ceedd", -}; - -function finishDocument(document) { - return new Promise((resolve, reject) => { - const chunks = []; - document.on("data", (chunk) => chunks.push(chunk)); - document.on("end", () => resolve(Buffer.concat(chunks))); - document.on("error", reject); - document.end(); - }); -} - -function withOpacity(document, opacity, draw) { - document.save(); - document.opacity(opacity); - draw(); - document.restore(); -} - -function drawLine(document, x1, y1, x2, y2, opacity = 0.4, width = 0.55) { - withOpacity(document, opacity, () => { - document - .lineWidth(width) - .strokeColor(colors.line) - .moveTo(x1, y1) - .lineTo(x2, y2) - .stroke(); - }); -} - -function drawCenteredText(document, text, y, options) { - document - .font(options.font) - .fontSize(options.size) - .fillColor(options.color ?? colors.white) - .text(text, 0, y, { - width: document.page.width, - height: options.height ?? options.size * 1.5, - align: "center", - lineGap: options.lineGap ?? 0, - }); -} - function drawBrand(document) { const logoSize = mm(24); const logoX = (document.page.width - logoSize) / 2; - const logoY = mm(29); + const logoY = mm(24); document.image(logoPath, logoX, logoY, { fit: [logoSize, logoSize], @@ -73,19 +17,13 @@ function drawBrand(document) { valign: "center", }); - drawCenteredText(document, "DEVSH GRAPHICS PROGRAMMING SP. Z O.O.", logoY + logoSize + mm(1.15), { - font: "FuturaMedium", - size: 5.6, - color: colors.white, - }); - - drawCenteredText(document, "DevSH 2026", mm(57.8), { + drawCenteredText(document, "DevSH 2026", mm(46), { font: "FuturaDemi", size: 30, color: colors.white, }); - drawCenteredText(document, "Professional Engineering Rates", mm(75.3), { + drawCenteredText(document, "Professional Engineering Rates", mm(62), { font: "FuturaBook", size: 12.4, color: colors.accent, @@ -194,85 +132,7 @@ function drawPricingTable(document) { return noteY + mm(14); } -function drawFooter(document, topY) { - const left = mm(22); - const width = document.page.width - left * 2; - const right = left + width; - const blockTop = topY + mm(2.2); - const legalLineY = topY + mm(23.4); - - drawLine(document, left, topY, right, topY, 0.5, 0.55); - - document - .font("FuturaDemi") - .fontSize(8.2) - .fillColor(colors.white) - .text("DevSH Graphics Programming Sp. z o.o.", left, blockTop + mm(1.2), { - width: mm(96), - height: 10, - lineBreak: false, - }); - - document - .font("FuturaBook") - .fontSize(7.4) - .fillColor(colors.muted) - .text("ul. Lipuska 36\n80-178 Gdansk\nPoland", left, blockTop + mm(6.6), { - width: mm(64), - height: mm(13), - lineGap: 1.15, - }); - - document - .font("FuturaBook") - .fontSize(7.4) - .fillColor(colors.muted) - .text("Contact", right - mm(45), blockTop + mm(5.8), { - width: mm(45), - height: 10, - align: "right", - lineBreak: false, - }); - - document - .font("FuturaBook") - .fontSize(7.4) - .fillColor(colors.accent) - .text("devsh@devsh.eu", right - mm(45), blockTop + mm(10.8), { - width: mm(45), - height: 10, - align: "right", - lineBreak: false, - }); - - drawLine(document, left, legalLineY, right, legalLineY, 0.36, 0.5); - - drawCenteredText(document, "NIP 5833334868 | REGON 382168019 | KRS 0000764661", legalLineY + mm(5), { - font: "FuturaBook", - size: 7.2, - color: colors.muted, - }); - drawCenteredText(document, "Share capital: 15 000 PLN paid in full", legalLineY + mm(9.6), { - font: "FuturaBook", - size: 7.2, - color: colors.muted, - }); -} - -const document = new PDFDocument({ - size: "A4", - margin: 0, - info: { - Title: "DevSH 2026 Professional Engineering Rates", - Author: "DevSH Graphics Programming Sp. z o.o.", - }, -}); - -document.registerFont("FuturaBook", path.join(fontDir, "FuturaCyrillicBook.ttf")); -document.registerFont("FuturaMedium", path.join(fontDir, "FuturaCyrillicMedium.ttf")); -document.registerFont("FuturaDemi", path.join(fontDir, "FuturaCyrillicDemi.ttf")); - -document.rect(0, 0, document.page.width, document.page.height).fill(colors.background); +const document = createDocument("DevSH 2026 Professional Engineering Rates"); drawBrand(document); const footerTop = drawPricingTable(document); drawFooter(document, footerTop); diff --git a/scripts/lib/pdf-brand.mjs b/scripts/lib/pdf-brand.mjs new file mode 100644 index 0000000..b3a4a46 --- /dev/null +++ b/scripts/lib/pdf-brand.mjs @@ -0,0 +1,138 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import PDFDocument from "pdfkit"; + +export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const logoPath = path.join(rootDir, "public", "brand", "devsh-logo-glow.png"); +const fontDir = path.join(rootDir, "app", "fonts", "FuturaPT", "pdf"); + +export const mm = (value) => value * 72 / 25.4; + +export const colors = { + background: "#020403", + white: "#f8fffd", + muted: "#9fa9a7", + line: "#bafff6", + accent: "#8ceedd", +}; + +export function finishDocument(document) { + return new Promise((resolve, reject) => { + const chunks = []; + document.on("data", (chunk) => chunks.push(chunk)); + document.on("end", () => resolve(Buffer.concat(chunks))); + document.on("error", reject); + document.end(); + }); +} + +function withOpacity(document, opacity, draw) { + document.save(); + document.opacity(opacity); + draw(); + document.restore(); +} + +export function drawLine(document, x1, y1, x2, y2, opacity = 0.4, width = 0.55) { + withOpacity(document, opacity, () => { + document + .lineWidth(width) + .strokeColor(colors.line) + .moveTo(x1, y1) + .lineTo(x2, y2) + .stroke(); + }); +} + +export function drawCenteredText(document, text, y, options) { + document + .font(options.font) + .fontSize(options.size) + .fillColor(options.color ?? colors.white) + .text(text, 0, y, { + width: document.page.width, + height: options.height ?? options.size * 1.5, + align: "center", + lineGap: options.lineGap ?? 0, + }); +} + +export function drawFooter(document, topY, contact = "devsh@devsh.eu", options = {}) { + const footerColors = { ...colors, ...options.colors }; + const left = mm(22); + const width = document.page.width - left * 2; + const right = left + width; + const blockTop = topY + mm(2.2); + const legalLineY = topY + mm(23.4); + + drawLine(document, left, topY, right, topY, options.lineOpacity ?? 0.5, 0.55); + + document + .font("FuturaDemi") + .fontSize(8.2) + .fillColor(footerColors.white) + .text("DevSH Graphics Programming Sp. z o.o.", left, blockTop + mm(1.2), { + width: mm(96), + height: 10, + lineBreak: false, + }); + + document + .font("FuturaBook") + .fontSize(7.4) + .fillColor(footerColors.muted) + .text("ul. Lipuska 36\n80-178 Gdansk\nPoland", left, blockTop + mm(6.6), { + width: mm(64), + height: mm(13), + lineGap: 1.15, + }); + + document + .font("FuturaBook") + .fontSize(7.4) + .fillColor(footerColors.muted) + .text("Contact", right - mm(45), blockTop + mm(5.8), { + width: mm(45), + height: 10, + align: "right", + lineBreak: false, + }); + + document + .font("FuturaBook") + .fontSize(7.4) + .fillColor(footerColors.accent) + .text(contact, right - mm(45), blockTop + mm(10.8), { + width: mm(45), + height: 10, + align: "right", + lineBreak: false, + }); + + drawLine(document, left, legalLineY, right, legalLineY, options.legalLineOpacity ?? 0.36, 0.5); + + drawCenteredText(document, "NIP 5833334868 | REGON 382168019 | KRS 0000764661", legalLineY + mm(5), { + font: "FuturaBook", + size: 7.2, + color: footerColors.muted, + }); + drawCenteredText(document, "Share capital: 15 000 PLN paid in full", legalLineY + mm(9.6), { + font: "FuturaBook", + size: 7.2, + color: footerColors.muted, + }); +} + +export function createDocument(title) { + const document = new PDFDocument({ + size: "A4", + margin: 0, + compress: true, + info: { Title: title, Author: "DevSH Graphics Programming Sp. z o.o." }, + }); + document.registerFont("FuturaBook", path.join(fontDir, "FuturaCyrillicBook.ttf")); + document.registerFont("FuturaMedium", path.join(fontDir, "FuturaCyrillicMedium.ttf")); + document.registerFont("FuturaDemi", path.join(fontDir, "FuturaCyrillicDemi.ttf")); + document.rect(0, 0, document.page.width, document.page.height).fill(colors.background); + return document; +}