feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
This commit is contained in:
parent
2e1b69998f
commit
7edfade53c
41 changed files with 2443 additions and 910 deletions
|
|
@ -9,11 +9,14 @@ const GITHUB_URL = 'https://github.com/emergence-engineering';
|
|||
|
||||
// Build provenance: the pcbjam commit this site was built from — it pins the
|
||||
// KiCad + wxWidgets submodule revisions implicitly and is our GPLv3
|
||||
// corresponding-source pointer (see /licenses). Resolved at build time:
|
||||
// Vercel sets VERCEL_GIT_COMMIT_SHA; local builds fall back to git.
|
||||
// corresponding-source pointer (see /licenses), so it must not silently degrade
|
||||
// to 'main'. Resolved at build time, in order: Cloudflare Pages CI
|
||||
// (CF_PAGES_COMMIT_SHA), GitHub Actions (GITHUB_SHA), then git. A direct-upload
|
||||
// `wrangler pages deploy` sets neither variable, but deploy-site.yml runs in
|
||||
// Actions on a real checkout, so the git fallback is correct there too.
|
||||
function resolveBuildSha(): string {
|
||||
const vercelSha = process.env.VERCEL_GIT_COMMIT_SHA;
|
||||
if (vercelSha) return vercelSha.slice(0, 7);
|
||||
const ciSha = process.env.CF_PAGES_COMMIT_SHA || process.env.GITHUB_SHA;
|
||||
if (ciSha) return ciSha.slice(0, 7);
|
||||
try {
|
||||
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* The live viewer needs cross-origin isolation (SharedArrayBuffer + pthreads)
|
||||
* and WebGL2. We feature-detect and degrade gracefully: capable browsers get the
|
||||
* live viewer; everything else gets a static poster + a short note. COOP/COEP are
|
||||
* applied to this route (dev: astro.config.mjs; prod: vercel.json); because
|
||||
* applied to this route (dev: astro.config.mjs; prod: public/_headers); because
|
||||
* Astro's <ClientRouter/> can deliver this page via a soft SPA swap (which is
|
||||
* NOT cross-origin isolated), the client script does one guarded reload so the
|
||||
* route headers take effect.
|
||||
|
|
@ -148,7 +148,7 @@ const caption =
|
|||
}
|
||||
|
||||
// Reloaded moments ago and STILL not isolated (stale cache, missing headers,
|
||||
// a preview without vercel.json…). Don't dead-end and don't blame the
|
||||
// a preview without _headers…). Don't dead-end and don't blame the
|
||||
// browser: the standalone viewer page is isolated on its own, so let the
|
||||
// button open it in a new tab — capable browsers get the full viewer there.
|
||||
if (launchEl) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Cookie Policy
|
||||
description: How PCBJam uses cookies and similar technologies, and the choices you have.
|
||||
updated: 2026-07-15
|
||||
updated: 2026-07-27
|
||||
---
|
||||
|
||||
This Cookie Policy explains how **PCBJam** ("PCBJam", "we", "us", "our") uses cookies and similar technologies — such as browser local storage, IndexedDB and cache storage — when you visit **pcbjam.com** or use the PCBJam application (the "Service"), and the choices you have.
|
||||
|
|
@ -61,7 +61,7 @@ The PCBJam application is rolling out in stages. We've split the tables so you c
|
|||
|
||||
**The PCBJam website does not set any cookies that require your consent.** Our usage measurement is cookieless (see Section 5), and joining the waitlist is handled by a server request — it does not place a cookie on your device.
|
||||
|
||||
If our hosting provider (Vercel) sets a strictly-necessary cookie for security or load-balancing in some circumstances, that cookie is essential to delivering the page you requested and carries no tracking function.
|
||||
If our hosting provider (Cloudflare) sets a strictly-necessary cookie for security or load-balancing in some circumstances, that cookie is essential to delivering the page you requested and carries no tracking function.
|
||||
|
||||
### 4b. The PCBJam application (as it rolls out)
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ We do **not** use Google Analytics, advertising pixels, or any cross-site tracki
|
|||
|---|---|---|---|
|
||||
| **Paddle** (Paddle.com Market Limited and affiliates) | Merchant of Record / payments | Checkout, security and fraud-prevention cookies; their own non-essential cookies (with consent where required, via Paddle's own controls). Independent controller. | [Paddle Privacy Policy](https://www.paddle.com/legal/privacy); Paddle's cookie controls appear within its checkout. |
|
||||
| **Plausible** (Plausible Insights OÜ) | Cookieless analytics | Nothing — no cookies and no device storage (see Section 5). | [Plausible Data Policy](https://plausible.io/data-policy) · [Plausible Privacy](https://plausible.io/privacy) |
|
||||
| **Vercel** (Vercel Inc.) | Hosting | At most strictly-necessary hosting cookies. | [Vercel Privacy](https://vercel.com/legal/privacy-policy) |
|
||||
| **Cloudflare** (Cloudflare, Inc.) | Hosting, CDN and network security | At most strictly-necessary hosting/security cookies. | [Cloudflare Privacy](https://www.cloudflare.com/privacypolicy/) |
|
||||
|
||||
We are not responsible for the privacy practices of these third parties; please review their notices. We update this list as our integrations change.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Privacy Policy
|
||||
description: How PCBJam handles your personal data.
|
||||
updated: 2026-07-15
|
||||
updated: 2026-07-27
|
||||
---
|
||||
|
||||
## 1. A quick summary
|
||||
|
|
@ -142,11 +142,10 @@ We do **not** sell your personal data. We share it only with the following categ
|
|||
|
||||
| Recipient | What they do | Their role | More info |
|
||||
|---|---|---|---|
|
||||
| **Vercel** | Hosting/infrastructure | **Processor** | [Vercel DPA](https://vercel.com/legal/dpa) · [Privacy](https://vercel.com/legal/privacy-policy) |
|
||||
| **Plausible** | Cookieless web analytics | **Processor** | [Plausible DPA](https://plausible.io/dpa) · [Data policy](https://plausible.io/data-policy) |
|
||||
| **Paddle** | Payments / Merchant of Record | **Independent controller** (for payment data) | [Paddle Privacy](https://www.paddle.com/legal/privacy) |
|
||||
| **Resend** | Sending transactional and marketing email | **Processor** | [Resend DPA](https://resend.com/legal/dpa) · [Subprocessors](https://resend.com/legal/subprocessors) |
|
||||
| **Cloudflare** | Storing your project files (R2 object storage), hosting application/account data, and network delivery & security (CDN, DNS, WAF) | **Processor** | [Cloudflare DPA](https://www.cloudflare.com/cloudflare-customer-dpa/) · [GDPR hub](https://www.cloudflare.com/trust-hub/gdpr/) |
|
||||
| **Cloudflare** | Hosting this website, storing your project files (R2 object storage), hosting application/account data, and network delivery & security (CDN, DNS, WAF) | **Processor** | [Cloudflare DPA](https://www.cloudflare.com/cloudflare-customer-dpa/) · [GDPR hub](https://www.cloudflare.com/trust-hub/gdpr/) |
|
||||
| **Google (Google Workspace)** | Our business email and support correspondence | **Processor** | [Google Cloud DPA](https://cloud.google.com/terms/data-processing-addendum) |
|
||||
| **Professional advisers & authorities** | Lawyers, accountants, auditors; courts, regulators, and law-enforcement where legally required | Controller / as required | — |
|
||||
| **A successor entity** | If we are involved in a merger, acquisition, financing, or sale of assets, your data may transfer to the successor under this policy | As required | — |
|
||||
|
|
@ -168,7 +167,7 @@ PCBJam lets you **publish projects and share them** with others (for example via
|
|||
|
||||
## 11. International data transfers
|
||||
|
||||
We are based in the EU, but some of our providers (for example, Vercel, Cloudflare, Paddle, and Resend) may process data **outside the European Economic Area**, including in the United States. Where personal data is transferred outside the EEA/UK, we rely on an appropriate safeguard under Chapter V GDPR, such as:
|
||||
We are based in the EU, but some of our providers (for example, Cloudflare, Paddle, and Resend) may process data **outside the European Economic Area**, including in the United States. Where personal data is transferred outside the EEA/UK, we rely on an appropriate safeguard under Chapter V GDPR, such as:
|
||||
|
||||
- an **adequacy decision** by the European Commission (including, where applicable, the **EU–US Data Privacy Framework** for certified US providers); or
|
||||
- the European Commission's **Standard Contractual Clauses** (and the UK International Data Transfer Addendum), together with additional safeguards where needed.
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const ogImageAbs = new URL(ogImage, Astro.site ?? Astro.url).href;
|
|||
pcbjam properties. Prod builds only — `astro dev` stays untracked, and
|
||||
Plausible ignores localhost regardless. Auto-tracks pageviews across
|
||||
ClientRouter navigations (History API). crossorigin="anonymous" so the
|
||||
script still loads on the COEP require-corp routes (see vercel.json):
|
||||
script still loads on the COEP require-corp routes (see public/_headers):
|
||||
cross-origin subresources there must be fetched with CORS. */}
|
||||
{
|
||||
import.meta.env.PROD && (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { defineMiddleware } from 'astro:middleware';
|
|||
* cross-origin isolated, and the embedded Gerber viewer's SharedArrayBuffer
|
||||
* would be unavailable. Set them here for `npm run dev`.
|
||||
*
|
||||
* Production is static (output: 'static'); these headers come from vercel.json
|
||||
* Production is static (output: 'static'); these headers come from public/_headers
|
||||
* scoped to the post + /gerber-demo routes, so we no-op outside dev to keep the
|
||||
* prerender/build output untouched.
|
||||
*/
|
||||
|
|
|
|||
46
site/src/pages/404.astro
Normal file
46
site/src/pages/404.astro
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
// Prerendered to dist/404.html, which Cloudflare Pages serves with a real 404
|
||||
// status for any unmatched path. This page is NOT optional: without a 404.html
|
||||
// in the output, Pages answers every unknown URL with the homepage at HTTP 200 —
|
||||
// a soft 404 that invites search engines to index arbitrary URLs as the
|
||||
// homepage. (On Vercel the SSR function rendered Astro's built-in 404 instead.)
|
||||
---
|
||||
|
||||
<BaseLayout title="Page not found" description="That page doesn't exist.">
|
||||
<section class="notfound">
|
||||
<p class="code">404</p>
|
||||
<h1>That page doesn't exist.</h1>
|
||||
<p class="muted">
|
||||
It may have moved, or the link may be wrong. The blog index and the
|
||||
homepage are good places to pick things back up.
|
||||
</p>
|
||||
<p class="links">
|
||||
<a href="/">Homepage</a>
|
||||
<a href="/blog">Blog</a>
|
||||
</p>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.notfound {
|
||||
padding: 5rem 0 6rem;
|
||||
max-width: 34rem;
|
||||
}
|
||||
.code {
|
||||
font-family: var(--font-display, inherit);
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
margin: 0 0 0.5rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.notfound h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { Resend } from 'resend';
|
||||
// Typed server secrets (schema in astro.config.mjs). Optional, so RESEND_API_KEY
|
||||
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
|
||||
// schema default so it's always a `string`. The Vercel adapter reads these from
|
||||
// process.env at runtime — never inlined.
|
||||
import {
|
||||
RESEND_API_KEY,
|
||||
RESEND_SEGMENT_ID,
|
||||
WAITLIST_FROM_EMAIL,
|
||||
WAITLIST_ALLOWED_ORIGINS,
|
||||
} from 'astro:env/server';
|
||||
|
||||
// Opt this single route into on-demand (serverless) rendering. The rest of the
|
||||
// site stays static; Vercel emits exactly one function for /api/waitlist.
|
||||
export const prerender = false;
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
/**
|
||||
* Redact an address for logs: keep the domain (useful for debugging a
|
||||
* misconfigured send) but mask the local-part so logs never hold the raw
|
||||
* address.
|
||||
*/
|
||||
function maskEmail(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
if (at <= 0) return '***';
|
||||
return `${email.slice(0, 1)}***@${email.slice(at + 1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort per-key rate limit — an in-process sliding window that bounds
|
||||
* bursts from one warm instance. NOT a complete defense on serverless (fresh
|
||||
* instances don't share it), so production should ALSO front this with a shared
|
||||
* store (Vercel KV / Upstash) or a CAPTCHA. Kept dependency-free so it runs.
|
||||
*/
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const RATE_MAX_PER_IP = 5;
|
||||
const RATE_MAX_PER_EMAIL = 2;
|
||||
const rateHits = new Map<string, number[]>();
|
||||
|
||||
function rateLimited(key: string, max: number, now: number): boolean {
|
||||
const hits = (rateHits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
|
||||
if (hits.length >= max) {
|
||||
rateHits.set(key, hits);
|
||||
return true;
|
||||
}
|
||||
hits.push(now);
|
||||
rateHits.set(key, hits);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Client IP from the standard proxy headers (Vercel sets x-forwarded-for). */
|
||||
function clientIp(request: Request): string {
|
||||
const xff = request.headers.get('x-forwarded-for');
|
||||
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS headers when the request Origin is in the configured allowlist — lets the
|
||||
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
|
||||
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
|
||||
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
|
||||
*/
|
||||
function corsHeaders(request: Request): Record<string, string> {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) return {};
|
||||
const allowed = WAITLIST_ALLOWED_ORIGINS.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (!allowed.includes(origin)) return {};
|
||||
return {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'POST, OPTIONS',
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'access-control-max-age': '86400',
|
||||
vary: 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function json(
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
function redirect(status: 'ok' | 'error') {
|
||||
// No-JS native submit: bounce back to the page with a status flag.
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { Location: `/?waitlist=${status}#waitlist` },
|
||||
});
|
||||
}
|
||||
|
||||
export const OPTIONS: APIRoute = ({ request }) =>
|
||||
new Response(null, { status: 204, headers: corsHeaders(request) });
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const cors = corsHeaders(request);
|
||||
const ct = request.headers.get('content-type') ?? '';
|
||||
const wantsJson = ct.includes('application/json');
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = wantsJson
|
||||
? await request.json()
|
||||
: Object.fromEntries(await request.formData());
|
||||
} catch {
|
||||
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
|
||||
}
|
||||
|
||||
const email = String(data.email ?? '').trim().toLowerCase();
|
||||
const honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
|
||||
const source = String(data.source ?? 'unknown');
|
||||
|
||||
// Bot caught by honeypot: silently "succeed" so we don't tip them off.
|
||||
if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
||||
|
||||
if (!EMAIL_RE.test(email) || email.length > 254) {
|
||||
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
|
||||
}
|
||||
|
||||
// Rate limit: bound per-IP and per-address submissions. Checked after
|
||||
// validation so invalid emails don't consume budget.
|
||||
const now = Date.now();
|
||||
const ip = clientIp(request);
|
||||
if (
|
||||
rateLimited(`ip:${ip}`, RATE_MAX_PER_IP, now) ||
|
||||
rateLimited(`email:${email}`, RATE_MAX_PER_EMAIL, now)
|
||||
) {
|
||||
return wantsJson
|
||||
? json(429, { ok: false, error: 'rate_limited' }, cors)
|
||||
: redirect('error');
|
||||
}
|
||||
|
||||
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
|
||||
if (!RESEND_API_KEY) {
|
||||
console.warn(
|
||||
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
|
||||
);
|
||||
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
||||
}
|
||||
|
||||
const resend = new Resend(RESEND_API_KEY);
|
||||
|
||||
try {
|
||||
// Add to the segment (the modern name for an "audience"). The SDK returns
|
||||
// { data, error } and does NOT throw on API errors. A duplicate contact has
|
||||
// no stable error code (it surfaces as a validation_error), so we treat the
|
||||
// contact step as best-effort: log any error but never fail the request on
|
||||
// it — the user-facing promise is the confirmation email below.
|
||||
if (RESEND_SEGMENT_ID) {
|
||||
const { error: contactError } = await resend.contacts.create({
|
||||
email,
|
||||
unsubscribed: false,
|
||||
segments: [{ id: RESEND_SEGMENT_ID }],
|
||||
});
|
||||
if (contactError) {
|
||||
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
|
||||
}
|
||||
}
|
||||
|
||||
const { error: sendError } = await resend.emails.send({
|
||||
from: WAITLIST_FROM_EMAIL,
|
||||
to: email,
|
||||
subject: "You're on the PCBJam waitlist",
|
||||
text: [
|
||||
"You're on the list. 🎉",
|
||||
'',
|
||||
"We'll send your early-access invite as seats open in waves, plus the occasional",
|
||||
'product update. Every update includes an unsubscribe link — and you can reply',
|
||||
'to this email at any time to be taken off the list.',
|
||||
'',
|
||||
"Didn't sign up? Just reply and we'll remove this address.",
|
||||
'',
|
||||
'— The PCBJam team, built by Emergence Engineering',
|
||||
'https://pcbjam.com/privacy',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// The confirmation send IS the user-facing promise — fail loudly if Resend
|
||||
// rejected it (bad key, unverified domain, invalid from-address, …).
|
||||
if (sendError) {
|
||||
console.error('[waitlist] emails.send failed', sendError);
|
||||
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
|
||||
}
|
||||
|
||||
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
||||
} catch (err) {
|
||||
// Defensive: unexpected throw (network error, bad construction).
|
||||
console.error('[waitlist] unexpected error', err);
|
||||
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
|
||||
}
|
||||
};
|
||||
|
||||
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
|
||||
export const GET: APIRoute = () => json(405, { ok: false, error: 'method_not_allowed' });
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* deliberately do NOT apply to the landing page: that would block the YouTube
|
||||
* hero embed (a no-COEP cross-origin iframe can't load in an isolated document).
|
||||
* So Launch opens the standalone viewer (/gerber-demo/) in a NEW TAB: that page
|
||||
* IS cross-origin isolated (vercel.json) and boots the board immediately.
|
||||
* IS cross-origin isolated (public/_headers) and boots the board immediately.
|
||||
*/
|
||||
import SectionBand from '../components/SectionBand.astro';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue