deploy: add public copy guardrails

This commit is contained in:
DMleadgen 2026-03-27 17:00:03 -06:00
parent 3df305d779
commit 3ed66cc715
Signed by: matt
GPG key ID: C2720CF8CD701894
14 changed files with 194 additions and 16 deletions

View file

@ -97,14 +97,26 @@ Do not rely on these names for current code behavior without a separate alignmen
pnpm deploy:staging:env
```
3. Run the full local preflight:
3. Run the public-copy guardrail:
```bash
pnpm copy:check
```
4. Run the full local preflight:
```bash
pnpm deploy:staging:preflight
```
4. Push the reviewed release commit to `origin/main`.
5. Verify Coolify is building from the repo root Dockerfile and exposing port `3001`.
5. Push the reviewed release commit to `origin/main`.
6. Verify Coolify is building from the repo root Dockerfile and exposing port `3001`.
## Public Copy Rule
- Customer-facing copy must sell the service, not explain the website.
- If a line mentions the site structure, design system, popup, embed, or internal workflow, rewrite it.
- Use `docs/public-copy-guardrails.md` as the source of truth before releasing public UI copy.
## Post-Deploy Smoke Tests

View file

@ -351,7 +351,7 @@ function renderLocationPage(locationData: any, locationSlug: string) {
<div className="p-1 text-center">
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
<p className="text-muted-foreground">
Open the Rocky placement popup and we&apos;ll follow up within one business day with the best next step for your location.
Tell us about your location and we&apos;ll follow up within one business day with recommendations for the right setup.
</p>
<div className="mt-6 flex flex-col items-center gap-3">
<GetFreeMachineCta buttonLabel="Get Free Placement" />

View file

@ -121,7 +121,7 @@ export default function ServiceAreasPage() {
Don&apos;t see your city yet?
</h2>
<p className="mt-3 text-base leading-relaxed text-muted-foreground">
We may still be able to help. If you&apos;re near one of these service zones, call or send us a request and we&apos;ll confirm the best next step.
We may still be able to help. If you&apos;re near one of these service zones, reach out and we&apos;ll let you know if we can cover your location.
</p>
</div>
@ -147,7 +147,7 @@ export default function ServiceAreasPage() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">Core Services</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance md:text-4xl">Our vending machine services across {state}</h2>
<p className="mx-auto mt-3 max-w-2xl text-base leading-relaxed text-muted-foreground">
From free placement and machine sales to repairs, moving, and parts help, we keep one consistent service experience across every city we serve.
From free placement and machine sales to repairs, moving, and parts help, we support businesses across Utah with dependable local service.
</p>
</div>

View file

@ -15,7 +15,7 @@ export default function SignInPage() {
align="center"
eyebrow="Customer Flow"
title="Admin Sign-In"
description="This route keeps the same Rocky shell as the rest of the site. Auth still needs to be wired before this area becomes usable."
description="This area is reserved for Rocky Mountain Vending admins."
/>
<PublicSurface className="p-6 text-center md:p-8">

View file

@ -351,7 +351,7 @@ export default async function LocationPage({ params }: LocationPageProps) {
<div className="text-center mb-6">
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
<p className="text-muted-foreground">
Open the free placement popup and we&apos;ll follow up within one business day to talk through the best machine mix for your location.
Tell us about your location and we&apos;ll follow up within one business day to talk through the best machine mix for your business.
</p>
</div>
<PublicInset className="mb-5 p-5 text-sm leading-relaxed text-muted-foreground">

View file

@ -29,7 +29,7 @@ export default async function MachinesWeUsePage() {
align="center"
eyebrow="Equipment"
title="Machines and hardware we trust in the field."
description="This page now shares the same warm Rocky Mountain Vending styling as the rest of the public site while keeping the product and equipment details intact."
description="Learn about the machines, payment hardware, and specialty equipment we trust to keep locations running smoothly."
/>
<section className="mt-10 grid gap-6 md:grid-cols-2 xl:grid-cols-3">

View file

@ -65,7 +65,7 @@ export function ReviewsPage() {
align="center"
eyebrow="Customer Reviews"
title="What Utah businesses say about working with Rocky Mountain Vending."
description="We built this page to feel like the rest of the site: clear, calm, and easy to scan. These reviews highlight why local businesses trust us for placement, restocking, repairs, and day-to-day support."
description="See why Utah businesses trust Rocky Mountain Vending for free placement, dependable service, and fast local support."
/>
<div className="mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
@ -92,8 +92,7 @@ export function ReviewsPage() {
Browse the full review feed from Rocky Mountain Vending customers.
</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted-foreground">
This is the same live review feed that powers the Google review widget, so the dedicated reviews page
shows the full set instead of just a few highlight quotes.
See the full stream of Google reviews from businesses that rely on us for placement, restocking, repairs, and day-to-day support.
</p>
</div>
<Badge variant="secondary" className="w-fit rounded-full px-3 py-1 text-sm">

View file

@ -36,7 +36,7 @@ export function ReviewsSection() {
align="center"
eyebrow="Google Reviews"
title="What Our Customers Say"
description="See what Utah businesses have to say about Rocky Mountain Vending, all in the same clean review shell used across the rest of the site."
description="See what Utah businesses have to say about Rocky Mountain Vending and the service they count on every day."
>
<div className="mt-4 flex justify-center">
<Badge variant="secondary" className="rounded-full px-4 py-1.5 text-sm">

View file

@ -55,7 +55,7 @@ export function ServicesSection() {
Our services and products
</h2>
<p className="mx-auto mt-3 max-w-2xl text-lg text-muted-foreground text-pretty leading-relaxed">
From free placement to manuals and repairs, these sections now share the same visual rhythm as the rest of the public site.
From free placement to manuals and repairs, we make it easy to get the machines, service, and support your location needs.
</p>
</div>

View file

@ -13,8 +13,8 @@ export function VendingMachinesPage() {
<PublicPageHeader
align="center"
eyebrow="Machine Options"
title="Modern vending machines with a cleaner Rocky Mountain Vending presentation."
description="Browse the machines we use, the features we prioritize, and the support that comes with every installation. This page now matches the rest of the site instead of feeling like a separate design system."
title="Modern vending machines built for reliable everyday use."
description="Browse the machines we use, the features we prioritize, and the support that comes with every installation."
/>
<div className="mt-10">

View file

@ -0,0 +1,61 @@
# Rocky Public Copy Guardrails
This guide applies to customer-facing copy in `app/` and `components/`.
## Voice
- Sound like a strong local vending company.
- Lead with customer outcomes: service, speed, support, placement, reliability.
- Keep it plainspoken, confident, and easy to scan.
- Write like Rocky Mountain Vending is talking to a business owner, property manager, school, or office admin.
## Do Say
- What we do
- Who it is for
- What happens next
- How quickly we respond
- Why businesses trust Rocky
Examples:
- `Tell us what you need and our team will follow up quickly.`
- `Need service, moving help, or machine sales support? Were here to help.`
- `Share your location details and well recommend the right machine mix.`
## Never Say
Do not let customer-facing copy explain the website, UI, or refactor work.
Avoid phrases like:
- `rest of the site`
- `design system`
- `presentation`
- `shell`
- `dedicated page`
- `intake`
- `handoff`
- `path`
- `popup`
- `embed`
- `this page now`
- `consistent service experience`
- `best next step`
- `fuller request`
- `fuller intake`
## Before / After
- Bad: `We built this page to feel like the rest of the site: clear, calm, and easy to scan.`
- Good: `See why Utah businesses trust Rocky Mountain Vending for dependable service and support.`
- Bad: `Open the Rocky placement popup and well follow up within one business day.`
- Good: `Tell us about your location and well follow up within one business day.`
- Bad: `This page now matches the rest of the site instead of feeling like a separate design system.`
- Good: `Browse the machines we use, the features we prioritize, and the support that comes with every installation.`
## Review Rule
If a sentence explains how the site is organized instead of why a customer should care, rewrite it.

View file

@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "next build",
"copy:check": "node scripts/check-public-copy.mjs",
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",

View file

@ -0,0 +1,98 @@
import { readFileSync, readdirSync, statSync } from "node:fs"
import path from "node:path"
import process from "node:process"
const ROOT = process.cwd()
const SEARCH_DIRS = ["app", "components"]
const INCLUDE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mdx"])
const IGNORE_PATTERNS = [
".backup",
`${path.sep}style-guide${path.sep}`,
`${path.sep}ui${path.sep}`,
`${path.sep}api${path.sep}`,
`${path.sep}manuals${path.sep}README.md`,
`${path.sep}components${path.sep}privacy-policy-page.tsx`,
`${path.sep}components${path.sep}terms-and-conditions-page.tsx`,
`${path.sep}app${path.sep}seaga-hy900-support${path.sep}`,
]
const BANNED_PATTERNS = [
{ label: "rest of the site", regex: /\brest of the site\b/i },
{ label: "design system", regex: /\bdesign system\b/i },
{ label: "presentation", regex: /\bpresentation\b/i },
{ label: "same clean", regex: /\bsame clean\b/i },
{ label: "same Rocky shell", regex: /\bsame rocky shell\b/i },
{ label: "dedicated page", regex: /\bdedicated page\b/i },
{ label: "intake", regex: /\bintake\b/i },
{ label: "handoff", regex: /\bhandoff\b/i },
{ label: "popup", regex: /\bpopup\b/i },
{ label: "embed", regex: /\bembed(?:ded)?\b/i },
{ label: "this page now", regex: /\bthis page now\b/i },
{ label: "consistent service experience", regex: /\bconsistent service experience\b/i },
{ label: "best next step", regex: /\bbest next step\b/i },
{ label: "fuller request", regex: /\bfuller request\b/i },
{ label: "fuller intake", regex: /\bfuller intake\b/i },
]
function shouldIgnore(filePath) {
return IGNORE_PATTERNS.some((pattern) => filePath.includes(pattern))
}
function walk(dir) {
const entries = readdirSync(dir, { withFileTypes: true })
const results = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (shouldIgnore(fullPath)) continue
if (entry.isDirectory()) {
results.push(...walk(fullPath))
continue
}
if (!entry.isFile()) continue
if (INCLUDE_EXTENSIONS.has(path.extname(entry.name))) {
results.push(fullPath)
}
}
return results
}
function findLineNumber(content, index) {
return content.slice(0, index).split("\n").length
}
function main() {
const files = SEARCH_DIRS.flatMap((dir) => {
const fullDir = path.join(ROOT, dir)
if (!statSync(fullDir, { throwIfNoEntry: false })) return []
return walk(fullDir)
})
const findings = []
for (const filePath of files) {
const content = readFileSync(filePath, "utf8")
for (const rule of BANNED_PATTERNS) {
const match = content.match(rule.regex)
if (!match || match.index == null) continue
findings.push({
filePath,
line: findLineNumber(content, match.index),
label: rule.label,
preview: match[0],
})
}
}
if (findings.length === 0) {
console.log("Public copy check passed.")
process.exit(0)
}
console.error("Public copy guardrail failed. Rewrite customer-facing copy that talks about the site or UI mechanics:")
for (const finding of findings) {
console.error(`- ${path.relative(ROOT, finding.filePath)}:${finding.line} [${finding.label}] ${finding.preview}`)
}
process.exit(1)
}
main()

View file

@ -223,6 +223,13 @@ function main() {
heading("Build")
const copyCheckResult = runShell("pnpm copy:check", { inherit: true })
if (copyCheckResult.status !== 0) {
failures.push("pnpm copy:check failed.")
} else {
console.log("pnpm copy:check: ok")
}
if (args.build) {
const buildResult = runShell("pnpm build", { inherit: true })
if (buildResult.status !== 0) {