Prepare Rocky Mountain for Coolify launch
This commit is contained in:
parent
46d973904b
commit
b63ca581f5
23 changed files with 2766 additions and 620 deletions
38
COOLIFY_LAUNCH.md
Normal file
38
COOLIFY_LAUNCH.md
Normal 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.
|
||||
29
Dockerfile
29
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
140
app/api/contact/route.test.ts
Normal file
140
app/api/contact/route.test.ts
Normal 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"]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
76
lib/convex.ts
Normal 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
93
lib/email.ts
Normal 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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
58
lib/ghl.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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
32
lib/server/admin-auth.ts
Normal 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";
|
||||
}
|
||||
608
lib/server/contact-submission.ts
Normal file
608
lib/server/contact-submission.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1778
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue