Compare commits

..

11 Commits

Author SHA1 Message Date
Khalid A
3ca12a1ed1 Rewrite SMS onboarding page for owner text-to-edit, not customer notifications
The sms-onboarding.astro page was previously framed around customers
subscribing to receive website update notifications (events, menu items,
announcements). This rewrites it entirely to reflect the actual product:
website owners editing their own site via natural language SMS.

Changes:
- Page title: 'Text-to-Edit' instead of 'Get Website Updates by Text'
- Hero: explains owners can edit their site by texting natural language
- Form: 'Enable Text-to-Edit' for the owner's phone number
- Features: edit content, add events, swap photos, update style — with
  example natural language prompts
- New 'How It Works' section: text → review proposal → reply YES
- FAQ: restructured around owner questions (who can edit, mistakes/undo,
  stopping, cost) with links to the visual editor
- Form action endpoint updated from /api/sms/subscribe to
  /api/sms/register-owner (reflects owner registration intent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 04:26:24 -05:00
Khalid A
250b0d4aa3 Add legal and onboarding pages
- Add /sms-onboarding page for text message consent signup
  with FAQ, consent checkbox, and feature grid
- Add /privacy-policy page with comprehensive sections
  covering data collection, SMS communications, and user rights
- Add /terms page with full terms of use including
  SMS service terms, intellectual property, and disclaimers

All pages use BaseLayout and site-context for dynamic branding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:48:01 -05:00
khalid@traclabs.com
3cb0cbe088 Add routing and summary models and start with a smaller model for json patches with a bigger fallback model immediately. 2026-04-23 09:10:52 -05:00
khalid@traclabs.com
c17ce052c1 Flip between messages for the update. 2026-04-23 08:44:30 -05:00
khalid@traclabs.com
68ecaec76c Add better messages for intent processing and update in progress 2026-04-23 08:40:15 -05:00
khalid@traclabs.com
46247b7733 Add a banner to indicate updates in progress 2026-04-23 08:34:47 -05:00
khalid@traclabs.com
4e014fa648 Switch to a smaller intent model 2026-04-23 08:26:24 -05:00
khalid@traclabs.com
36bce5a908 Fix how env variables are loaded in astro 2026-04-23 08:20:16 -05:00
khalid@traclabs.com
36fadf710d Add live reload on changes 2026-04-23 08:04:34 -05:00
khalid@traclabs.com
233fb6d003 Autoapply edits if env variable configured 2026-04-23 07:58:35 -05:00
khalid@traclabs.com
fdf6124fa1 Add tailwind and shadcn 2026-04-23 07:41:37 -05:00
23 changed files with 6452 additions and 47 deletions

View File

@@ -5,6 +5,12 @@ API_EDIT_SECRET=change-me-to-a-random-string
OLLAMA_API_KEY=
# For Ollama Cloud use https://ollama.com, for local Ollama use http://localhost:11434
OLLAMA_HOST=https://ollama.com
OLLAMA_INTENT_MODEL=gemma4:31b-cloud
OLLAMA_ROUTING_MODEL=gemma4:31b-cloud
OLLAMA_SUMMARY_MODEL=gemma4:31b-cloud
OLLAMA_JSON_MODEL=gemma4:31b-cloud
OLLAMA_JSON_FALLBACK_MODEL=qwen3.5:397b-cloud
OLLAMA_FALLBACK_MODEL=gpt-oss:120b
# Paths
REPO_ROOT=.
@@ -13,6 +19,18 @@ IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
# SSR cache
SITE_DATA_TTL_MS=500
# Browser live reload (optional)
# When enabled, the orchestrator (port 3001) hosts a websocket endpoint that broadcasts
# a reload event whenever content is written. Pages that opt-in will reload on message.
LIVE_RELOAD_WS_ENABLED=false
LIVE_RELOAD_WS_PATH=/__live_reload
# These are read by the Astro app (public envs are embedded client-side)
PUBLIC_LIVE_RELOAD_WS_ENABLED=false
PUBLIC_LIVE_RELOAD_WS_PATH=/__live_reload
# Optional override for deployments behind a reverse proxy:
# PUBLIC_LIVE_RELOAD_WS_URL=wss://your-domain.example/__live_reload
PUBLIC_LIVE_RELOAD_WS_URL=
# SMS (Vonage)
VONAGE_API_KEY=your_vonage_api_key
VONAGE_API_SECRET=your_vonage_api_secret
@@ -34,6 +52,8 @@ LOG_LEVEL=debug
# Proposals
PROPOSAL_TTL_MS=900000
# Set to "true" to skip the YES/NO confirmation step and apply edits immediately
AUTO_APPLY_EDITS=false
# Editor auth
EDITOR_SESSION_SECRET=change-me-to-another-random-string

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"css.lint.unknownAtRules": "ignore"
}

View File

@@ -162,3 +162,6 @@ Both the Astro SSR site and the orchestrator run in a single container, sharing
- **Zod is the contract**: Schemas drive validation at every boundary
- **Atomic writes**: temp file + rename prevents partial writes
- **Pre-write backups**: Last 20 versions per file under `content/.backups/`
## Shadcn Blocks
- Pre-built shadcn blocks can be found by running `npx shadcn@latest search @shadcnblocks`

View File

@@ -1,10 +1,14 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
server: { port: 4321 },
vite: {
plugins: [tailwindcss()],
},
});

32
components.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "hugeicons",
"rtl": false,
"aliases": {
"components": "@shared/components",
"utils": "@shared/lib/utils",
"ui": "@shared/components/ui",
"lib": "@shared/lib",
"hooks": "@shared/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@shadcnblocks": {
"url": "https://shadcnblocks.com/r/{name}",
"headers": {
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
}
}
}
}

4836
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,13 +21,24 @@
"check": "npm run lint && npm run check:content && npm test"
},
"dependencies": {
"astro": "^5.8.0",
"@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.1",
"@dynamic-sites/shared": "file:shared",
"@fontsource-variable/figtree": "^5.2.10",
"@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6",
"@tailwindcss/vite": "^4.2.4",
"astro": "^5.8.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^3.24.0",
"@dynamic-sites/shared": "file:shared"
"shadcn": "^4.4.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@@ -19,6 +19,7 @@
"express-rate-limit": "^7.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"ws": "^8.20.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -26,6 +27,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"@types/ws": "^8.18.1",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}

View File

@@ -3,6 +3,7 @@ import { createEditQueue } from './queue/edit-queue.js';
import { processEditJob } from './queue/process-edit-job.js';
import { openDb, closeDb, pruneExpiredProposals, pruneIdempotencyKeys } from './db.js';
import { logger } from './logger.js';
import { initLiveReload } from './live-reload.js';
const PORT = parseInt(process.env.ORCHESTRATOR_PORT || '3001', 10);
@@ -27,6 +28,9 @@ export async function startServer() {
logger.info({ event: 'server.started', port: PORT }, `Orchestrator listening on port ${PORT}`);
});
// Optional websocket for browser reload on content writes
initLiveReload(server);
// Graceful shutdown
let shuttingDown = false;
async function shutdown(signal: string) {

View File

@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
import { stringifyCanonical } from '@dynamic-sites/shared';
import { writeAuditLog } from '../db.js';
import { logger } from '../logger.js';
import { broadcastReload } from '../live-reload.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const MAX_BACKUPS = 20;
@@ -70,4 +71,13 @@ export function writeContentFile(
});
logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written');
// Notify any connected browsers to reload.
broadcastReload('content.written', {
path: repoRelativePath,
proposalId: opts?.proposalId,
source: opts?.source,
beforeHash,
afterHash,
});
}

68
server/src/live-reload.ts Normal file
View File

@@ -0,0 +1,68 @@
import type http from 'node:http';
import { WebSocketServer, type WebSocket } from 'ws';
import { logger } from './logger.js';
let wss: WebSocketServer | null = null;
export function initLiveReload(server: http.Server): void {
if (wss) return;
const enabled = process.env.LIVE_RELOAD_WS_ENABLED === 'true';
if (!enabled) {
logger.info({ event: 'live_reload.disabled' }, 'Live reload websocket disabled');
return;
}
const wsPath = process.env.LIVE_RELOAD_WS_PATH || '/__live_reload';
wss = new WebSocketServer({ server, path: wsPath });
wss.on('connection', (socket: WebSocket) => {
logger.info({ event: 'live_reload.connected' }, 'Live reload client connected');
socket.on('close', () => {
logger.info({ event: 'live_reload.disconnected' }, 'Live reload client disconnected');
});
});
logger.info({ event: 'live_reload.started', path: wsPath }, 'Live reload websocket started');
}
export function broadcastReload(reason: string, data?: Record<string, unknown>): void {
if (!wss) return;
const payload = JSON.stringify({
type: 'reload',
ts: Date.now(),
reason,
...data,
});
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(payload);
}
}
}
export type UpdateStatusPhase =
| 'request_received'
| 'intent_processing'
| 'updating'
| 'update_done';
export function broadcastUpdateStatus(phase: UpdateStatusPhase, data?: Record<string, unknown>): void {
if (!wss) return;
const payload = JSON.stringify({
type: 'update_status',
ts: Date.now(),
phase,
...data,
});
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(payload);
}
}
}

View File

@@ -4,8 +4,12 @@ import { logger } from '../logger.js';
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
const PRIMARY_MODEL = process.env.OLLAMA_MODEL || 'qwen3.5:397b-cloud';
const FALLBACK_MODEL = process.env.OLLAMA_FALLBACK_MODEL || 'gpt-oss:120b';
const INTENT_MODEL = process.env.OLLAMA_INTENT_MODEL || 'gemma4:31b-cloud';
const ROUTING_MODEL = process.env.OLLAMA_ROUTING_MODEL || 'gemma4:31b-cloud';
const SUMMARY_MODEL = process.env.OLLAMA_SUMMARY_MODEL || 'gemma4:31b-cloud';
const JSON_MODEL = process.env.OLLAMA_JSON_MODEL || 'qwen3.5:397b-cloud';
const JSON_FALLBACK_MODEL = process.env.OLLAMA_JSON_FALLBACK_MODEL || FALLBACK_MODEL;
const MAX_RETRIES = 3;
export interface LlmChatCaller {
@@ -39,13 +43,21 @@ async function generateWithValidation<T>(params: {
messages: Array<{ role: string; content: string }>;
schema: z.ZodType<T>;
chat?: LlmChatCaller;
models?: string[];
maxRetries?: number;
maxRetriesByModel?: number[];
}): Promise<T> {
const chat = params.chat || ollamaChat;
const models = [PRIMARY_MODEL, FALLBACK_MODEL];
const models = params.models?.length ? params.models : [JSON_MODEL, JSON_FALLBACK_MODEL];
for (const model of models) {
for (const [modelIndex, model] of models.entries()) {
const msgs = [...params.messages];
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const maxRetries =
params.maxRetriesByModel?.[modelIndex] ??
params.maxRetries ??
MAX_RETRIES;
for (let attempt = 0; attempt < maxRetries; attempt++) {
logger.debug({ event: 'llm.request', model, attempt }, 'LLM call');
try {
const raw = await chat(msgs, model);
@@ -67,7 +79,7 @@ async function generateWithValidation<T>(params: {
);
} catch (err) {
logger.warn({ event: 'llm.retry', model, attempt, error: (err as Error).message }, 'LLM call or parse failed');
if (attempt === MAX_RETRIES - 1) break;
if (attempt === maxRetries - 1) break;
msgs.push(
{ role: 'user', content: `Your response was not valid JSON. Please respond with ONLY a JSON object, no markdown or extra text.` }
);
@@ -110,7 +122,14 @@ The output must be valid JSON matching the exact same schema as the input.`,
},
];
return generateWithValidation({ messages, schema: params.schema, chat });
return generateWithValidation({
messages,
schema: params.schema,
chat,
models: [JSON_MODEL, JSON_FALLBACK_MODEL],
// Switch to fallback immediately after one failure on the primary JSON model.
maxRetriesByModel: [1, MAX_RETRIES],
});
}
export interface RouteEditIntentParams {
@@ -145,7 +164,17 @@ Example response:
},
];
return generateWithValidation({ messages, schema: routingOutputSchema, chat });
const routed = await generateWithValidation({
messages,
schema: routingOutputSchema,
chat,
models: [ROUTING_MODEL, FALLBACK_MODEL],
});
// Some TS setups infer optional fields from the Zod schema; normalize to our contract type.
return {
...routed,
needs_clarification: routed.needs_clarification ?? false,
} satisfies RoutingOutput;
}
// ── Message Classification ──
@@ -188,7 +217,7 @@ Examples:
},
];
return generateWithValidation({ messages, schema: classificationSchema, chat });
return generateWithValidation({ messages, schema: classificationSchema, chat, models: [INTENT_MODEL, FALLBACK_MODEL] });
}
// ── Info Response Generation ──
@@ -226,7 +255,7 @@ Rules:
];
try {
const result = await chatFn(messages, PRIMARY_MODEL);
const result = await chatFn(messages, SUMMARY_MODEL);
return result.trim().slice(0, 320);
} catch {
// Fallback: generate a basic listing from the manifest
@@ -242,31 +271,68 @@ export async function generateSummary(params: {
after: unknown;
repoRelativePath: string;
userMessage: string;
autoApply?: boolean;
chat?: LlmChatCaller;
}): Promise<string> {
const chat = params.chat || ollamaChat;
const messages = [
{
role: 'system',
content: `You write short, friendly summaries of website changes for a small business owner who manages their site via text message.
const proposalPrompt = `You write short, friendly summaries of PROPOSED website changes for a small business owner who manages their site via text message.
IMPORTANT: The change has NOT been applied yet. This is a proposal the owner must approve. Your summary will be shown to the owner prefixed with "I'd like to: ", so write it as a proposed action — what WILL happen if they approve.
Rules:
- Keep it under 140 characters.
- Describe what visitors will SEE on the website, not what changed in the data.
- Use plain, everyday language. No technical terms, no field names, no file paths, no JSON jargon.
- Write in a warm, conversational tone — like texting a friend.
- Write as a proposed future action — use phrasing like "show…", "add…", "update…", "hide…".
- NEVER use past tense or phrases like "is now live", "has been updated", "done", "all set".
Good examples:
- "Show the promo banner on your site"
- "Update your main headline to 'Welcome Home'"
- "Hide the testimonials section from visitors"
- "Add a new event: Wine Tasting on June 15"
- "Change the About section text"
- "show the promo banner on your site"
- "update your main headline to 'Welcome Home'"
- "hide the testimonials section from visitors"
- "add a new event: Wine Tasting on June 15"
- "change the About section text"
Bad examples (sounds already done — never do this):
- "The promo banner is now live on your site!"
- "Your headline has been updated!"
- "You got it! The sale banner is showing"
- "Done! Testimonials are hidden"
Bad examples (too technical — never do this):
- "Change promo-banner visible from false to true"
- "Update hero.headline field"
- "Modify content/sections/about.json"`,
- "Modify content/sections/about.json"`;
const autoApplyPrompt = `You write short, friendly summaries of website changes for a small business owner who manages their site via text message.
IMPORTANT: The change has ALREADY been applied. Your summary will be shown prefixed with "Done! I went ahead and ", so write it as a completed past-tense action.
Rules:
- Keep it under 140 characters.
- Describe what visitors will NOW SEE on the website, not what changed in the data.
- Use plain, everyday language. No technical terms, no field names, no file paths, no JSON jargon.
- Write in past tense — use phrasing like "updated…", "added…", "showed…", "hid…".
- Start lowercase (the sentence prefix is already provided).
- NEVER use future tense or proposal phrasing like "I'd like to", "will show", "would add".
Good examples:
- "showed the promo banner on your site"
- "updated your main headline to 'Welcome Home'"
- "hid the testimonials section from visitors"
- "added a new event: Wine Tasting on June 15"
- "changed the About section text"
Bad examples (too technical — never do this):
- "Change promo-banner visible from false to true"
- "Update hero.headline field"
- "Modify content/sections/about.json"`;
const messages = [
{
role: 'system',
content: params.autoApply ? autoApplyPrompt : proposalPrompt,
},
{
role: 'user',
@@ -275,7 +341,7 @@ Bad examples (too technical — never do this):
];
try {
const result = await chat(messages, PRIMARY_MODEL);
const result = await chat(messages, SUMMARY_MODEL);
return result.replace(/["'`]/g, '').trim().slice(0, 280);
} catch {
// Fallback: echo back the user's original request

View File

@@ -1,5 +1,6 @@
import type { EditJobPayload } from '@dynamic-sites/shared';
import { logger } from '../logger.js';
import { broadcastUpdateStatus } from '../live-reload.js';
export interface EditQueue {
enqueue(payload: EditJobPayload): void;
@@ -27,8 +28,15 @@ export function createEditQueue(): EditQueue {
try {
await processor!(job);
logger.info({ event: 'job.completed', kind: job.kind, id: job.id }, 'Job completed');
broadcastUpdateStatus('update_done', { jobKind: job.kind, jobId: job.id, ok: true });
} catch (err) {
logger.error({ event: 'job.failed', kind: job.kind, id: job.id, error: (err as Error).message }, 'Job failed');
broadcastUpdateStatus('update_done', {
jobKind: job.kind,
jobId: job.id,
ok: false,
error: (err as Error).message,
});
}
}
@@ -51,6 +59,7 @@ export function createEditQueue(): EditQueue {
}
jobs.push(payload);
logger.info({ event: 'job.enqueued', kind: payload.kind, id: payload.id, depth: jobs.length }, 'Job enqueued');
broadcastUpdateStatus('request_received', { jobKind: payload.kind, jobId: payload.id, depth: jobs.length });
// Start draining on next tick
if (processor) setImmediate(drain);
},

View File

@@ -10,8 +10,10 @@ import { buildSectionManifest, type ManifestEntry } from './manifest.js';
import { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js';
import { logger } from '../logger.js';
import { broadcastUpdateStatus } from '../live-reload.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const AUTO_APPLY = process.env.AUTO_APPLY_EDITS === 'true';
/** Get a friendly display name for a manifest entry. */
function sectionDisplayName(m: ManifestEntry): string {
@@ -54,6 +56,7 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
try {
// Step 0: Classify message intent (edit, info, or help)
broadcastUpdateStatus('intent_processing', { jobKind: job.kind, jobId: job.id, source: job.source });
const classification = await classifyMessageIntent({ userMessage: job.message });
log.info({ event: 'classification.result', intent: classification.intent, reason: classification.reason }, 'Message classified');
@@ -76,6 +79,7 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
}
// ── EDIT intent: proceed with existing edit flow ──
broadcastUpdateStatus('updating', { jobKind: job.kind, jobId: job.id, source: job.source });
// Step 1: Route — determine which file the edit targets
let repoRelativePath = job.repo_relative_path;
@@ -136,6 +140,7 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
after: editedJson,
repoRelativePath,
userMessage: job.message,
autoApply: AUTO_APPLY,
});
// Step 5: Store proposal
@@ -154,10 +159,27 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
// Step 6: Notify user
// Step 6: Auto-apply or ask for confirmation
if (AUTO_APPLY) {
const validation = schema.safeParse(editedJson);
if (!validation.success) {
log.error({ event: 'auto_apply.validation_failed', errors: validation.error.message }, 'Auto-apply validation failed');
updateProposalStatus(proposalId, 'rejected');
return;
}
writeContentFile(repoRelativePath, validation.data, { proposalId, source: job.source });
updateProposalStatus(proposalId, 'applied');
log.info({ event: 'proposal.auto_applied', proposalId, path: repoRelativePath }, 'Proposal auto-applied');
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.AUTO_APPLIED(summary));
}
} else {
if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_SUMMARY(summary, proposalId));
}
}
} catch (err) {
log.error({ event: 'propose.failed', error: (err as Error).message }, 'Propose failed');
@@ -171,6 +193,7 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
async function handleApply(job: Extract<EditJobPayload, { kind: 'apply' }>) {
const log = logger.child({ jobId: job.id, kind: 'apply', proposalId: job.proposal_id });
broadcastUpdateStatus('updating', { jobKind: job.kind, jobId: job.id, source: job.source, proposalId: job.proposal_id });
const proposal = getProposal(job.proposal_id);
if (!proposal) {

View File

@@ -192,7 +192,8 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
// GET /api/job/:id — poll for a proposal created by an async job
router.get('/job/:id', (req: Request, res: Response) => {
const entry = jobProposalMap.get(req.params.id);
const jobId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const entry = jobProposalMap.get(jobId);
if (!entry) {
res.json({ status: 'processing' });
return;
@@ -214,7 +215,8 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
// GET /api/proposal/:id — check proposal status
router.get('/proposal/:id', (req: Request, res: Response) => {
const proposal = getProposal(req.params.id);
const proposalId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const proposal = getProposal(proposalId);
if (!proposal) {
res.status(404).json({ error: 'Proposal not found' });
return;

View File

@@ -5,6 +5,9 @@ export const SMS_TEMPLATES = {
APPLIED: (summary: string) =>
`Done! ${summary} Your site will update in a moment.`,
AUTO_APPLIED: (summary: string) =>
`Done! I went ahead and ${summary} Your site will update in a moment.`,
REJECTED: () =>
`No problem — I cancelled that change. Just text me whenever you'd like to make an edit!`,

View File

@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@shared/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

6
shared/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -5,6 +5,12 @@ interface Props {
}
const { title, primaryColor = '#2d5016' } = Astro.props;
// Prefer runtime env (SSR) so Dokploy/containers can inject at deploy time.
const liveReloadEnabled =
(process.env.PUBLIC_LIVE_RELOAD_WS_ENABLED ?? import.meta.env.PUBLIC_LIVE_RELOAD_WS_ENABLED) === 'true';
const wsPath = (process.env.PUBLIC_LIVE_RELOAD_WS_PATH ?? import.meta.env.PUBLIC_LIVE_RELOAD_WS_PATH) || '/__live_reload';
const wsUrl = (process.env.PUBLIC_LIVE_RELOAD_WS_URL ?? import.meta.env.PUBLIC_LIVE_RELOAD_WS_URL) || '';
---
<!doctype html>
<html lang="en">
@@ -96,9 +102,45 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.update-banner {
position: sticky;
top: 0;
z-index: 50;
width: 100%;
height: 32px;
display: none;
align-items: center;
gap: 0.5rem;
padding: 0 1rem;
background: color-mix(in srgb, var(--color-primary), white 92%);
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
font-size: 0.85rem;
}
.update-banner[data-visible='true'] {
display: flex;
}
.update-banner__spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid color-mix(in srgb, var(--color-primary), white 70%);
border-top-color: var(--color-primary);
animation: updateBannerSpin 0.9s linear infinite;
flex: 0 0 auto;
}
@keyframes updateBannerSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="update-banner" class="update-banner" data-visible="false" role="status" aria-live="polite">
<span class="update-banner__spinner" aria-hidden="true"></span>
<span id="update-banner-text">Updating…</span>
</div>
<header class="site-header">
<div class="container">
<a href="/" class="site-logo"><slot name="logo">Dynamic Site</slot></a>
@@ -113,5 +155,92 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
<slot name="footer">© {new Date().getFullYear()}</slot>
</div>
</footer>
{liveReloadEnabled && (
<script define:vars={{ wsPath, wsUrl }}>
(() => {
const configuredUrl = (typeof wsUrl === 'string' ? wsUrl : '').trim();
const path = (typeof wsPath === 'string' ? wsPath : '/__live_reload').trim() || '/__live_reload';
const defaultPort = '3001';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.hostname;
const url = configuredUrl || `${proto}//${host}:${defaultPort}${path.startsWith('/') ? '' : '/'}${path}`;
const bannerEl = document.getElementById('update-banner');
const bannerTextEl = document.getElementById('update-banner-text');
function setBanner(visible, text) {
if (!bannerEl || !bannerTextEl) return;
bannerEl.dataset.visible = visible ? 'true' : 'false';
if (typeof text === 'string' && text.length > 0) bannerTextEl.textContent = text;
}
const updatingMessages = [
'Updating website',
'Reading website sections',
'Found section to update',
'Updating found section',
];
let updatingMessageIdx = 0;
let updatingTimer = null;
function stopUpdatingRotation() {
if (updatingTimer) {
clearInterval(updatingTimer);
updatingTimer = null;
}
updatingMessageIdx = 0;
}
function startUpdatingRotation() {
if (updatingTimer) return;
updatingMessageIdx = 0;
setBanner(true, updatingMessages[updatingMessageIdx] || 'Updating website');
updatingTimer = setInterval(() => {
updatingMessageIdx = (updatingMessageIdx + 1) % updatingMessages.length;
setBanner(true, updatingMessages[updatingMessageIdx] || 'Updating website');
}, 30_000);
}
function connect() {
const ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data || ''));
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'reload') {
window.location.reload();
return;
}
if (msg.type === 'update_status') {
if (msg.phase === 'request_received') setBanner(true, 'Processing update request');
else if (msg.phase === 'intent_processing') {
stopUpdatingRotation();
setBanner(true, 'Processing update request');
} else if (msg.phase === 'updating') {
startUpdatingRotation();
} else if (msg.phase === 'update_done') {
stopUpdatingRotation();
setBanner(false);
}
}
} catch {
// ignore
}
};
ws.onclose = () => setTimeout(connect, 1000);
ws.onerror = () => {
try { ws.close(); } catch { /* ignore */ }
};
}
connect();
})();
</script>
)}
</body>
</html>

View File

@@ -0,0 +1,245 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext } = loadSiteData();
---
<BaseLayout title={`Privacy Policy — ${siteContext.businessName}`} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">Your Privacy Matters</Fragment>
<section class="legal-page">
<div class="container">
<h1>Privacy Policy</h1>
<p class="effective-date">Effective Date: April 26, 2026</p>
<div class="legal-content">
<section class="legal-section">
<h2>1. Introduction</h2>
<p>
{siteContext.businessName} ("we," "us," or "our") respects your privacy and is committed to protecting your personal information.
This Privacy Policy explains how we collect, use, store, and safeguard your information when you visit our website,
use our services, or subscribe to our text message updates.
</p>
</section>
<section class="legal-section">
<h2>2. Information We Collect</h2>
<h3>2.1 Information You Provide Directly</h3>
<ul>
<li><strong>Contact Information:</strong> Name, phone number, and email address when you sign up for text notifications or contact us.</li>
<li><strong>Communication Preferences:</strong> Your opt-in consent for receiving text messages and marketing communications.</li>
<li><strong>Feedback:</strong> Any comments, reviews, or messages you send us.</li>
</ul>
<h3>2.2 Information Collected Automatically</h3>
<ul>
<li><strong>Usage Data:</strong> Pages visited, time spent on site, clicks, and interactions.</li>
<li><strong>Device Information:</strong> Browser type, operating system, IP address, and device identifiers.</li>
<li><strong>Cookies:</strong> Small data files stored on your device to improve your browsing experience.</li>
</ul>
</section>
<section class="legal-section">
<h2>3. How We Use Your Information</h2>
<p>We use the information we collect for the following purposes:</p>
<ul>
<li>To send you text message updates about our website, events, and offerings (with your explicit consent).</li>
<li>To respond to your inquiries and provide customer support.</li>
<li>To improve our website, services, and user experience.</li>
<li>To comply with legal obligations and enforce our terms.</li>
<li>To detect and prevent fraud, abuse, and security incidents.</li>
</ul>
</section>
<section class="legal-section">
<h2>4. Text Message Communications (SMS)</h2>
<p>
When you subscribe to our text message updates, you provide explicit consent to receive SMS messages
from {siteContext.businessName} at the phone number you provide.
</p>
<ul>
<li><strong>Message Frequency:</strong> Message frequency varies, typically 24 messages per month.</li>
<li><strong>Message and Data Rates:</strong> Standard message and data rates may apply based on your wireless carrier plan.</li>
<li><strong>Opt-Out:</strong> You may opt out at any time by replying <strong>STOP</strong> to any message.</li>
<li><strong>Help:</strong> Reply <strong>HELP</strong> for assistance or contact us directly.</li>
</ul>
<p>
We use third-party SMS providers to deliver messages. Your phone number is shared with these providers
solely for the purpose of message delivery and is not used for any other marketing without your additional consent.
</p>
</section>
<section class="legal-section">
<h2>5. How We Share Your Information</h2>
<p>We do not sell your personal information. We may share your information in the following limited circumstances:</p>
<ul>
<li><strong>Service Providers:</strong> With trusted third-party vendors who help us operate our website and deliver services (e.g., SMS providers, hosting, analytics).</li>
<li><strong>Legal Compliance:</strong> When required by law, subpoena, or legal process.</li>
<li><strong>Business Transfers:</strong> In connection with a merger, acquisition, or sale of assets, with notice to you.</li>
</ul>
</section>
<section class="legal-section">
<h2>6. Data Security</h2>
<p>
We implement reasonable technical and organizational measures to protect your personal information
against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission
over the internet or electronic storage is 100% secure, and we cannot guarantee absolute security.
</p>
</section>
<section class="legal-section">
<h2>7. Data Retention</h2>
<p>
We retain your personal information for as long as necessary to fulfill the purposes outlined in this policy,
unless a longer retention period is required or permitted by law. If you opt out of SMS messages,
we retain your phone number in a suppression list to honor your opt-out preference.
</p>
</section>
<section class="legal-section">
<h2>8. Your Rights</h2>
<p>Depending on your location, you may have the following rights regarding your personal information:</p>
<ul>
<li><strong>Access:</strong> Request a copy of the personal information we hold about you.</li>
<li><strong>Correction:</strong> Request that we correct inaccurate or incomplete information.</li>
<li><strong>Deletion:</strong> Request that we delete your personal information, subject to legal obligations.</li>
<li><strong>Opt-Out:</strong> Unsubscribe from marketing communications at any time.</li>
<li><strong>Portability:</strong> Request a copy of your data in a portable format.</li>
</ul>
<p>To exercise any of these rights, please contact us using the information below.</p>
</section>
<section class="legal-section">
<h2>9. Cookies and Tracking Technologies</h2>
<p>
We use cookies and similar technologies to enhance your experience on our website.
You can manage your cookie preferences through your browser settings.
Disabling cookies may affect the functionality of certain features on our site.
</p>
</section>
<section class="legal-section">
<h2>10. Third-Party Links</h2>
<p>
Our website may contain links to third-party websites. We are not responsible for the privacy practices
or content of those sites. We encourage you to review the privacy policies of any third-party sites you visit.
</p>
</section>
<section class="legal-section">
<h2>11. Children's Privacy</h2>
<p>
Our services are not directed to individuals under the age of 13.
We do not knowingly collect personal information from children under 13.
If we become aware that we have collected such information, we will take steps to delete it promptly.
</p>
</section>
<section class="legal-section">
<h2>12. Changes to This Policy</h2>
<p>
We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated
effective date. We encourage you to review this policy periodically. Significant changes may be communicated
via text message if you are subscribed to our updates.
</p>
</section>
<section class="legal-section">
<h2>13. Contact Us</h2>
<p>
If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices,
please contact us:
</p>
<ul class="contact-list">
{siteContext.contactEmail && <li><strong>Email:</strong> <a href={`mailto:${siteContext.contactEmail}`}>{siteContext.contactEmail}</a></li>}
{siteContext.contactPhone && <li><strong>Phone:</strong> {siteContext.contactPhone}</li>}
{siteContext.address && <li><strong>Address:</strong> {siteContext.address}</li>}
</ul>
</section>
</div>
</div>
</section>
<Fragment slot="footer">
&copy; {new Date().getFullYear()} {siteContext.businessName} &middot;
<a href="/privacy-policy">Privacy Policy</a> &middot;
<a href="/terms">Terms of Use</a>
</Fragment>
</BaseLayout>
<style>
.legal-page {
padding: 3rem 1.5rem 4rem;
}
.legal-page h1 {
font-family: var(--font-display);
font-size: 2rem;
color: var(--color-primary-dark);
margin-bottom: 0.5rem;
}
.effective-date {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 2rem;
font-style: italic;
}
.legal-content {
max-width: 720px;
margin: 0 auto;
}
.legal-section {
margin-bottom: 2rem;
}
.legal-section h2 {
font-family: var(--font-display);
font-size: 1.2rem;
color: var(--color-primary-dark);
margin-bottom: 0.75rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--color-border);
}
.legal-section h3 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 1rem 0 0.5rem;
}
.legal-section p {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 0.75rem;
}
.legal-section ul {
margin-left: 1.25rem;
margin-bottom: 0.75rem;
}
.legal-section li {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 0.4rem;
}
.legal-section li strong {
color: var(--color-primary-dark);
}
.contact-list {
list-style: none;
margin-left: 0;
padding: 0.75rem;
background: color-mix(in srgb, var(--color-primary), white 95%);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.contact-list li {
margin-bottom: 0.35rem;
}
.contact-list a {
color: var(--color-primary);
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,478 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext } = loadSiteData();
// In a real implementation, this would connect to an API endpoint
// that stores the owner's phone number and sends a confirmation SMS
---
<BaseLayout title={`Text-to-Edit — ${siteContext.businessName}`} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">Owner Portal</Fragment>
<section class="onboarding-hero">
<div class="container">
<h1>Update Your Website by Text</h1>
<p class="lead">
Send a text message to edit your website — no login, no dashboard, just natural language.
Change headlines, update hours, add events, swap photos, and more.
</p>
</div>
</section>
<section class="onboarding-form">
<div class="container">
<div class="form-card">
<h2>Enable Text-to-Edit</h2>
<p class="form-subtitle">
Enter the phone number you want to use to manage <strong>{siteContext.businessName}</strong>.
</p>
<form id="sms-signup-form" method="POST" action="/api/sms/register-owner">
<div class="form-group">
<label for="phone">Your Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
placeholder="(503) 555-0142"
required
pattern="[\d\s\-\+\(\)]{10,}"
/>
<span class="hint">We'll send a confirmation text to verify this number.</span>
</div>
<div class="form-group">
<label for="name">Your Name (optional)</label>
<input
type="text"
id="name"
name="name"
placeholder="First name"
/>
</div>
<div class="consent-box">
<label class="checkbox-label">
<input type="checkbox" name="consent" required />
<span class="checkmark"></span>
<span class="label-text">
I am the owner or authorized manager of <strong>{siteContext.businessName}</strong>
and I consent to receive text messages that allow me to edit this website.
Message and data rates may apply.
</span>
</label>
</div>
<button type="submit" class="submit-btn">Enable Text-to-Edit</button>
</form>
<div id="form-success" class="form-message success" style="display: none;">
<h3>You're all set!</h3>
<p>Check your phone for a confirmation message. Reply YES to activate text-to-edit.</p>
</div>
<div id="form-error" class="form-message error" style="display: none;">
<h3>Something went wrong</h3>
<p>Please try again or contact support.</p>
</div>
</div>
</div>
</section>
<section class="what-to-expect">
<div class="container">
<h2>What You Can Do</h2>
<div class="feature-grid">
<div class="feature">
<span class="feature-icon">✏️</span>
<h3>Edit Content</h3>
<p>"Change the headline to Summer Sale" or "Update our hours to 8am6pm"</p>
</div>
<div class="feature">
<span class="feature-icon">📅</span>
<h3>Add Events</h3>
<p>"Add a workshop on May 15 at 2pm" or "Cancel the April webinar"</p>
</div>
<div class="feature">
<span class="feature-icon">🖼️</span>
<h3>Swap Photos</h3>
<p>Text a photo to replace the hero image or add it to the gallery.</p>
</div>
<div class="feature">
<span class="feature-icon">🎨</span>
<h3>Update Style</h3>
<p>"Change our primary color to teal" or "Make the font bigger"</p>
</div>
</div>
</div>
</section>
<section class="how-it-works">
<div class="container">
<h2>How It Works</h2>
<ol class="steps-list">
<li><strong>Text your edit.</strong> Send a natural language message describing what you want changed.</li>
<li><strong>Review the proposal.</strong> You'll get a text back showing exactly what will change.</li>
<li><strong>Reply YES.</strong> The edit is applied to your site instantly — no deploy needed.</li>
</ol>
<p class="steps-note">
Every edit is validated, backed up, and reversible. Your site stays safe even if you mistype.
</p>
</div>
</section>
<section class="faq-section">
<div class="container">
<h2>Common Questions</h2>
<div class="faq-list">
<details class="faq-item">
<summary>Who can edit the website?</summary>
<p>
Only the phone number you register here can send edit commands.
You can update or transfer ownership at any time from the <a href="/editor">visual editor</a>.
</p>
</details>
<details class="faq-item">
<summary>Is there a cost?</summary>
<p>
Standard text messaging rates from your wireless carrier apply.
There is no additional charge from {siteContext.businessName} for using text-to-edit.
</p>
</details>
<details class="faq-item">
<summary>How do I stop using text-to-edit?</summary>
<p>
Reply <strong>STOP</strong> to any message to pause edits.
Reply <strong>START</strong> to resume.
You can also disable text-to-edit entirely from the <a href="/editor">editor</a>.
</p>
</details>
<details class="faq-item">
<summary>What if I make a mistake?</summary>
<p>
Every change is backed up automatically. Reply <strong>UNDO</strong> to revert the last edit,
or visit the <a href="/editor">visual editor</a> to review and restore any previous version.
</p>
</details>
</div>
</div>
</section>
<Fragment slot="footer">
&copy; {new Date().getFullYear()} {siteContext.businessName} &middot;
<a href="/privacy-policy">Privacy Policy</a> &middot;
<a href="/terms">Terms of Use</a>
</Fragment>
</BaseLayout>
<style>
.onboarding-hero {
text-align: center;
padding: 4rem 1.5rem 2.5rem;
background: linear-gradient(180deg, color-mix(in srgb, var(--color-primary), white 92%) 0%, var(--color-bg) 100%);
}
.onboarding-hero h1 {
font-family: var(--font-display);
font-size: 2.25rem;
color: var(--color-primary-dark);
margin-bottom: 0.75rem;
}
.onboarding-hero .lead {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 560px;
margin: 0 auto;
line-height: 1.6;
}
.onboarding-form {
padding: 1.5rem 1.5rem 3rem;
}
.form-card {
max-width: 520px;
margin: 0 auto;
background: white;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 2rem;
}
.form-card h2 {
font-family: var(--font-display);
font-size: 1.3rem;
color: var(--color-primary-dark);
margin-bottom: 0.5rem;
text-align: center;
}
.form-subtitle {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
margin-bottom: 0.35rem;
}
.form-group input {
display: block;
width: 100%;
padding: 0.65rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 1rem;
font-family: var(--font-body);
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--color-primary);
}
.form-group .hint {
display: block;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.3rem;
}
.consent-box {
margin-bottom: 1.5rem;
}
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.6rem;
cursor: pointer;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-text);
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
margin-top: 0.15rem;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.label-text strong {
color: var(--color-primary-dark);
}
.submit-btn {
width: 100%;
padding: 0.8rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
font-family: var(--font-body);
transition: background 0.2s;
}
.submit-btn:hover {
background: var(--color-primary-dark);
}
.form-message {
text-align: center;
padding: 1.5rem;
border-radius: 8px;
margin-top: 1rem;
}
.form-message h3 {
font-family: var(--font-display);
font-size: 1.15rem;
margin-bottom: 0.5rem;
}
.form-message.success {
background: color-mix(in srgb, #22c55e, white 88%);
color: #166534;
}
.form-message.error {
background: color-mix(in srgb, #ef4444, white 88%);
color: #991b1b;
}
.what-to-expect {
padding: 3rem 1.5rem;
background: color-mix(in srgb, var(--color-primary), white 96%);
border-top: 1px solid var(--color-border);
}
.what-to-expect h2 {
font-family: var(--font-display);
font-size: 1.6rem;
color: var(--color-primary-dark);
text-align: center;
margin-bottom: 2rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
max-width: 720px;
margin: 0 auto;
}
@media (max-width: 600px) {
.feature-grid {
grid-template-columns: 1fr;
}
}
.feature {
text-align: center;
padding: 1.25rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.feature-icon {
font-size: 1.75rem;
display: block;
margin-bottom: 0.5rem;
}
.feature h3 {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-primary-dark);
margin-bottom: 0.4rem;
}
.feature p {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.how-it-works {
padding: 3rem 1.5rem;
border-top: 1px solid var(--color-border);
}
.how-it-works h2 {
font-family: var(--font-display);
font-size: 1.6rem;
color: var(--color-primary-dark);
text-align: center;
margin-bottom: 2rem;
}
.steps-list {
max-width: 560px;
margin: 0 auto 1.5rem;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.steps-list li {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.6;
}
.steps-list li strong {
color: var(--color-primary-dark);
}
.steps-note {
text-align: center;
font-size: 0.85rem;
color: var(--color-text-muted);
max-width: 480px;
margin: 0 auto;
}
.faq-section {
padding: 3rem 1.5rem;
}
.faq-section h2 {
font-family: var(--font-display);
font-size: 1.6rem;
color: var(--color-primary-dark);
text-align: center;
margin-bottom: 2rem;
}
.faq-list {
max-width: 680px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.faq-item {
background: white;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.faq-item summary {
padding: 1rem 1.25rem;
font-weight: 500;
color: var(--color-text);
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.faq-item summary::-webkit-details-marker {
display: none;
}
.faq-item summary::after {
content: '+';
font-size: 1.25rem;
color: var(--color-primary);
font-weight: 300;
}
.faq-item[open] summary::after {
content: '';
}
.faq-item p {
padding: 0 1.25rem 1rem;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.faq-item p a {
color: var(--color-primary);
text-decoration: underline;
}
</style>
<script>
const form = document.getElementById('sms-signup-form');
const successMsg = document.getElementById('form-success');
const errorMsg = document.getElementById('form-error');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
const response = await fetch('/api/sms/register-owner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: formData.get('phone'),
name: formData.get('name'),
consent: formData.get('consent') === 'on',
}),
});
if (response.ok) {
form.style.display = 'none';
successMsg.style.display = 'block';
} else {
throw new Error('Registration failed');
}
} catch {
errorMsg.style.display = 'block';
}
});
}
</script>

290
src/pages/terms.astro Normal file
View File

@@ -0,0 +1,290 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext } = loadSiteData();
---
<BaseLayout title={`Terms of Use — ${siteContext.businessName}`} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">Terms and Conditions</Fragment>
<section class="legal-page">
<div class="container">
<h1>Terms of Use</h1>
<p class="effective-date">Effective Date: April 26, 2026</p>
<div class="legal-content">
<section class="legal-section">
<h2>1. Acceptance of Terms</h2>
<p>
By accessing or using the website and services of {siteContext.businessName} ("we," "us," or "our"),
you agree to be bound by these Terms of Use. If you do not agree to all of these terms, you may not
access or use our website or services. These terms apply to all visitors, users, and others who access
or use the service.
</p>
</section>
<section class="legal-section">
<h2>2. Changes to Terms</h2>
<p>
We reserve the right to modify or replace these Terms of Use at any time at our sole discretion.
Changes will be effective immediately upon posting to this page. Your continued use of the website
after any changes constitutes acceptance of the new terms. We encourage you to review these terms
periodically.
</p>
</section>
<section class="legal-section">
<h2>3. Use of the Website</h2>
<p>You agree to use our website only for lawful purposes and in a manner that does not infringe the rights of,
restrict, or inhibit anyone else's use and enjoyment of the website. Prohibited behavior includes:</p>
<ul>
<li>Using the website in any way that breaches any applicable local, national, or international law.</li>
<li>Transmitting or procuring the sending of any unsolicited or unauthorized advertising or promotional material.</li>
<li>Knowingly transmitting any data or sending any material that contains viruses, malware, or other harmful code.</li>
<li>Attempting to gain unauthorized access to our website, server, or any related systems.</li>
<li>Engaging in any activity that disrupts or interferes with the website's functionality.</li>
</ul>
</section>
<section class="legal-section">
<h2>4. Intellectual Property</h2>
<p>
All content on this website, including but not limited to text, graphics, logos, images, audio clips,
digital downloads, data compilations, and software, is the property of {siteContext.businessName}
or its content suppliers and is protected by copyright, trademark, and other intellectual property laws.
</p>
<p>You may not:</p>
<ul>
<li>Reproduce, duplicate, copy, sell, resell, or exploit any portion of the website without express written permission.</li>
<li>Use our trademarks, service marks, or logos without prior written consent.</li>
<li>Modify, adapt, translate, or create derivative works based on the website or its content.</li>
</ul>
</section>
<section class="legal-section">
<h2>5. User Contributions</h2>
<p>
If you submit any content to us — including feedback, suggestions, reviews, or other materials — you grant us
a non-exclusive, royalty-free, perpetual, irrevocable, and fully sublicensable right to use, reproduce,
modify, adapt, publish, translate, create derivative works from, distribute, and display such content
throughout the world in any media.
</p>
<p>You represent and warrant that:</p>
<ul>
<li>You own or have the necessary rights to the content you submit.</li>
<li>The content does not violate the rights of any third party, including copyright, privacy, or publicity rights.</li>
<li>The content is not unlawful, defamatory, obscene, or otherwise objectionable.</li>
</ul>
</section>
<section class="legal-section">
<h2>6. Text Message Service (SMS)</h2>
<p>
By subscribing to our text message updates, you agree to the following terms in addition to these Terms of Use:
</p>
<ul>
<li><strong>Eligibility:</strong> You must be at least 18 years old or have parental consent to subscribe.</li>
<li><strong>Consent:</strong> You provide express written consent to receive automated marketing and informational text messages.</li>
<li><strong>Frequency:</strong> Message frequency varies, typically 24 messages per month.</li>
<li><strong>Rates:</strong> Message and data rates may apply based on your wireless carrier plan.</li>
<li><strong>Opt-Out:</strong> You may cancel at any time by replying <strong>STOP</strong> to any message.</li>
<li><strong>Help:</strong> Reply <strong>HELP</strong> for assistance or contact us directly.</li>
</ul>
<p>
We are not liable for delayed or undelivered messages. Delivery is subject to your wireless carrier's
service and network availability. We reserve the right to discontinue the SMS service at any time.
</p>
</section>
<section class="legal-section">
<h2>7. Third-Party Links and Services</h2>
<p>
Our website may contain links to third-party websites or services that are not owned or controlled by
{siteContext.businessName}. We have no control over and assume no responsibility for the content,
privacy policies, or practices of any third-party websites or services. You acknowledge and agree that we
shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to
be caused by or in connection with the use of or reliance on any such content, goods, or services
available on or through any such websites or services.
</p>
</section>
<section class="legal-section">
<h2>8. Disclaimer of Warranties</h2>
<p>
Our website and services are provided on an <strong>"as is" and "as available"</strong> basis.
We make no warranties, expressed or implied, regarding the operation of the website, the accuracy,
completeness, or reliability of any content, or that the website will be uninterrupted, timely, secure,
or error-free.
</p>
<p>To the fullest extent permitted by applicable law, we disclaim all warranties, express or implied,
including but not limited to implied warranties of merchantability, fitness for a particular purpose,
and non-infringement.</p>
</section>
<section class="legal-section">
<h2>9. Limitation of Liability</h2>
<p>
To the fullest extent permitted by applicable law, {siteContext.businessName} and its officers,
directors, employees, agents, and affiliates shall not be liable for any indirect, incidental, special,
consequential, or punitive damages, including but not limited to loss of profits, data, use, goodwill,
or other intangible losses, resulting from:
</p>
<ul>
<li>Your access to or use of, or inability to access or use, the website.</li>
<li>Any conduct or content of any third party on the website.</li>
<li>Any content obtained from the website.</li>
<li>Unauthorized access, use, or alteration of your transmissions or content.</li>
</ul>
</section>
<section class="legal-section">
<h2>10. Indemnification</h2>
<p>
You agree to defend, indemnify, and hold harmless {siteContext.businessName} and its affiliates,
officers, directors, employees, and agents from and against any and all claims, damages, obligations,
losses, liabilities, costs, or debt, and expenses (including but not limited to attorney's fees)
arising from your use of and access to the website, your violation of any term of these Terms of Use,
or your violation of any third-party right, including without limitation any copyright, property,
or privacy right.
</p>
</section>
<section class="legal-section">
<h2>11. Termination</h2>
<p>
We may terminate or suspend your access to the website immediately, without prior notice or liability,
for any reason whatsoever, including without limitation if you breach these Terms of Use.
Upon termination, your right to use the website will immediately cease. All provisions of these terms
which by their nature should survive termination shall survive, including ownership provisions,
warranty disclaimers, indemnity, and limitations of liability.
</p>
</section>
<section class="legal-section">
<h2>12. Governing Law</h2>
<p>
These Terms of Use shall be governed by and construed in accordance with the laws of the State of Texas,
without regard to its conflict of law provisions. Any dispute arising from or relating to these terms
shall be subject to the exclusive jurisdiction of the courts located in Houston, Texas.
</p>
</section>
<section class="legal-section">
<h2>13. Dispute Resolution</h2>
<p>
Any dispute, controversy, or claim arising out of or relating to these Terms of Use, including the
formation, interpretation, breach, termination, or validity thereof, shall first be attempted to be
resolved through good-faith negotiation. If the dispute cannot be resolved through negotiation within
thirty (30) days, either party may pursue legal remedies in accordance with the Governing Law section above.
</p>
</section>
<section class="legal-section">
<h2>14. Severability</h2>
<p>
If any provision of these Terms of Use is found to be invalid, illegal, or unenforceable by a court of
competent jurisdiction, such provision shall be severed from these terms, and the remaining provisions
shall continue in full force and effect.
</p>
</section>
<section class="legal-section">
<h2>15. Entire Agreement</h2>
<p>
These Terms of Use, together with our <a href="/privacy-policy">Privacy Policy</a>, constitute the
entire agreement between you and {siteContext.businessName} regarding the use of our website and services,
superseding any prior agreements, communications, and proposals, whether oral or written.
</p>
</section>
<section class="legal-section">
<h2>16. Contact Us</h2>
<p>
If you have any questions about these Terms of Use, please contact us:
</p>
<ul class="contact-list">
{siteContext.contactEmail && <li><strong>Email:</strong> <a href={`mailto:${siteContext.contactEmail}`}>{siteContext.contactEmail}</a></li>}
{siteContext.contactPhone && <li><strong>Phone:</strong> {siteContext.contactPhone}</li>}
{siteContext.address && <li><strong>Address:</strong> {siteContext.address}</li>}
</ul>
</section>
</div>
</div>
</section>
<Fragment slot="footer">
&copy; {new Date().getFullYear()} {siteContext.businessName} &middot;
<a href="/privacy-policy">Privacy Policy</a> &middot;
<a href="/terms">Terms of Use</a>
</Fragment>
</BaseLayout>
<style>
.legal-page {
padding: 3rem 1.5rem 4rem;
}
.legal-page h1 {
font-family: var(--font-display);
font-size: 2rem;
color: var(--color-primary-dark);
margin-bottom: 0.5rem;
}
.effective-date {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 2rem;
font-style: italic;
}
.legal-content {
max-width: 720px;
margin: 0 auto;
}
.legal-section {
margin-bottom: 2rem;
}
.legal-section h2 {
font-family: var(--font-display);
font-size: 1.2rem;
color: var(--color-primary-dark);
margin-bottom: 0.75rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--color-border);
}
.legal-section p {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 0.75rem;
}
.legal-section ul {
margin-left: 1.25rem;
margin-bottom: 0.75rem;
}
.legal-section li {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 0.4rem;
}
.legal-section li strong {
color: var(--color-primary-dark);
}
.contact-list {
list-style: none;
margin-left: 0;
padding: 0.75rem;
background: color-mix(in srgb, var(--color-primary), white 95%);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.contact-list li {
margin-bottom: 0.35rem;
}
.contact-list a {
color: var(--color-primary);
text-decoration: underline;
}
</style>

132
src/styles/global.css Normal file
View File

@@ -0,0 +1,132 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/figtree";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Figtree Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}