Prepare Rocky Mountain for Coolify launch

This commit is contained in:
Codex 2026-03-25 15:46:26 -06:00
parent 46d973904b
commit b63ca581f5
23 changed files with 2766 additions and 620 deletions

38
COOLIFY_LAUNCH.md Normal file
View file

@ -0,0 +1,38 @@
# Rocky Mountain Coolify Launch
## Staging
- Coolify project: `Rocky Mountain Vending`
- Environment UUID: `ew8k8og0gw48swck4ckk84kk`
- Forgejo repo: `https://git.abundancepartners.app/matt/Rocky_Mountain_Vending.git`
- Branch: `main`
- Build pack: `dockerfile`
- Dockerfile: `/Dockerfile`
- Port: `3001`
- Staging host: `rmv.rockymountainvending.com`
- DNS: `A rmv -> 85.239.237.247` (`DNS only`, `TTL Auto`)
## Required App Env
- `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com`
- `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com`
- `CONVEX_URL`
- `CONVEX_DEPLOY_ON_BUILD=false`
- `CONVEX_SELF_HOSTED_URL`
- `CONVEX_SELF_HOSTED_ADMIN_KEY`
- `CONVEX_TENANT_SLUG=rocky_mountain_vending`
- `CONVEX_TENANT_NAME=Rocky Mountain Vending`
- `USESEND_API_KEY`
- `USESEND_BASE_URL`
- `USESEND_FROM_EMAIL`
- `CONTACT_FORM_TO_EMAIL`
- `GHL_API_TOKEN`
- `GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG`
- `ADMIN_UI_ENABLED=false`
- `ADMIN_API_TOKEN`
- `LIVEKIT_URL`
- `LIVEKIT_API_KEY`
- `LIVEKIT_API_SECRET`
## Email Notes
- The app prefers Usesend when `USESEND_API_KEY` is present.
- If Usesend is not ready yet, the lead pipeline can fall back to AWS SES using `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`.
- Keep all email secrets in Coolify-managed env vars.

View file

@ -1,50 +1,25 @@
# Multi-stage Dockerfile for Next.js frontend
# Stage 1: Install dependencies and build the application
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY pnpm-lock.yaml ./ RUN npm ci
# Install pnpm and dependencies
RUN npm install -g pnpm
RUN pnpm install --no-frozen-lockfile
# Copy source code
COPY . . COPY . .
RUN npm run build
# Build the application
RUN pnpm build
# Stage 2: Production image
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
# Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
# Create app directory
WORKDIR /app WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy built application from builder stage
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/. ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/. ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs USER nextjs
# Expose port
EXPOSE 3001 EXPOSE 3001
# Set environment variable for port
ENV PORT=3001 ENV PORT=3001
# Start the application
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -1,16 +1,14 @@
import { auth } from '@clerk/nextjs/server' import { redirect } from "next/navigation";
import { redirect } from 'next/navigation' import { isAdminUiEnabled } from "@/lib/server/admin-auth";
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const { userId } = await auth() if (!isAdminUiEnabled()) {
redirect("/");
if (!userId) {
redirect('/sign-in')
} }
return <>{children}</> return <>{children}</>;
} }

View file

@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
processLeadSubmission,
type ContactLeadPayload,
type RequestMachineLeadPayload,
} from "@/lib/server/contact-submission";
test("processLeadSubmission stores and syncs a contact lead", async () => {
const calls: string[] = [];
const payload: ContactLeadPayload = {
kind: "contact",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
phone: "(435) 555-1212",
company: "ACME",
message: "Need vending help for our office.",
source: "website",
page: "/contact",
timestamp: "2026-03-25T00:00:00.000Z",
url: "https://rmv.example/contact",
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true,
emailConfigured: true,
ghlConfigured: true,
tenantSlug: "rocky_mountain_vending",
tenantName: "Rocky Mountain Vending",
tenantDomains: ["rockymountainvending.com"],
ingest: async () => {
calls.push("ingest");
return {
inserted: true,
leadId: "lead_123",
idempotencyKey: "abc",
tenantId: "tenant_123",
};
},
updateLeadStatus: async () => {
calls.push("update");
return { ok: true };
},
sendEmail: async () => {
calls.push("email");
return {};
},
createContact: async () => {
calls.push("ghl");
return { contact: { id: "ghl_123" } };
},
logger: console,
});
assert.equal(result.status, 200);
assert.equal(result.body.success, true);
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]);
assert.equal(calls.filter((call) => call === "email").length, 2);
assert.ok(calls.includes("ingest"));
assert.ok(calls.includes("update"));
assert.ok(calls.includes("ghl"));
});
test("processLeadSubmission validates request-machine submissions", async () => {
const payload: RequestMachineLeadPayload = {
kind: "request-machine",
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
phone: "4352339668",
company: "Warehouse Co",
employeeCount: "0",
machineType: "snack",
machineCount: "2",
marketingConsent: true,
termsAgreement: true,
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: false,
emailConfigured: false,
ghlConfigured: false,
tenantSlug: "rocky_mountain_vending",
tenantName: "Rocky Mountain Vending",
tenantDomains: [],
ingest: async () => {
throw new Error("should not run");
},
updateLeadStatus: async () => {
throw new Error("should not run");
},
sendEmail: async () => {
throw new Error("should not run");
},
createContact: async () => {
throw new Error("should not run");
},
logger: console,
});
assert.equal(result.status, 400);
assert.equal(result.body.success, false);
assert.match(result.body.error || "", /Invalid number of employees/);
});
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
const payload: ContactLeadPayload = {
kind: "contact",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
phone: "(435) 555-1212",
message: "Need vending help for our office.",
};
const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true,
emailConfigured: false,
ghlConfigured: false,
tenantSlug: "rocky_mountain_vending",
tenantName: "Rocky Mountain Vending",
tenantDomains: [],
ingest: async () => ({
inserted: false,
leadId: "lead_123",
idempotencyKey: "abc",
tenantId: "tenant_123",
}),
updateLeadStatus: async () => ({ ok: true }),
sendEmail: async () => ({}),
createContact: async () => null,
logger: console,
});
assert.equal(result.status, 200);
assert.equal(result.body.success, true);
assert.equal(result.body.deduped, true);
assert.deepEqual(result.body.deliveredVia, ["convex"]);
});

View file

@ -1,67 +1,5 @@
import { NextRequest, NextResponse } from "next/server" import { handleLeadRequest } from "@/lib/server/contact-submission";
import { WebhookClient, ContactFormWebhookData } from "@/lib/webhook-client"
interface ContactFormData extends ContactFormWebhookData {} export async function POST(request: Request) {
return handleLeadRequest(request);
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const formData: ContactFormData = body
// Validate required fields
const requiredFields = ['firstName', 'lastName', 'email', 'phone', 'message']
const missingFields = requiredFields.filter(field => !formData[field])
if (missingFields.length > 0) {
return NextResponse.json(
{ error: `Missing required fields: ${missingFields.join(', ')}` },
{ status: 400 }
)
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
)
}
// Basic phone validation (remove all non-digit characters)
const phoneDigits = formData.phone.replace(/\D/g, '')
if (phoneDigits.length < 10) {
return NextResponse.json(
{ error: 'Invalid phone number' },
{ status: 400 }
)
}
// Submit to webhook
const webhookResult = await WebhookClient.submitContactForm(formData)
if (!webhookResult.success) {
console.error('Contact webhook submission failed:', webhookResult.error)
return NextResponse.json(
{ error: webhookResult.message },
{ status: 500 }
)
}
console.log('Successfully submitted contact form to GHL:', formData)
// Return success response
return NextResponse.json({
success: true,
message: webhookResult.message
})
} catch (error) {
console.error('Contact form submission error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
} }

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server' import { requireAdminToken } from '@/lib/server/admin-auth'
// Order types // Order types
interface OrderItem { interface OrderItem {
@ -42,10 +42,9 @@ function generateOrderId(): string {
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Check authentication - only authenticated users can view orders const authError = requireAdminToken(request)
const { userId } = await auth() if (authError) {
if (!userId) { return authError
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {
@ -95,6 +94,11 @@ export async function GET(request: NextRequest) {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try { try {
const body = await request.json() const body = await request.json()
const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient } from '@/lib/stripe/client'
import { requireAdminToken } from '@/lib/server/admin-auth'
import { import {
fetchAllProducts, fetchAllProducts,
fetchProductById, fetchProductById,
@ -10,10 +10,9 @@ import {
} from '@/lib/stripe/products' } from '@/lib/stripe/products'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
// Check authentication const authError = requireAdminToken(request)
const { userId } = await auth() if (authError) {
if (!userId) { return authError
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {
@ -71,10 +70,9 @@ export async function GET(request: NextRequest) {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// Check authentication const authError = requireAdminToken(request)
const { userId } = await auth() if (authError) {
if (!userId) { return authError
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {
@ -125,10 +123,9 @@ export async function POST(request: NextRequest) {
// Handle PUT and PATCH for bulk operations // Handle PUT and PATCH for bulk operations
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
// Check authentication const authError = requireAdminToken(request)
const { userId } = await auth() if (authError) {
if (!userId) { return authError
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {

View file

@ -1,100 +1,5 @@
import { NextRequest, NextResponse } from "next/server" import { handleLeadRequest } from "@/lib/server/contact-submission";
import { WebhookClient, RequestMachineFormWebhookData } from "@/lib/webhook-client"
interface RequestMachineFormData extends RequestMachineFormWebhookData {} export async function POST(request: Request) {
return handleLeadRequest(request, "request-machine");
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const formData: RequestMachineFormData = body
// Validate required fields
const requiredFields = ['firstName', 'lastName', 'email', 'phone', 'company', 'employeeCount', 'machineType', 'machineCount']
const missingFields = requiredFields.filter(field => !formData[field])
if (missingFields.length > 0) {
return NextResponse.json(
{ error: `Missing required fields: ${missingFields.join(', ')}` },
{ status: 400 }
)
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
)
}
// Basic phone validation (remove all non-digit characters)
const phoneDigits = formData.phone.replace(/\D/g, '')
if (phoneDigits.length < 10) {
return NextResponse.json(
{ error: 'Invalid phone number' },
{ status: 400 }
)
}
// Validate employee count
const employeeCount = parseInt(formData.employeeCount)
if (isNaN(employeeCount) || employeeCount < 1 || employeeCount > 10000) {
return NextResponse.json(
{ error: 'Invalid number of employees/people' },
{ status: 400 }
)
}
// Validate machine count
const machineCount = parseInt(formData.machineCount)
if (isNaN(machineCount) || machineCount < 1 || machineCount > 100) {
return NextResponse.json(
{ error: 'Invalid number of machines' },
{ status: 400 }
)
}
// Validate consent and agreement
if (!formData.marketingConsent) {
return NextResponse.json(
{ error: 'Marketing consent is required' },
{ status: 400 }
)
}
if (!formData.termsAgreement) {
return NextResponse.json(
{ error: 'Terms agreement is required' },
{ status: 400 }
)
}
// Submit to webhook
const webhookResult = await WebhookClient.submitRequestMachineForm(formData)
if (!webhookResult.success) {
console.error('Machine request webhook submission failed:', webhookResult.error)
return NextResponse.json(
{ error: webhookResult.message },
{ status: 500 }
)
}
console.log('Successfully submitted machine request form to GHL:', formData)
// Return success response
return NextResponse.json({
success: true,
message: webhookResult.message
})
} catch (error) {
console.error('Machine request form submission error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
} }

View file

@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { getStripeClient } from '@/lib/stripe/client' import { getStripeClient } from '@/lib/stripe/client'
import { requireAdminToken } from '@/lib/server/admin-auth'
export async function POST() { export async function POST(request: Request) {
// Check authentication - only authenticated users can test Stripe const authError = requireAdminToken(request)
const { userId } = await auth() if (authError) {
if (!userId) { return authError
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
try { try {

View file

@ -3,7 +3,6 @@ import type { Metadata } from "next"
import { Inter, Geist_Mono } from "next/font/google" import { Inter, Geist_Mono } from "next/font/google"
import Script from "next/script" import Script from "next/script"
import { Analytics } from "@vercel/analytics/next" import { Analytics } from "@vercel/analytics/next"
import { ClerkProvider } from "@clerk/nextjs"
import { Header } from "@/components/header" import { Header } from "@/components/header"
import { Footer } from "@/components/footer" import { Footer } from "@/components/footer"
import { StructuredData } from "@/components/structured-data" import { StructuredData } from "@/components/structured-data"
@ -114,7 +113,6 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<ClerkProvider>
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}> <html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
<head> <head>
{/* Resource hints for third-party domains */} {/* Resource hints for third-party domains */}
@ -150,6 +148,5 @@ export default function RootLayout({
/> />
</body> </body>
</html> </html>
</ClerkProvider>
) )
} }

View file

@ -1,9 +1,20 @@
import { SignIn } from '@clerk/nextjs' import { redirect } from "next/navigation";
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
export default function SignInPage() { export default function SignInPage() {
return ( if (!isAdminUiEnabled()) {
<div className="flex min-h-screen items-center justify-center bg-muted/30"> redirect("/");
<SignIn /> }
</div>
) return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-6">
<div className="max-w-md rounded-2xl border border-border bg-background p-8 text-center shadow-sm">
<h1 className="text-2xl font-semibold">Admin Sign-In</h1>
<p className="mt-3 text-sm text-muted-foreground">
Admin sign-in is not configured in this deployment. Enable the admin
UI and wire an auth provider before using this area.
</p>
</div>
</div>
);
} }

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { CheckCircle, AlertCircle } from "lucide-react" import { CheckCircle, AlertCircle } from "lucide-react"
@ -18,10 +18,16 @@ interface ContactFormData {
message: string message: string
source?: string source?: string
page?: string page?: string
confirmEmail?: string
}
interface SubmittedContactFormData extends ContactFormData {
timestamp: string
url: string
} }
interface ContactFormProps { interface ContactFormProps {
onSubmit?: (data: ContactFormData) => void onSubmit?: (data: SubmittedContactFormData) => void
className?: string className?: string
} }
@ -46,12 +52,11 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
const watchedValues = watch() const watchedValues = watch()
// Update page URL when component mounts useEffect(() => {
useState(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setValue("page", window.location.pathname) setValue("page", window.location.pathname)
} }
}) }, [setValue])
const onFormSubmit = async (data: ContactFormData) => { const onFormSubmit = async (data: ContactFormData) => {
setIsSubmitting(true) setIsSubmitting(true)
@ -59,7 +64,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
try { try {
// Add URL and timestamp // Add URL and timestamp
const formData = { const formData: SubmittedContactFormData = {
...data, ...data,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
url: window.location.href, url: window.location.href,
@ -124,9 +129,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
<input <input
id="confirm-email" id="confirm-email"
type="email" type="email"
{...register("confirmEmail", { {...register("confirmEmail")}
shouldTouch: true,
})}
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
/> />

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useEffect, useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { CheckCircle, AlertCircle } from "lucide-react" import { CheckCircle, AlertCircle } from "lucide-react"
import Link from "next/link" import Link from "next/link"
@ -25,10 +25,16 @@ interface RequestMachineFormData {
termsAgreement: boolean termsAgreement: boolean
source?: string source?: string
page?: string page?: string
confirmEmail?: string
}
interface SubmittedRequestMachineFormData extends RequestMachineFormData {
timestamp: string
url: string
} }
interface RequestMachineFormProps { interface RequestMachineFormProps {
onSubmit?: (data: RequestMachineFormData) => void onSubmit?: (data: SubmittedRequestMachineFormData) => void
className?: string className?: string
} }
@ -55,12 +61,11 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
const watchedValues = watch() const watchedValues = watch()
// Update page URL when component mounts useEffect(() => {
useState(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setValue("page", window.location.pathname) setValue("page", window.location.pathname)
} }
}) }, [setValue])
const onFormSubmit = async (data: RequestMachineFormData) => { const onFormSubmit = async (data: RequestMachineFormData) => {
setIsSubmitting(true) setIsSubmitting(true)
@ -68,7 +73,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
try { try {
// Add URL and timestamp // Add URL and timestamp
const formData = { const formData: SubmittedRequestMachineFormData = {
...data, ...data,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
url: window.location.href, url: window.location.href,
@ -133,9 +138,7 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
<input <input
id="confirm-email" id="confirm-email"
type="email" type="email"
{...register("confirmEmail", { {...register("confirmEmail")}
shouldTouch: true,
})}
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
/> />
@ -220,7 +223,6 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
error={errors.employeeCount?.message} error={errors.employeeCount?.message}
{...register("employeeCount", { {...register("employeeCount", {
required: "Number of people is required", required: "Number of people is required",
valueAsNumber: true,
validate: (value) => { validate: (value) => {
const num = parseInt(value) const num = parseInt(value)
return num >= 1 && num <= 10000 || "Please enter a valid number between 1 and 10,000" return num >= 1 && num <= 10000 || "Please enter a valid number between 1 and 10,000"
@ -416,7 +418,6 @@ export function RequestMachineForm({ onSubmit, className }: RequestMachineFormPr
error={errors.machineCount?.message} error={errors.machineCount?.message}
{...register("machineCount", { {...register("machineCount", {
required: "Number of machines is required", required: "Number of machines is required",
valueAsNumber: true,
validate: (value) => { validate: (value) => {
const num = parseInt(value) const num = parseInt(value)
return num >= 1 && num <= 100 || "Please enter a number between 1 and 100" return num >= 1 && num <= 100 || "Please enter a number between 1 and 100"

76
lib/convex.ts Normal file
View file

@ -0,0 +1,76 @@
export type LeadSyncStatus = "pending" | "sent" | "synced" | "failed" | "skipped";
export type IngestLeadInput = {
host: string;
tenantSlug?: string;
tenantName?: string;
tenantDomains?: string[];
source?: string;
idempotencyKey: string;
name: string;
email: string;
company?: string;
service?: string;
message: string;
};
export type IngestLeadResult = {
inserted: boolean;
leadId: string;
idempotencyKey: string;
tenantId: string;
};
function getConvexUrl() {
return (process.env.CONVEX_URL || process.env.NEXT_PUBLIC_CONVEX_URL || "").replace(
/\/+$/,
""
);
}
async function callConvex(path: string, args: Record<string, unknown>) {
const convexUrl = getConvexUrl();
if (!convexUrl) {
throw new Error("CONVEX_URL is not configured");
}
const endpoint = `${convexUrl}/api/mutation`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
path,
args,
format: "json",
}),
});
const body = await response.json().catch(() => null);
if (!response.ok || body?.status === "error") {
const message = body?.errorMessage || `Convex call failed (${response.status})`;
throw new Error(message);
}
return body?.value;
}
export function isConvexConfigured() {
return getConvexUrl().length > 0;
}
export async function ingestLead(input: IngestLeadInput): Promise<IngestLeadResult> {
const value = await callConvex("leads:ingestLead", input);
return value as IngestLeadResult;
}
export async function updateLeadSyncStatus(input: {
leadId: string;
usesendStatus?: LeadSyncStatus;
ghlStatus?: LeadSyncStatus;
error?: string;
}) {
return await callConvex("leads:updateLeadSyncStatus", input);
}

93
lib/email.ts Normal file
View file

@ -0,0 +1,93 @@
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { UseSend } from "usesend-js";
const apiKey = process.env.USESEND_API_KEY;
const baseUrl = process.env.USESEND_BASE_URL;
const awsAccessKey = process.env.AWS_ACCESS_KEY;
const awsSecretKey = process.env.AWS_SECRET_KEY;
const awsRegion = process.env.AWS_DEFAULT_REGION;
export const usesend = apiKey ? new UseSend(apiKey, baseUrl) : null;
const sesClient =
awsAccessKey && awsSecretKey && awsRegion
? new SESv2Client({
region: awsRegion,
credentials: {
accessKeyId: awsAccessKey,
secretAccessKey: awsSecretKey,
},
})
: null;
export const FROM_EMAIL =
process.env.USESEND_FROM_EMAIL || "info@rockymountainvending.com";
export const TO_EMAIL =
process.env.CONTACT_FORM_TO_EMAIL || "info@rockymountainvending.com";
function htmlToText(html: string) {
return html
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim();
}
export function isEmailConfigured() {
return Boolean(usesend || sesClient);
}
export async function sendTransactionalEmail({
to,
subject,
html,
replyTo,
}: {
to: string;
subject: string;
html: string;
replyTo?: string;
}) {
if (usesend) {
return usesend.emails.send({
from: FROM_EMAIL,
to,
subject,
html,
...(replyTo ? { replyTo } : {}),
});
}
if (sesClient) {
return sesClient.send(
new SendEmailCommand({
FromEmailAddress: FROM_EMAIL,
Destination: {
ToAddresses: [to],
},
ReplyToAddresses: replyTo ? [replyTo] : undefined,
Content: {
Simple: {
Subject: {
Data: subject,
},
Body: {
Html: {
Data: html,
},
Text: {
Data: htmlToText(html),
},
},
},
},
})
);
}
throw new Error("No email transport is configured");
}

58
lib/ghl.ts Normal file
View file

@ -0,0 +1,58 @@
const GHL_API_BASE = "https://services.leadconnectorhq.com";
const GHL_API_VERSION = "2021-07-28";
export async function createGHLContact(data: {
email: string;
firstName?: string;
lastName?: string;
phone?: string;
company?: string;
source?: string;
tags?: string[];
}) {
const locationId = process.env.GHL_LOCATION_ID;
const apiToken = process.env.GHL_API_TOKEN;
if (!locationId || !apiToken) {
console.warn("GHL credentials incomplete; skipping contact creation.");
return null;
}
const nameParts =
data.firstName || data.lastName
? {
first_name: data.firstName || "",
last_name: data.lastName || "",
}
: {};
const body: Record<string, unknown> = {
location_id: locationId,
email: data.email,
...nameParts,
...(data.phone && { phone: data.phone }),
...(data.company && { company_name: data.company }),
...(data.source && { source: data.source }),
...(data.tags?.length && { tags: data.tags }),
};
const res = await fetch(`${GHL_API_BASE}/contacts/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Version: GHL_API_VERSION,
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
console.error("GHL contact creation failed:", res.status, text);
return null;
}
return res.json();
}

View file

@ -1,3 +1,7 @@
const configuredWebsite =
(process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || "https://rockymountainvending.com")
.replace(/\/+$/, "");
/** /**
* Centralized SEO and Business Information Configuration * Centralized SEO and Business Information Configuration
* Used for NAP consistency, structured data, and SEO metadata * Used for NAP consistency, structured data, and SEO metadata
@ -12,7 +16,7 @@ export const businessConfig = {
phoneUrl: "tel:+14352339668", phoneUrl: "tel:+14352339668",
smsUrl: "sms:+14352339668", smsUrl: "sms:+14352339668",
email: "info@rockymountainvending.com", email: "info@rockymountainvending.com",
website: "https://rockymountainvending.com", website: configuredWebsite,
description: description:
"Rocky Mountain Vending provides high-quality vending machines, vending machine sales, and vending machine repair services to businesses and schools across Utah. We specialize in offering healthy snack and beverage options, making us a trusted partner for organizations looking to promote wellness.", "Rocky Mountain Vending provides high-quality vending machines, vending machine sales, and vending machine repair services to businesses and schools across Utah. We specialize in offering healthy snack and beverage options, making us a trusted partner for organizations looking to promote wellness.",
category: "Vending machine supplier", category: "Vending machine supplier",

32
lib/server/admin-auth.ts Normal file
View file

@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
function getProvidedToken(request: Request) {
const authHeader = request.headers.get("authorization") || "";
const bearerToken = authHeader.startsWith("Bearer ")
? authHeader.slice("Bearer ".length).trim()
: "";
return request.headers.get("x-admin-token") || bearerToken;
}
export function requireAdminToken(request: Request) {
const configuredToken = process.env.ADMIN_API_TOKEN;
if (!configuredToken) {
return NextResponse.json(
{ error: "Admin API is disabled." },
{ status: 503 }
);
}
const providedToken = getProvidedToken(request);
if (providedToken !== configuredToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
export function isAdminUiEnabled() {
return process.env.ADMIN_UI_ENABLED === "true";
}

View file

@ -0,0 +1,608 @@
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import {
ingestLead,
isConvexConfigured,
type LeadSyncStatus,
updateLeadSyncStatus,
} from "@/lib/convex";
import { isEmailConfigured, sendTransactionalEmail, TO_EMAIL } from "@/lib/email";
import { createGHLContact } from "@/lib/ghl";
export type LeadKind = "contact" | "request-machine";
type StorageStatus = "stored" | "deduped" | "skipped" | "failed";
type DeliveryChannel = "convex" | "email" | "ghl";
type SharedLeadFields = {
firstName: string;
lastName: string;
email: string;
phone: string;
source?: string;
page?: string;
timestamp?: string;
url?: string;
confirmEmail?: string;
};
export type ContactLeadPayload = SharedLeadFields & {
kind: "contact";
company?: string;
message: string;
};
export type RequestMachineLeadPayload = SharedLeadFields & {
kind: "request-machine";
company: string;
employeeCount: string;
machineType: string;
machineCount: string;
message?: string;
marketingConsent: boolean;
termsAgreement: boolean;
};
export type LeadPayload = ContactLeadPayload | RequestMachineLeadPayload;
export type LeadSubmissionResponse = {
success: boolean;
message: string;
deduped?: boolean;
leadStored?: boolean;
storageConfigured?: boolean;
storageStatus?: StorageStatus;
idempotencyKey?: string;
deliveredVia?: DeliveryChannel[];
sync?: {
usesendStatus: LeadSyncStatus;
ghlStatus: LeadSyncStatus;
};
warnings?: string[];
error?: string;
};
type LeadSubmissionResult = {
status: number;
body: LeadSubmissionResponse;
};
export type LeadSubmissionDeps = {
storageConfigured: boolean;
emailConfigured: boolean;
ghlConfigured: boolean;
ingest: typeof ingestLead;
updateLeadStatus: typeof updateLeadSyncStatus;
sendEmail: (to: string, subject: string, html: string, replyTo?: string) => Promise<unknown>;
createContact: typeof createGHLContact;
logger: Pick<typeof console, "warn" | "error">;
tenantSlug: string;
tenantName: string;
tenantDomains: string[];
};
const DEFAULT_TENANT_SLUG =
process.env.CONVEX_TENANT_SLUG || "rocky_mountain_vending";
const DEFAULT_TENANT_NAME =
process.env.CONVEX_TENANT_NAME || "Rocky Mountain Vending";
const DEFAULT_SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://rockymountainvending.com";
function normalizeHost(input: string) {
return input
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "");
}
function parseHostFromUrl(url?: string) {
if (!url) {
return "";
}
try {
return normalizeHost(new URL(url).host);
} catch {
return normalizeHost(url);
}
}
function getConfiguredTenantDomains() {
const siteDomain = normalizeHost(process.env.NEXT_PUBLIC_SITE_DOMAIN || "");
const siteUrlHost = parseHostFromUrl(process.env.NEXT_PUBLIC_SITE_URL);
const fallbackHost = parseHostFromUrl(DEFAULT_SITE_URL);
const domains = [siteDomain, siteUrlHost, fallbackHost].filter(Boolean);
if (siteDomain && !siteDomain.startsWith("www.")) {
domains.push(`www.${siteDomain}`);
}
return Array.from(new Set(domains.map(normalizeHost).filter(Boolean)));
}
function defaultDeps(): LeadSubmissionDeps {
return {
storageConfigured: isConvexConfigured(),
emailConfigured: isEmailConfigured(),
ghlConfigured: Boolean(process.env.GHL_API_TOKEN && process.env.GHL_LOCATION_ID),
ingest: ingestLead,
updateLeadStatus: updateLeadSyncStatus,
sendEmail: (to, subject, html, replyTo) =>
sendTransactionalEmail({ to, subject, html, replyTo }),
createContact: createGHLContact,
logger: console,
tenantSlug: DEFAULT_TENANT_SLUG,
tenantName: DEFAULT_TENANT_NAME,
tenantDomains: getConfiguredTenantDomains(),
};
}
function escapeHtml(input: string) {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getSourceHost(request: Request) {
const forwardedHost = request.headers.get("x-forwarded-host");
const host = request.headers.get("host");
return normalizeHost(forwardedHost || host || "rockymountainvending.com");
}
function isRequestMachinePayload(body: Record<string, unknown>) {
return (
body.kind === "request-machine" ||
"employeeCount" in body ||
"machineType" in body ||
"machineCount" in body
);
}
function coerceString(input: unknown) {
return typeof input === "string" ? input.trim() : "";
}
function coerceBoolean(input: unknown) {
return input === true || input === "true" || input === "on";
}
function normalizeLeadPayload(
body: Record<string, unknown>,
sourceHost: string,
kindOverride?: LeadKind
): LeadPayload {
const kind = kindOverride || (isRequestMachinePayload(body) ? "request-machine" : "contact");
const shared = {
firstName: coerceString(body.firstName),
lastName: coerceString(body.lastName),
email: coerceString(body.email).toLowerCase(),
phone: coerceString(body.phone),
source: coerceString(body.source) || "website",
page: coerceString(body.page),
timestamp: coerceString(body.timestamp) || new Date().toISOString(),
url: coerceString(body.url) || `https://${sourceHost}`,
confirmEmail: coerceString(body.confirmEmail),
};
if (kind === "request-machine") {
return {
kind,
...shared,
company: coerceString(body.company),
employeeCount: String(body.employeeCount ?? "").trim(),
machineType: coerceString(body.machineType),
machineCount: String(body.machineCount ?? "").trim(),
message: coerceString(body.message),
marketingConsent: coerceBoolean(body.marketingConsent),
termsAgreement: coerceBoolean(body.termsAgreement),
};
}
return {
kind,
...shared,
company: coerceString(body.company),
message: coerceString(body.message),
};
}
function validateLeadPayload(payload: LeadPayload) {
const requiredFields = [
["firstName", payload.firstName],
["lastName", payload.lastName],
["email", payload.email],
["phone", payload.phone],
];
if (payload.kind === "contact") {
requiredFields.push(["message", payload.message]);
} else {
requiredFields.push(
["company", payload.company],
["employeeCount", payload.employeeCount],
["machineType", payload.machineType],
["machineCount", payload.machineCount]
);
}
const missingFields = requiredFields
.filter(([, value]) => !String(value).trim())
.map(([field]) => field);
if (missingFields.length > 0) {
return `Missing required fields: ${missingFields.join(", ")}`;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(payload.email)) {
return "Invalid email address";
}
const phoneDigits = payload.phone.replace(/\D/g, "");
if (phoneDigits.length < 10 || phoneDigits.length > 15) {
return "Invalid phone number";
}
if (payload.kind === "contact") {
if (payload.message.length < 10) {
return "Message must be at least 10 characters";
}
return null;
}
const employeeCount = Number(payload.employeeCount);
if (!Number.isFinite(employeeCount) || employeeCount < 1 || employeeCount > 10000) {
return "Invalid number of employees/people";
}
const machineCount = Number(payload.machineCount);
if (!Number.isFinite(machineCount) || machineCount < 1 || machineCount > 100) {
return "Invalid number of machines";
}
if (!payload.marketingConsent) {
return "Marketing consent is required";
}
if (!payload.termsAgreement) {
return "Terms agreement is required";
}
return null;
}
function buildLeadMessage(payload: LeadPayload) {
if (payload.kind === "contact") {
return payload.message;
}
return [
payload.message ? `Additional information: ${payload.message}` : "",
`Company: ${payload.company}`,
`People/employees: ${payload.employeeCount}`,
`Machine type: ${payload.machineType}`,
`Machine count: ${payload.machineCount}`,
`Marketing consent: ${payload.marketingConsent ? "yes" : "no"}`,
`Terms agreement: ${payload.termsAgreement ? "yes" : "no"}`,
`Source: ${payload.source || "website"}`,
`Page: ${payload.page || ""}`,
`URL: ${payload.url || ""}`,
]
.filter(Boolean)
.join("\n");
}
function buildIdempotencyKey(payload: LeadPayload, sourceHost: string) {
const identity = [
payload.kind,
sourceHost,
payload.firstName,
payload.lastName,
payload.email,
payload.phone,
payload.company || "",
buildLeadMessage(payload),
]
.map((value) => value.trim().toLowerCase())
.join("|");
return createHash("sha256").update(identity).digest("hex");
}
function buildEmailSubject(payload: LeadPayload) {
const fullName = `${payload.firstName} ${payload.lastName}`.trim();
if (payload.kind === "request-machine") {
return `[Rocky Mountain Vending] Machine request from ${fullName}`;
}
return `[Rocky Mountain Vending] Contact from ${fullName}`;
}
function buildAdminEmailHtml(payload: LeadPayload) {
const common = `
<p><strong>Name:</strong> ${escapeHtml(
`${payload.firstName} ${payload.lastName}`.trim()
)}</p>
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
<p><strong>Phone:</strong> ${escapeHtml(payload.phone)}</p>
${payload.company ? `<p><strong>Company:</strong> ${escapeHtml(payload.company)}</p>` : ""}
<p><strong>Source:</strong> ${escapeHtml(payload.source || "website")}</p>
${payload.page ? `<p><strong>Page:</strong> ${escapeHtml(payload.page)}</p>` : ""}
${payload.url ? `<p><strong>URL:</strong> ${escapeHtml(payload.url)}</p>` : ""}
`;
if (payload.kind === "contact") {
return `
${common}
<p><strong>Message:</strong></p>
<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(
payload.message
)}</pre>
`;
}
return `
${common}
<p><strong>Employees/people:</strong> ${escapeHtml(payload.employeeCount)}</p>
<p><strong>Machine type:</strong> ${escapeHtml(payload.machineType)}</p>
<p><strong>Machine count:</strong> ${escapeHtml(payload.machineCount)}</p>
<p><strong>Marketing consent:</strong> ${payload.marketingConsent ? "Yes" : "No"}</p>
<p><strong>Terms agreement:</strong> ${payload.termsAgreement ? "Yes" : "No"}</p>
${
payload.message
? `<p><strong>Additional information:</strong></p><pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(
payload.message
)}</pre>`
: ""
}
`;
}
function buildConfirmationEmailHtml(payload: LeadPayload) {
const firstName = escapeHtml(payload.firstName);
const siteUrl = escapeHtml(DEFAULT_SITE_URL);
const detail =
payload.kind === "request-machine"
? "We received your free vending machine consultation request and will contact you within 24 hours."
: "We received your message and will get back to you within 24 hours.";
return `<p>Hi ${firstName},</p>
<p>${detail}</p>
<p>If you need anything else sooner, you can reply to this email or visit <a href="${siteUrl}">${siteUrl}</a>.</p>
<p>Rocky Mountain Vending</p>`;
}
function buildGhlTags(payload: LeadPayload) {
return [
"website-lead",
payload.kind === "request-machine" ? "machine-request" : "contact-form",
];
}
export async function processLeadSubmission(
payload: LeadPayload,
sourceHost: string,
deps: LeadSubmissionDeps = defaultDeps()
): Promise<LeadSubmissionResult> {
if (payload.confirmEmail) {
return {
status: 200,
body: {
success: true,
message: "Thanks! We will be in touch shortly.",
storageConfigured: deps.storageConfigured,
storageStatus: "skipped",
sync: {
usesendStatus: "skipped",
ghlStatus: "skipped",
},
},
};
}
const validationError = validateLeadPayload(payload);
if (validationError) {
return {
status: 400,
body: {
success: false,
message: validationError,
error: validationError,
},
};
}
const hasConfiguredSink =
deps.storageConfigured || deps.emailConfigured || deps.ghlConfigured;
if (!hasConfiguredSink) {
deps.logger.error("No lead sinks are configured for Rocky Mountain Vending.");
return {
status: 503,
body: {
success: false,
message: "Lead intake is not configured. Please try again later.",
error: "Lead intake is not configured. Please try again later.",
},
};
}
const idempotencyKey = buildIdempotencyKey(payload, sourceHost);
const fullName = `${payload.firstName} ${payload.lastName}`.trim();
const leadMessage = buildLeadMessage(payload);
const warnings: string[] = [];
let storageStatus: StorageStatus = deps.storageConfigured ? "failed" : "skipped";
let leadId: string | undefined;
let usesendStatus: LeadSyncStatus = "skipped";
let ghlStatus: LeadSyncStatus = "skipped";
if (deps.storageConfigured) {
try {
const ingestResult = await deps.ingest({
host: sourceHost,
tenantSlug: deps.tenantSlug,
tenantName: deps.tenantName,
tenantDomains: Array.from(new Set([...deps.tenantDomains, sourceHost])),
source:
payload.kind === "request-machine"
? "rocky_machine_request_form"
: "rocky_contact_form",
idempotencyKey,
name: fullName,
email: payload.email,
company: payload.company || undefined,
service: payload.kind === "request-machine" ? "machine-request" : "contact",
message: leadMessage,
});
leadId = ingestResult.leadId;
if (!ingestResult.inserted) {
storageStatus = "deduped";
return {
status: 200,
body: {
success: true,
message: "Thanks! We already received this submission.",
deduped: true,
leadStored: true,
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
deliveredVia: ["convex"],
sync: { usesendStatus, ghlStatus },
},
};
}
storageStatus = "stored";
} catch (error) {
deps.logger.error("Failed to store Rocky lead in Convex:", error);
storageStatus = "failed";
warnings.push("Lead storage failed.");
}
}
if (deps.emailConfigured) {
const emailResults = await Promise.allSettled([
deps.sendEmail(TO_EMAIL, buildEmailSubject(payload), buildAdminEmailHtml(payload), payload.email),
deps.sendEmail(
payload.email,
"Thanks for contacting Rocky Mountain Vending",
buildConfirmationEmailHtml(payload)
),
]);
const failures = emailResults.filter((result) => result.status === "rejected");
usesendStatus = failures.length === 0 ? "sent" : "failed";
if (failures.length > 0) {
warnings.push("Usesend delivery failed for one or more recipients.");
}
} else {
deps.logger.warn("No email transport is configured; skipping Rocky email sync.");
}
if (deps.ghlConfigured) {
try {
const ghlResult = await deps.createContact({
email: payload.email,
firstName: payload.firstName,
lastName: payload.lastName,
phone: payload.phone,
company: payload.company || undefined,
source: `${sourceHost}:${payload.kind}`,
tags: buildGhlTags(payload),
});
ghlStatus = ghlResult ? "synced" : "failed";
if (!ghlResult) {
warnings.push("GHL contact creation returned no record.");
}
} catch (error) {
ghlStatus = "failed";
warnings.push(`GHL sync error: ${String(error)}`);
}
} else {
deps.logger.warn("GHL credentials incomplete; skipping Rocky GHL sync.");
}
if (leadId) {
try {
await deps.updateLeadStatus({
leadId,
usesendStatus,
ghlStatus,
error: warnings.length > 0 ? warnings.join(" | ") : undefined,
});
} catch (error) {
deps.logger.error("Failed to update Rocky lead sync status:", error);
}
}
const deliveredVia: DeliveryChannel[] = [];
if (storageStatus === "stored") {
deliveredVia.push("convex");
}
if (usesendStatus === "sent") {
deliveredVia.push("email");
}
if (ghlStatus === "synced") {
deliveredVia.push("ghl");
}
if (deliveredVia.length === 0) {
return {
status: 500,
body: {
success: false,
message: "We could not process your request right now. Please try again shortly.",
error: "We could not process your request right now. Please try again shortly.",
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
sync: { usesendStatus, ghlStatus },
warnings,
},
};
}
return {
status: 200,
body: {
success: true,
message:
payload.kind === "request-machine"
? "Your consultation request was submitted successfully."
: "Your message was submitted successfully.",
leadStored: storageStatus === "stored",
storageConfigured: deps.storageConfigured,
storageStatus,
idempotencyKey,
deliveredVia,
sync: { usesendStatus, ghlStatus },
...(warnings.length > 0 ? { warnings } : {}),
},
};
}
export async function handleLeadRequest(request: Request, kindOverride?: LeadKind) {
try {
const body = (await request.json()) as Record<string, unknown>;
const sourceHost = getSourceHost(request);
const payload = normalizeLeadPayload(body, sourceHost, kindOverride);
const result = await processLeadSubmission(payload, sourceHost);
return NextResponse.json(result.body, { status: result.status });
} catch (error) {
console.error("Lead API error:", error);
return NextResponse.json(
{
success: false,
message: "Internal server error.",
error: "Internal server error.",
},
{ status: 500 }
);
}
}

View file

@ -36,121 +36,64 @@ export interface RequestMachineFormWebhookData {
} }
export class WebhookClient { export class WebhookClient {
private static async sendToWebhook( private static async sendToApi(
webhookUrl: string, payload: Record<string, unknown>,
payload: any, kind: "contact" | "request-machine"
webhookType: 'contact' | 'request-machine'
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
try { try {
// Send initial request const response = await fetch("/api/contact", {
const response = await fetch(webhookUrl, { method: "POST",
method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
if (response.ok) { const body = await response.json().catch(() => null);
return {
success: true,
message: `${webhookType} form submitted successfully`
}
}
// Log the error and retry once if (!response.ok) {
console.error(`Webhook failed for ${webhookType}:`, response.status, response.statusText)
const retryResponse = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (retryResponse.ok) {
return {
success: true,
message: `${webhookType} form submitted successfully (retry)`
}
}
console.error(`Webhook retry failed for ${webhookType}:`, retryResponse.status, retryResponse.statusText)
return { return {
success: false, success: false,
message: 'Failed to submit to webhook after retry', message: body?.message || body?.error || "Failed to submit form",
error: `${retryResponse.status} ${retryResponse.statusText}` error: body?.error || `Request failed with status ${response.status}`,
};
} }
return {
success: body?.success !== false,
message:
body?.message ||
(kind === "request-machine"
? "Your consultation request was submitted successfully."
: "Your message was submitted successfully."),
};
} catch (error) { } catch (error) {
console.error(`Webhook error for ${webhookType}:`, error) console.error(`Lead API error for ${kind}:`, error)
return { return {
success: false, success: false,
message: 'Failed to submit to webhook', message: "Failed to submit form",
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
} }
} }
} }
static async submitContactForm(data: ContactFormWebhookData): Promise<WebhookResponse> { static async submitContactForm(data: ContactFormWebhookData): Promise<WebhookResponse> {
const webhookUrl = process.env.GHL_CONTACT_WEBHOOK_URL return this.sendToApi(
{
if (!webhookUrl) { kind: "contact",
return { ...data,
success: false, },
message: 'Webhook URL not configured', "contact"
error: 'GHL_CONTACT_WEBHOOK_URL environment variable not set' )
}
}
const ghlPayload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
company: data.company || '',
custom1: data.message,
custom2: data.source || 'website',
custom3: data.page || window.location.pathname,
custom4: data.timestamp,
custom5: data.url,
}
return this.sendToWebhook(webhookUrl, ghlPayload, 'contact')
} }
static async submitRequestMachineForm(data: RequestMachineFormWebhookData): Promise<WebhookResponse> { static async submitRequestMachineForm(data: RequestMachineFormWebhookData): Promise<WebhookResponse> {
const webhookUrl = process.env.GHL_REQUEST_MACHINE_WEBHOOK_URL return this.sendToApi(
{
if (!webhookUrl) { kind: "request-machine",
return { ...data,
success: false, },
message: 'Webhook URL not configured', "request-machine"
error: 'GHL_REQUEST_MACHINE_WEBHOOK_URL environment variable not set' )
}
}
const ghlPayload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
company: data.company,
custom1: data.employeeCount,
custom2: data.machineType,
custom3: data.machineCount,
custom4: data.message || '',
custom5: data.source || 'website',
custom6: data.page || window.location.pathname,
custom7: data.timestamp,
custom8: data.url,
custom9: data.marketingConsent ? 'Consented' : 'Not Consented',
custom10: data.termsAgreement ? 'Agreed' : 'Not Agreed',
tags: ['Machine Request'],
custom11: 'Free Consultation Request',
}
return this.sendToWebhook(webhookUrl, ghlPayload, 'request-machine')
} }
} }

View file

@ -1,6 +1,9 @@
import { clerkMiddleware } from '@clerk/nextjs/server' import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export default clerkMiddleware() export function middleware(_request: NextRequest) {
return NextResponse.next();
}
export const config = { export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],

1778
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{ {
"name": "my-v0-project", "name": "rocky-mountain-vending",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
@ -8,6 +8,7 @@
"dev": "next dev", "dev": "next dev",
"lint": "eslint .", "lint": "eslint .",
"start": "next start", "start": "next start",
"test": "tsx --test app/api/contact/route.test.ts",
"lighthouse:dev": "node scripts/lighthouse-test.js --dev", "lighthouse:dev": "node scripts/lighthouse-test.js --dev",
"lighthouse:build": "node scripts/lighthouse-test.js", "lighthouse:build": "node scripts/lighthouse-test.js",
"lighthouse:ci": "lighthouse-ci autorun", "lighthouse:ci": "lighthouse-ci autorun",
@ -23,8 +24,8 @@
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive" "seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-sesv2": "^3.888.0",
"@aws-sdk/client-s3": "^3.0.0", "@aws-sdk/client-s3": "^3.0.0",
"@clerk/nextjs": "^6.37.3",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-alert-dialog": "1.1.4",
@ -75,6 +76,7 @@
"stripe": "^17.0.0", "stripe": "^17.0.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"usesend-js": "^1.6.3",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"zod": "3.25.76" "zod": "3.25.76"
}, },
@ -88,6 +90,7 @@
"lighthouse": "^12.0.0", "lighthouse": "^12.0.0",
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"tsx": "^4.20.6",
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",
"typescript": "^5", "typescript": "^5",
"wait-on": "^8.0.1", "wait-on": "^8.0.1",