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
# Set working directory
WORKDIR /app
# Copy package files
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 . .
RUN npm run build
# Build the application
RUN pnpm build
# Stage 2: Production image
FROM node:20-alpine AS runner
# Set environment variables
ENV NODE_ENV=production
# Create app directory
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
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/.next/standalone/. ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3001
# Set environment variable for port
ENV PORT=3001
# Start the application
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({
children,
}: {
children: React.ReactNode
}) {
const { userId } = await auth()
if (!userId) {
redirect('/sign-in')
if (!isAdminUiEnabled()) {
redirect("/");
}
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 { WebhookClient, ContactFormWebhookData } from "@/lib/webhook-client"
import { handleLeadRequest } from "@/lib/server/contact-submission";
interface ContactFormData extends ContactFormWebhookData {}
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 }
)
}
}
export async function POST(request: Request) {
return handleLeadRequest(request);
}

View file

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

View file

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

View file

@ -1,100 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
import { WebhookClient, RequestMachineFormWebhookData } from "@/lib/webhook-client"
import { handleLeadRequest } from "@/lib/server/contact-submission";
interface RequestMachineFormData extends RequestMachineFormWebhookData {}
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 }
)
}
}
export async function POST(request: Request) {
return handleLeadRequest(request, "request-machine");
}

View file

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

View file

@ -3,7 +3,6 @@ import type { Metadata } from "next"
import { Inter, Geist_Mono } from "next/font/google"
import Script from "next/script"
import { Analytics } from "@vercel/analytics/next"
import { ClerkProvider } from "@clerk/nextjs"
import { Header } from "@/components/header"
import { Footer } from "@/components/footer"
import { StructuredData } from "@/components/structured-data"
@ -114,42 +113,40 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<ClerkProvider>
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
<head>
{/* Resource hints for third-party domains */}
<link rel="preconnect" href="https://beta.leadconnectorhq.com" />
<link rel="dns-prefetch" href="https://beta.leadconnectorhq.com" />
<StructuredData />
<OrganizationSchema />
</head>
<body className="font-sans antialiased">
<CartProvider>
<div className="min-h-screen flex flex-col">
{/* Skip to main content link for keyboard users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
>
Skip to main content
</a>
<Header />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</div>
</CartProvider>
{/* Third-party scripts - loaded after page becomes interactive to avoid blocking render */}
<Analytics />
<Script
src="https://beta.leadconnectorhq.com/loader.js"
data-resources-url="https://beta.leadconnectorhq.com/chat-widget/loader.js"
data-widget-id="679bb5e2ce087f8626e06144"
strategy="afterInteractive"
/>
</body>
</html>
</ClerkProvider>
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
<head>
{/* Resource hints for third-party domains */}
<link rel="preconnect" href="https://beta.leadconnectorhq.com" />
<link rel="dns-prefetch" href="https://beta.leadconnectorhq.com" />
<StructuredData />
<OrganizationSchema />
</head>
<body className="font-sans antialiased">
<CartProvider>
<div className="min-h-screen flex flex-col">
{/* Skip to main content link for keyboard users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
>
Skip to main content
</a>
<Header />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</div>
</CartProvider>
{/* Third-party scripts - loaded after page becomes interactive to avoid blocking render */}
<Analytics />
<Script
src="https://beta.leadconnectorhq.com/loader.js"
data-resources-url="https://beta.leadconnectorhq.com/chat-widget/loader.js"
data-widget-id="679bb5e2ce087f8626e06144"
strategy="afterInteractive"
/>
</body>
</html>
)
}

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() {
if (!isAdminUiEnabled()) {
redirect("/");
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30">
<SignIn />
<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"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { CheckCircle, AlertCircle } from "lucide-react"
@ -18,10 +18,16 @@ interface ContactFormData {
message: string
source?: string
page?: string
confirmEmail?: string
}
interface SubmittedContactFormData extends ContactFormData {
timestamp: string
url: string
}
interface ContactFormProps {
onSubmit?: (data: ContactFormData) => void
onSubmit?: (data: SubmittedContactFormData) => void
className?: string
}
@ -46,12 +52,11 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
const watchedValues = watch()
// Update page URL when component mounts
useState(() => {
useEffect(() => {
if (typeof window !== 'undefined') {
setValue("page", window.location.pathname)
}
})
}, [setValue])
const onFormSubmit = async (data: ContactFormData) => {
setIsSubmitting(true)
@ -59,7 +64,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
try {
// Add URL and timestamp
const formData = {
const formData: SubmittedContactFormData = {
...data,
timestamp: new Date().toISOString(),
url: window.location.href,
@ -124,9 +129,7 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
<input
id="confirm-email"
type="email"
{...register("confirmEmail", {
shouldTouch: true,
})}
{...register("confirmEmail")}
className="sr-only"
tabIndex={-1}
/>
@ -243,4 +246,4 @@ export function ContactForm({ onSubmit, className }: ContactFormProps) {
/>
</form>
)
}
}

View file

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

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
* Used for NAP consistency, structured data, and SEO metadata
@ -12,7 +16,7 @@ export const businessConfig = {
phoneUrl: "tel:+14352339668",
smsUrl: "sms:+14352339668",
email: "info@rockymountainvending.com",
website: "https://rockymountainvending.com",
website: configuredWebsite,
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.",
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 {
private static async sendToWebhook(
webhookUrl: string,
payload: any,
webhookType: 'contact' | 'request-machine'
private static async sendToApi(
payload: Record<string, unknown>,
kind: "contact" | "request-machine"
): Promise<WebhookResponse> {
try {
// Send initial request
const response = await fetch(webhookUrl, {
method: 'POST',
const response = await fetch("/api/contact", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
});
if (response.ok) {
const body = await response.json().catch(() => null);
if (!response.ok) {
return {
success: true,
message: `${webhookType} form submitted successfully`
}
success: false,
message: body?.message || body?.error || "Failed to submit form",
error: body?.error || `Request failed with status ${response.status}`,
};
}
// Log the error and retry once
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 {
success: false,
message: 'Failed to submit to webhook after retry',
error: `${retryResponse.status} ${retryResponse.statusText}`
}
success: body?.success !== false,
message:
body?.message ||
(kind === "request-machine"
? "Your consultation request was submitted successfully."
: "Your message was submitted successfully."),
};
} catch (error) {
console.error(`Webhook error for ${webhookType}:`, error)
console.error(`Lead API error for ${kind}:`, error)
return {
success: false,
message: 'Failed to submit to webhook',
error: error instanceof Error ? error.message : 'Unknown error'
message: "Failed to submit form",
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
static async submitContactForm(data: ContactFormWebhookData): Promise<WebhookResponse> {
const webhookUrl = process.env.GHL_CONTACT_WEBHOOK_URL
if (!webhookUrl) {
return {
success: false,
message: 'Webhook URL not configured',
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')
return this.sendToApi(
{
kind: "contact",
...data,
},
"contact"
)
}
static async submitRequestMachineForm(data: RequestMachineFormWebhookData): Promise<WebhookResponse> {
const webhookUrl = process.env.GHL_REQUEST_MACHINE_WEBHOOK_URL
if (!webhookUrl) {
return {
success: false,
message: 'Webhook URL not configured',
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')
return this.sendToApi(
{
kind: "request-machine",
...data,
},
"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 = {
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",
"private": true,
"type": "module",
@ -8,6 +8,7 @@
"dev": "next dev",
"lint": "eslint .",
"start": "next start",
"test": "tsx --test app/api/contact/route.test.ts",
"lighthouse:dev": "node scripts/lighthouse-test.js --dev",
"lighthouse:build": "node scripts/lighthouse-test.js",
"lighthouse:ci": "lighthouse-ci autorun",
@ -23,8 +24,8 @@
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
},
"dependencies": {
"@aws-sdk/client-sesv2": "^3.888.0",
"@aws-sdk/client-s3": "^3.0.0",
"@clerk/nextjs": "^6.37.3",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
@ -75,6 +76,7 @@
"stripe": "^17.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"usesend-js": "^1.6.3",
"vaul": "^0.9.9",
"zod": "3.25.76"
},
@ -88,9 +90,10 @@
"lighthouse": "^12.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tsx": "^4.20.6",
"tw-animate-css": "1.3.3",
"typescript": "^5",
"wait-on": "^8.0.1",
"wrangler": "^3.0.0"
}
}
}