Compare commits
11 Commits
c61f3acae9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca12a1ed1 | ||
|
|
250b0d4aa3 | ||
|
|
3cb0cbe088 | ||
|
|
c17ce052c1 | ||
|
|
68ecaec76c | ||
|
|
46247b7733 | ||
|
|
4e014fa648 | ||
|
|
36bce5a908 | ||
|
|
36fadf710d | ||
|
|
233fb6d003 | ||
|
|
fdf6124fa1 |
20
.env.example
20
.env.example
@@ -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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"css.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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
32
components.json
Normal 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
4836
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
68
server/src/live-reload.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,9 +159,26 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
|
||||
|
||||
log.info({ event: 'proposal.created', proposalId, path: repoRelativePath }, 'Proposal created');
|
||||
|
||||
// Step 6: Notify user
|
||||
if (job.smsReplyMeta) {
|
||||
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.PROPOSAL_SUMMARY(summary, proposalId));
|
||||
// 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) {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!`,
|
||||
|
||||
|
||||
65
shared/src/components/ui/button.tsx
Normal file
65
shared/src/components/ui/button.tsx
Normal 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
6
shared/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
245
src/pages/privacy-policy.astro
Normal file
245
src/pages/privacy-policy.astro
Normal 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 2–4 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">
|
||||
© {new Date().getFullYear()} {siteContext.businessName} ·
|
||||
<a href="/privacy-policy">Privacy Policy</a> ·
|
||||
<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>
|
||||
478
src/pages/sms-onboarding.astro
Normal file
478
src/pages/sms-onboarding.astro
Normal 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 8am–6pm"</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">
|
||||
© {new Date().getFullYear()} {siteContext.businessName} ·
|
||||
<a href="/privacy-policy">Privacy Policy</a> ·
|
||||
<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
290
src/pages/terms.astro
Normal 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 2–4 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">
|
||||
© {new Date().getFullYear()} {siteContext.businessName} ·
|
||||
<a href="/privacy-policy">Privacy Policy</a> ·
|
||||
<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
132
src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user