diff --git a/.gitignore b/.gitignore index ed2f5952..adfae40c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ doc-outside/ .playwright-mcp/ .ruff_cache/ .claude/ +worker/subscribe/.dev.vars +worker/subscribe/node_modules/ +worker/subscribe/.wrangler/ node_modules/ .env .env.* diff --git a/README.md b/README.md index 890e09ec..16ca1c0b 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,11 @@ Polymaker-Preset/ ├── style.css # Styling (dark theme + wiki light theme) ├── index.json # Auto-generated index of all presets ├── package.json # Node.js project configuration -├── assets/ # Static assets (logo, fonts) +├── assets/ # Static assets (logo, fonts, banner) │ ├── logo/ # Polymaker Teal.png (en), Chinese_NoSlogan_Teal.png (zh) +│ ├── banner/ # LayerHub banner art, logos, mail icon │ └── font/Figtree/ # Self-hosted Figtree variable fonts (.ttf) +├── worker/subscribe/ # Cloudflare Worker → Brevo LayerHub Waitlist ├── preset/ # All preset files │ └── / │ └── / diff --git a/app.js b/app.js index 5ff57cb3..13a76881 100644 --- a/app.js +++ b/app.js @@ -33,6 +33,14 @@ function trackGaEvent(eventName, params) { } } +function initBannerCta() { + var cta = document.getElementById('layerhub-banner-cta'); + if (!cta) return; + cta.addEventListener('click', function () { + trackGaEvent('banner_explore', { destination: 'layerhub3d.com' }); + }); +} + function applyTheme(theme) { var body = document.body; if (theme === 'wiki') { @@ -2501,3 +2509,4 @@ initDuplicateModal(); initBambuRestartModal(); initMissingVariantModal(); initAccordion(); +initBannerCta(); diff --git a/assets/banner/banner-art-dark.jpg b/assets/banner/banner-art-dark.jpg new file mode 100644 index 00000000..4031c10e Binary files /dev/null and b/assets/banner/banner-art-dark.jpg differ diff --git a/assets/banner/banner-art-wiki.jpg b/assets/banner/banner-art-wiki.jpg new file mode 100644 index 00000000..06ac2d87 Binary files /dev/null and b/assets/banner/banner-art-wiki.jpg differ diff --git a/assets/banner/icon-mail.svg b/assets/banner/icon-mail.svg new file mode 100644 index 00000000..6824e164 --- /dev/null +++ b/assets/banner/icon-mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/banner/layerhub-logo-dark.svg b/assets/banner/layerhub-logo-dark.svg new file mode 100644 index 00000000..9e7240a3 --- /dev/null +++ b/assets/banner/layerhub-logo-dark.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/banner/layerhub-logo-white.svg b/assets/banner/layerhub-logo-white.svg new file mode 100644 index 00000000..517acca2 --- /dev/null +++ b/assets/banner/layerhub-logo-white.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/i18n.js b/i18n.js index c090878c..70748d31 100644 --- a/i18n.js +++ b/i18n.js @@ -18,6 +18,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': 'Heat Ready. Impact Ready. Still PLA.', 'product.htPlaPro.cta': 'Learn More', + // LayerHub banner + 'banner.title': 'Official Presets + Global Creators', + 'banner.title.line2': 'All on LayerHub', + 'banner.subtitle': 'Not finding the filament preset you\'re interested in?
LayerHub is a community platform where you can discover official picks alongside presets shared by creators worldwide – and share your own too.', + 'banner.submit': 'Explore Now', + // Filters 'filter.slicer.label': 'Select Your Slicer', 'filter.slicer.placeholder': 'Select Slicer', @@ -190,6 +196,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': '耐热就绪,抗冲击就绪,依然是 PLA。', 'product.htPlaPro.cta': '了解更多', + // LayerHub banner + 'banner.title': '官方预设 + 全球创作者', + 'banner.title.line2': '都在 LayerHub', + 'banner.subtitle': '没找到你感兴趣的耗材预设?
LayerHub 是一个社区平台,你可以在这里发现官方精选和全球创作者分享的预设,也可以分享你自己的作品。', + 'banner.submit': '立即探索', + // Filters 'filter.slicer.label': '选择切片软件', 'filter.slicer.placeholder': '请选择', @@ -362,6 +374,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': 'Hitzebeständig. Schlagfest. Trotzdem PLA.', 'product.htPlaPro.cta': 'Mehr erfahren', + // LayerHub banner + 'banner.title': 'Offizielle Presets + globale Creator', + 'banner.title.line2': 'Alles auf LayerHub', + 'banner.subtitle': 'Sie finden das gewünschte Filamentprofil nicht?
LayerHub ist eine Community-Plattform, auf der Sie offizielle Empfehlungen und von Creatorn weltweit geteilte Presets entdecken – und eigene teilen können.', + 'banner.submit': 'Jetzt entdecken', + // Filters 'filter.slicer.label': 'Slicer auswählen', 'filter.slicer.placeholder': 'Slicer auswählen', @@ -536,6 +554,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': 'Resistente al calore. Resistente agli urti. Sempre PLA.', 'product.htPlaPro.cta': 'Scopri di più', + // LayerHub banner + 'banner.title': 'Preset ufficiali + creator globali', + 'banner.title.line2': 'Tutto su LayerHub', + 'banner.subtitle': 'Non trovi il preset di filamento che ti interessa?
LayerHub è una piattaforma community dove puoi scoprire selezioni ufficiali e preset condivisi da creator di tutto il mondo — e condividere i tuoi.', + 'banner.submit': 'Esplora ora', + 'filter.slicer.label': 'Seleziona il tuo slicer', 'filter.slicer.placeholder': 'Seleziona slicer', 'filter.slicer.notice': 'Each preset we provide is tuned for a specific combination of material, printer model, and slicer. If a preset is unavailable for your slicer but available for another, you can adapt it manually.', @@ -696,6 +720,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': 'Résistant à la chaleur. Résistant aux chocs. Toujours du PLA.', 'product.htPlaPro.cta': 'En savoir plus', + // LayerHub banner + 'banner.title': 'Presets officiels + créateurs du monde', + 'banner.title.line2': 'Tout sur LayerHub', + 'banner.subtitle': 'Vous ne trouvez pas le preset de filament qui vous intéresse ?
LayerHub est une plateforme communautaire où vous pouvez découvrir des sélections officielles et des presets partagés par des créateurs du monde entier — et partager les vôtres.', + 'banner.submit': 'Explorer maintenant', + 'filter.slicer.label': 'Sélectionnez votre slicer', 'filter.slicer.placeholder': 'Sélectionner un slicer', 'filter.slicer.notice': 'Each preset we provide is tuned for a specific combination of material, printer model, and slicer. If a preset is unavailable for your slicer but available for another, you can adapt it manually.', @@ -857,6 +887,12 @@ var I18N = (function () { 'product.htPlaPro.tagline': 'Listo para el calor. Listo para los impactos. Y sigue siendo PLA.', 'product.htPlaPro.cta': 'Más información', + // LayerHub banner + 'banner.title': 'Presets oficiales + creadores globales', + 'banner.title.line2': 'Todo en LayerHub', + 'banner.subtitle': '¿No encuentras el preset de filamento que te interesa?
LayerHub es una plataforma comunitaria donde puedes descubrir selecciones oficiales y presets compartidos por creadores de todo el mundo, y compartir los tuyos.', + 'banner.submit': 'Explorar ahora', + // Filters 'filter.slicer.label': 'Seleccione su laminador', 'filter.slicer.placeholder': 'Seleccionar laminador', diff --git a/index.html b/index.html index 49bc5bba..485c0d90 100644 --- a/index.html +++ b/index.html @@ -11039,6 +11039,34 @@

Select your slicer to view and download Polymaker print profiles and filament presets

+
+ + + + +
+ + +

+ Official Presets + Global Creators + All on LayerHub +

+

Not finding the filament preset you're interested in?
LayerHub is a community platform where you can discover official picks alongside presets shared by creators worldwide – and share your own too.

+ Explore Now +
+
+
diff --git a/style.css b/style.css index 56b89a66..e3e2a156 100644 --- a/style.css +++ b/style.css @@ -179,6 +179,197 @@ body.theme-wiki .preset-text { margin: 0; } +/* LayerHub waitlist banner — Figma 51:52 (dark) / 51:94 (wiki) */ +.layerhub-banner { + position: relative; + isolation: isolate; + overflow: hidden; + flex-shrink: 0; + margin: 0 0 1.25rem; + min-height: 280px; + border-radius: var(--radius-lg); + background: #0e0c25; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.layerhub-banner-art { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: right center; + pointer-events: none; + z-index: 0; +} + +.layerhub-banner-art--wiki { + display: none; +} + +/* Figma 51:54 glow layers: 2126×1458 on 2880×720, mix-blend-screen */ +.layerhub-banner-glow { + position: absolute; + width: 73.82%; + height: 202.5%; + pointer-events: none; + z-index: 1; +} + +.layerhub-banner-glow--magenta { + left: -32.78%; + top: -6.39%; + bottom: auto; + opacity: 0.4; + mix-blend-mode: screen; + background: radial-gradient(ellipse 50% 50% at 50% 50%, rgb(188, 43, 134) 0%, rgba(188, 43, 134, 0) 87.5%); +} + +.layerhub-banner-glow--cyan { + left: 65.17%; + right: auto; + top: -15.28%; + bottom: auto; + opacity: 0.25; + mix-blend-mode: screen; + background: radial-gradient(ellipse 50% 50% at 50% 50%, rgb(0, 192, 198) 0%, rgba(0, 192, 198, 0) 87.5%); +} + +.layerhub-banner-inner { + position: relative; + z-index: 2; + max-width: min(42rem, 56%); + padding: 1.5rem 1.75rem 1.25rem; +} + +.layerhub-banner-logo { + display: block; + width: 155px; + height: 40px; + margin: 0 0 0.75rem; +} + +.layerhub-banner-logo--wiki { + display: none; +} + +.layerhub-banner-title { + margin: 0 0 0.5rem; + color: #ffffff; + font-size: clamp(1.35rem, 2.6vw, 2rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.05; +} + +.layerhub-banner-title span { + display: block; +} + +.layerhub-banner-title span:first-child { + white-space: nowrap; +} + +.layerhub-banner-subtitle { + margin: 0 0 1rem; + max-width: 27.5rem; + color: #ffffff; + font-size: clamp(0.78rem, 1.15vw, 0.94rem); + font-weight: 500; + line-height: 1.35; +} + +.layerhub-banner-submit { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 8.5rem; + height: 48px; + padding: 0 1.25rem; + border: none; + border-radius: 10px; + background: linear-gradient(90deg, #bc2b86 0%, #ec4f59 58%, #ffb52c 100%); + color: #ffffff; + font-family: inherit; + font-size: 0.95rem; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: filter var(--transition), transform var(--transition); +} + +.layerhub-banner-submit:hover { + filter: brightness(1.06); +} + +.layerhub-banner-submit:focus-visible { + outline: 2px solid #ffb52c; + outline-offset: 2px; +} + + +body.theme-wiki .layerhub-banner { + min-height: 320px; + background: #f9f9fb; + border-color: rgba(15, 23, 42, 0.06); +} + +body.theme-wiki .layerhub-banner-art--dark { + display: none; +} + +body.theme-wiki .layerhub-banner-art--wiki { + display: block; +} + +body.theme-wiki .layerhub-banner-glow { + inset: 0; + width: 100%; + height: 100%; +} + +body.theme-wiki .layerhub-banner-glow--magenta { + left: 0; + right: auto; + top: 0; + bottom: auto; + opacity: 1; + mix-blend-mode: normal; + background: radial-gradient(ellipse 50% 78% at 0% 100%, rgba(210, 140, 180, 0.16) 0%, rgba(210, 140, 180, 0) 68%); +} + +body.theme-wiki .layerhub-banner-glow--cyan { + left: 0; + right: auto; + top: 0; + bottom: auto; + opacity: 1; + mix-blend-mode: normal; + background: radial-gradient(ellipse 46% 74% at 100% 100%, rgba(130, 195, 215, 0.14) 0%, rgba(130, 195, 215, 0) 68%); +} + +body.theme-wiki .layerhub-banner-logo--dark { + display: none; +} + +body.theme-wiki .layerhub-banner-logo--wiki { + display: block; +} + +body.theme-wiki .layerhub-banner-title { + color: #111111; +} + +body.theme-wiki .layerhub-banner-subtitle { + color: #33363c; +} + +body.theme-wiki .layerhub-banner-submit { + background: linear-gradient(90deg, #ea4c5c 0%, #f57a48 50%, #ffac34 100%); +} + /* Compact product highlight */ .product-highlight { display: flex; @@ -1991,6 +2182,34 @@ body.theme-wiki .install-steps strong { .hero-desc { font-size: 0.75rem; } + .layerhub-banner { + min-height: 0; + } + .layerhub-banner-inner { + max-width: 100%; + padding: 1rem 0.75rem 0.9rem; + } + .layerhub-banner-art { + opacity: 0.35; + } + .layerhub-banner-logo { + width: 124px; + height: 32px; + margin-bottom: 0.5rem; + } + .layerhub-banner-title { + font-size: 1.2rem; + } + .layerhub-banner-title span:first-child { + white-space: normal; + } + .layerhub-banner-subtitle { + font-size: 0.75rem; + margin-bottom: 0.75rem; + } + .layerhub-banner-submit { + min-width: 7.5rem; + } .product-highlight { align-items: flex-end; gap: 0.75rem; diff --git a/tests/banner-subscribe.test.js b/tests/banner-subscribe.test.js new file mode 100644 index 00000000..3be3c687 --- /dev/null +++ b/tests/banner-subscribe.test.js @@ -0,0 +1,128 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const htmlPath = path.join(__dirname, '..', 'index.html'); +const stylePath = path.join(__dirname, '..', 'style.css'); +const appPath = path.join(__dirname, '..', 'app.js'); +const i18nPath = path.join(__dirname, '..', 'i18n.js'); +const htmlContent = fs.readFileSync(htmlPath, 'utf-8'); +const styleContent = fs.readFileSync(stylePath, 'utf-8'); +const appContent = fs.readFileSync(appPath, 'utf-8'); +const i18nContent = fs.readFileSync(i18nPath, 'utf-8'); + +describe('LayerHub banner HTML', () => { + it('places the banner between hero and slicer card', () => { + var heroEnd = htmlContent.indexOf(''); + var banner = htmlContent.indexOf('id="layerhub-banner"'); + var slicer = htmlContent.indexOf('id="slicer-card"'); + assert.ok(heroEnd !== -1 && banner !== -1 && slicer !== -1); + assert.ok(heroEnd < banner && banner < slicer); + }); + + it('is a CTA link to the official LayerHub site', () => { + var bannerStart = htmlContent.indexOf('id="layerhub-banner"'); + var bannerEnd = htmlContent.indexOf('id="slicer-card"'); + var bannerHtml = htmlContent.slice(bannerStart, bannerEnd); + assert.ok(bannerHtml.includes('id="layerhub-banner-cta"')); + assert.ok(bannerHtml.includes('href="https://www.layerhub3d.com"')); + assert.ok(bannerHtml.includes('target="_blank"')); + assert.ok(bannerHtml.includes('rel="noopener noreferrer"')); + assert.ok(bannerHtml.includes('data-i18n="banner.title"')); + assert.ok(bannerHtml.includes('data-i18n="banner.title.line2"')); + assert.ok(bannerHtml.includes('data-i18n-html="banner.subtitle"')); + assert.ok(bannerHtml.includes('data-i18n="banner.submit"')); + assert.ok(bannerHtml.includes('Explore Now')); + assert.ok(!bannerHtml.includes('id="layerhub-banner-form"')); + assert.ok(!bannerHtml.includes('id="layerhub-banner-email"')); + assert.ok(!bannerHtml.includes('id="layerhub-banner-honeypot"')); + assert.ok(!bannerHtml.includes('id="layerhub-banner-status"')); + assert.ok(!bannerHtml.includes('data-subscribe-url=')); + assert.ok(!bannerHtml.includes('data-i18n="banner.email.placeholder"')); + assert.ok(!bannerHtml.includes('data-i18n="banner.privacy"')); + assert.ok(!bannerHtml.includes('icon-mail.svg')); + assert.ok(!bannerHtml.includes(' { + assert.ok(htmlContent.includes('assets/banner/banner-art-dark.jpg')); + assert.ok(htmlContent.includes('assets/banner/banner-art-wiki.jpg')); + assert.ok(htmlContent.includes('assets/banner/layerhub-logo-white.svg')); + assert.ok(htmlContent.includes('assets/banner/layerhub-logo-dark.svg')); + var bannerStart = htmlContent.indexOf('id="layerhub-banner"'); + var bannerEnd = htmlContent.indexOf('id="slicer-card"'); + var bannerHtml = htmlContent.slice(bannerStart, bannerEnd); + assert.ok(!bannerHtml.includes(' { + it('uses the Figma dark canvas and wiki overrides', () => { + assert.match(styleContent, /\.layerhub-banner\s*\{[^}]*background:\s*#0e0c25/s); + assert.match( + styleContent, + /body\.theme-wiki \.layerhub-banner\s*\{[^}]*background:\s*#f9f9fb/s + ); + assert.match(styleContent, /body\.theme-wiki \.layerhub-banner-art--wiki/); + assert.match( + styleContent, + /body\.theme-wiki \.layerhub-banner-glow--magenta\s*\{[^}]*radial-gradient\(ellipse 50% 78% at 0% 100%/s + ); + assert.match( + styleContent, + /body\.theme-wiki \.layerhub-banner-glow--cyan\s*\{[^}]*radial-gradient\(ellipse 46% 74% at 100% 100%/s + ); + assert.ok(!/body\.theme-wiki \.layerhub-banner-art[^{]*\{[^}]*mask-image/s.test(styleContent)); + assert.match(styleContent, /body\.theme-wiki \.layerhub-banner-logo--wiki/); + assert.match( + styleContent, + /\.layerhub-banner-submit\s*\{[^}]*linear-gradient\(90deg, #bc2b86 0%, #ec4f59 58%, #ffb52c 100%\)/s + ); + assert.match( + styleContent, + /body\.theme-wiki \.layerhub-banner-submit\s*\{[^}]*linear-gradient\(90deg, #ea4c5c 0%, #f57a48 50%, #ffac34 100%\)/s + ); + assert.match( + styleContent, + /\.layerhub-banner-glow--magenta\s*\{[^}]*radial-gradient\(ellipse 50% 50% at 50% 50%, rgb\(188, 43, 134\)/s + ); + assert.match( + styleContent, + /\.layerhub-banner-glow--cyan\s*\{[^}]*radial-gradient\(ellipse 50% 50% at 50% 50%, rgb\(0, 192, 198\)/s + ); + assert.match(styleContent, /\.layerhub-banner-title span:first-child\s*\{[^}]*white-space:\s*nowrap/s); + assert.match(styleContent, /\.layerhub-banner-subtitle\s*\{[^}]*max-width:\s*27\.5rem/s); + assert.match(styleContent, /\.layerhub-banner-submit\s*\{[^}]*text-decoration:\s*none/s); + assert.ok(!styleContent.includes('.layerhub-banner-email-wrap')); + assert.ok(!styleContent.includes('.layerhub-banner-form')); + assert.ok(!styleContent.includes('.layerhub-banner-status')); + }); +}); + +describe('LayerHub banner i18n', () => { + it('keeps CTA copy and drops subscribe-only keys', () => { + assert.ok(i18nContent.includes("'banner.submit': 'Explore Now'")); + assert.ok(i18nContent.includes("'banner.submit': '立即探索'")); + assert.ok(!i18nContent.includes('banner.email.placeholder')); + assert.ok(!i18nContent.includes('banner.success')); + assert.ok(!i18nContent.includes('banner.error')); + assert.ok(!i18nContent.includes('banner.invalid')); + assert.ok(!i18nContent.includes('subscribe here to stay updated')); + }); +}); + +describe('app.js CTA wiring', () => { + it('initializes the banner CTA and tracks banner_explore', () => { + assert.ok(appContent.includes('function initBannerCta()')); + assert.ok(appContent.includes('initBannerCta();')); + assert.ok(appContent.includes("trackGaEvent('banner_explore'")); + assert.ok(appContent.includes("getElementById('layerhub-banner-cta')")); + assert.ok(!appContent.includes('function initBannerSubscribe()')); + assert.ok(!appContent.includes("trackGaEvent('banner_subscribe'")); + assert.ok(!appContent.includes('isSubscribeHoneypotFilled')); + }); +}); diff --git a/tests/worker-subscribe.test.js b/tests/worker-subscribe.test.js new file mode 100644 index 00000000..63d933e6 --- /dev/null +++ b/tests/worker-subscribe.test.js @@ -0,0 +1,16 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { isValidEmail, isHoneypotFilled } from '../worker/subscribe/src/index.js'; + +describe('subscribe worker helpers', () => { + it('validates emails the same way as the form', () => { + assert.strictEqual(isValidEmail('user@example.com'), true); + assert.strictEqual(isValidEmail('bad'), false); + assert.strictEqual(isValidEmail(''), false); + }); + + it('accepts honeypot company/website fields', () => { + assert.strictEqual(isHoneypotFilled(''), false); + assert.strictEqual(isHoneypotFilled('spam-bot'), true); + }); +}); diff --git a/worker/subscribe/README.md b/worker/subscribe/README.md new file mode 100644 index 00000000..42c005ae --- /dev/null +++ b/worker/subscribe/README.md @@ -0,0 +1,51 @@ +# LayerHub Banner Subscribe — Ops and Deploy + +The banner POSTs emails to this Worker. The Worker writes contacts to a dedicated Brevo list, `LayerHub Waitlist`. The API key lives only in Cloudflare Secrets and is never committed to Git. + +## 1. Brevo (one-time setup) + +1. Sign up for [Brevo Free](https://www.brevo.com/) (about 300 emails/day). +2. Contacts → Lists → create **LayerHub Waitlist** (do not merge it into the main Polymaker list). +3. Add contact attributes: `SOURCE` (text) and `LANG` (text). +4. Note the List ID. +5. Settings → SMTP & API → API keys → generate a key with Contacts permission only. +6. Sending domain: start with Brevo’s default domain; switch to a branded domain later. +7. Leave double opt-in off for now. To enable it later: create a DOI template, set the Worker’s `BREVO_DOI` to `1`, and configure `BREVO_DOI_TEMPLATE_ID` and `BREVO_DOI_REDIRECT_URL`. + +## 2. Deploy the Worker + +```bash +cd worker/subscribe +npx wrangler login +npx wrangler secret put BREVO_API_KEY +npx wrangler secret put BREVO_LIST_ID +npx wrangler deploy +``` + +Note the `*.workers.dev` URL. To use `subscribe.presets.polymaker.com`, add a Worker route on the presets domain in Cloudflare. + +The frontend URL is `#layerhub-banner[data-subscribe-url]` in [`index.html`](../../index.html). Update it to the real Worker URL after deploy. + +Local: `npx wrangler dev`, then temporarily set `data-subscribe-url` to `http://127.0.0.1:8787`. + +## 3. Turn off the Klaviyo popup + +Keep the Klaviyo script (`WZCXdW`) in `index.html` for now. After the banner ships, disable the presets.polymaker.com popup in Klaviyo Onsite so you do not run two subscribe boxes and two lists. **Do not** write LayerHub subscribers into Klaviyo. + +## 4. Day-to-day ops + +- List: Brevo → Contacts → `LayerHub Waitlist`. Filter with `SOURCE = presets.polymaker.com`. +- Launch email: create a Campaign against that list (free tier is about 300 emails/day; send in batches if needed). +- Unsubscribe: Brevo campaign emails include unsubscribe by default. +- Export: CSV. Upgrade the plan if you hit limits; the API does not need to change. + +## 5. Acceptance check + +```bash +curl -sS -X POST "$WORKER_URL" \ + -H "Origin: https://presets.polymaker.com" \ + -H "Content-Type: application/json" \ + -d '{"email":"you@example.com","lang":"en"}' +``` + +It should return `{"ok":true}`, and the email should appear in the Brevo list. Sending the same email again should also succeed (`updateEnabled`). diff --git a/worker/subscribe/src/index.js b/worker/subscribe/src/index.js new file mode 100644 index 00000000..96b0ca88 --- /dev/null +++ b/worker/subscribe/src/index.js @@ -0,0 +1,134 @@ +var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +var ALLOWED_ORIGINS = [ + 'https://presets.polymaker.com', + 'http://localhost:8000', + 'http://127.0.0.1:8000' +]; + +var rateBuckets = new Map(); + +function corsHeaders(origin) { + var allow = ALLOWED_ORIGINS.indexOf(origin) !== -1 ? origin : ALLOWED_ORIGINS[0]; + return { + 'Access-Control-Allow-Origin': allow, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + 'Content-Type': 'application/json' + }; +} + +function json(body, status, headers) { + return new Response(JSON.stringify(body), { status: status, headers: headers }); +} + +function isValidEmail(email) { + return !!(email && EMAIL_RE.test(email)); +} + +function isHoneypotFilled(value) { + return !!(value && String(value).replace(/^\s+|\s+$/g, '')); +} + +function isRateLimited(ip) { + var now = Date.now(); + var rec = rateBuckets.get(ip) || { n: 0, t: now }; + if (now - rec.t > 60000) { + rec.n = 0; + rec.t = now; + } + rec.n += 1; + rateBuckets.set(ip, rec); + return rec.n > 8; +} + +async function createBrevoContact(env, email, lang) { + var listId = Number(env.BREVO_LIST_ID); + var apiKey = env.BREVO_API_KEY; + if (!apiKey || !listId) { + return { ok: false, status: 503, error: 'not_configured' }; + } + + var useDoi = env.BREVO_DOI === '1'; + var url = useDoi + ? 'https://api.brevo.com/v3/contacts/doubleOptinConfirmation' + : 'https://api.brevo.com/v3/contacts'; + var payload = useDoi + ? { + email: email, + includeListIds: [listId], + templateId: Number(env.BREVO_DOI_TEMPLATE_ID), + redirectionUrl: env.BREVO_DOI_REDIRECT_URL || 'https://presets.polymaker.com/', + attributes: { SOURCE: 'presets.polymaker.com', LANG: lang } + } + : { + email: email, + listIds: [listId], + updateEnabled: true, + attributes: { SOURCE: 'presets.polymaker.com', LANG: lang } + }; + + var res = await fetch(url, { + method: 'POST', + headers: { + 'api-key': apiKey, + 'accept': 'application/json', + 'content-type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (res.ok || res.status === 204) { + return { ok: true, status: 200 }; + } + + var text = await res.text(); + if (/already exist|duplicate/i.test(text)) { + return { ok: true, status: 200 }; + } + return { ok: false, status: 502, error: 'upstream' }; +} + +export default { + async fetch(request, env) { + var origin = request.headers.get('Origin') || ''; + var headers = corsHeaders(origin); + + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: headers }); + } + if (request.method !== 'POST') { + return json({ error: 'method_not_allowed' }, 405, headers); + } + + var ip = request.headers.get('CF-Connecting-IP') || 'unknown'; + if (isRateLimited(ip)) { + return json({ error: 'rate_limited' }, 429, headers); + } + + var body; + try { + body = await request.json(); + } catch (e) { + return json({ error: 'invalid_json' }, 400, headers); + } + + if (isHoneypotFilled(body && (body.company || body.website))) { + return json({ ok: true }, 200, headers); + } + + var email = String((body && body.email) || '').replace(/^\s+|\s+$/g, '').toLowerCase(); + if (!isValidEmail(email)) { + return json({ error: 'invalid_email' }, 400, headers); + } + + var lang = String((body && body.lang) || 'en').slice(0, 8); + var result = await createBrevoContact(env, email, lang); + if (result.ok) { + return json({ ok: true }, 200, headers); + } + return json({ error: result.error }, result.status, headers); + } +}; + +export { isValidEmail, isHoneypotFilled, isRateLimited }; diff --git a/worker/subscribe/wrangler.toml b/worker/subscribe/wrangler.toml new file mode 100644 index 00000000..e502befc --- /dev/null +++ b/worker/subscribe/wrangler.toml @@ -0,0 +1,16 @@ +name = "layerhub-subscribe" +main = "src/index.js" +compatibility_date = "2026-08-31" + +# Secrets (do not commit): +# wrangler secret put BREVO_API_KEY +# wrangler secret put BREVO_LIST_ID +# +# Optional: +# wrangler secret put BREVO_DOI_TEMPLATE_ID +# wrangler secret put BREVO_DOI_REDIRECT_URL +# +# Set BREVO_DOI=1 only after the DOI template exists. + +[vars] +BREVO_DOI = "0"