deploy: clean Jessica chat and manual catalog plumbing
This commit is contained in:
parent
42c9400e6d
commit
207e6194b2
10 changed files with 3408 additions and 89 deletions
|
|
@ -1,17 +1,18 @@
|
|||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY package.json package-lock.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache curl wget
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Metadata } from 'next'
|
||||
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client'
|
||||
import { getConvexManualDashboard } from '@/lib/convex-service'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
|
|
@ -74,7 +75,7 @@ async function loadDashboardData(): Promise<DashboardData> {
|
|||
}
|
||||
|
||||
export default async function ManualsDashboardPage() {
|
||||
const data = await loadDashboardData()
|
||||
const data = (await getConvexManualDashboard()) || (await loadDashboardData())
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
|
|
@ -96,4 +97,3 @@ export default async function ManualsDashboardPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Metadata } from 'next'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
import { ManualsPageClient } from '@/components/manuals-page-client'
|
||||
import { ManualsPageShell } from '@/components/manuals-page-shell'
|
||||
import { listConvexManuals } from '@/lib/convex-service'
|
||||
import {
|
||||
scanManuals,
|
||||
groupManuals,
|
||||
|
|
@ -86,8 +87,9 @@ export default async function ManualsPage() {
|
|||
const manufacturerAliases = getManufacturerAliases()
|
||||
const includePaymentComponents = shouldIncludePaymentComponents(siteDomain)
|
||||
|
||||
// Scan manuals directory
|
||||
const allManuals = await scanManuals()
|
||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||
const convexManuals = await listConvexManuals()
|
||||
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
|
||||
// Filtering chain:
|
||||
// 1. Filter by MDB (existing)
|
||||
|
|
@ -168,7 +170,7 @@ export default async function ManualsPage() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<ManualsPageClient
|
||||
<ManualsPageShell
|
||||
manuals={manuals}
|
||||
groupedManuals={groupedManuals}
|
||||
manufacturers={manufacturers}
|
||||
|
|
@ -178,6 +180,3 @@ export default async function ManualsPage() {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
27
components/manuals-page-shell.tsx
Normal file
27
components/manuals-page-shell.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import type { Manual, ManualGroup } from "@/lib/manuals-types"
|
||||
|
||||
const ManualsPageClient = dynamic(
|
||||
() => import("@/components/manuals-page-client").then((mod) => mod.ManualsPageClient),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
|
||||
Loading manual browser...
|
||||
</div>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
interface ManualsPageShellProps {
|
||||
manuals: Manual[]
|
||||
groupedManuals: ManualGroup[]
|
||||
manufacturers: string[]
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
export function ManualsPageShell(props: ManualsPageShellProps) {
|
||||
return <ManualsPageClient {...props} />
|
||||
}
|
||||
7
lib/convex-config.ts
Normal file
7
lib/convex-config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function getConvexUrl() {
|
||||
return process.env.NEXT_PUBLIC_CONVEX_URL ?? "";
|
||||
}
|
||||
|
||||
export function hasConvexUrl() {
|
||||
return Boolean(getConvexUrl());
|
||||
}
|
||||
160
lib/convex-service.ts
Normal file
160
lib/convex-service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import type { Product } from "@/lib/products/types"
|
||||
import type { Manual } from "@/lib/manuals-types"
|
||||
|
||||
type ConvexProductDoc = {
|
||||
_id: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
currency: string
|
||||
images: string[]
|
||||
metadata?: Record<string, string>
|
||||
stripeProductId?: string
|
||||
stripePriceId?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
type ConvexManualDoc = {
|
||||
filename: string
|
||||
path: string
|
||||
manufacturer: string
|
||||
category: string
|
||||
size?: number
|
||||
lastModified?: number
|
||||
searchTerms?: string[]
|
||||
commonNames?: string[]
|
||||
thumbnailUrl?: string
|
||||
manualUrl?: string
|
||||
}
|
||||
|
||||
type ConvexOrderItem = {
|
||||
productId?: string | null
|
||||
productName: string
|
||||
price: number
|
||||
quantity: number
|
||||
priceId: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export type ConvexAdminOrder = {
|
||||
id: string
|
||||
customerEmail: string
|
||||
customerName?: string
|
||||
totalAmount: number
|
||||
currency: string
|
||||
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
|
||||
stripeSessionId: string | null
|
||||
paymentIntentId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
shippingAddress?: {
|
||||
name?: string
|
||||
address?: string
|
||||
city?: string
|
||||
state?: string
|
||||
zipCode?: string
|
||||
country?: string
|
||||
}
|
||||
items: ConvexOrderItem[]
|
||||
}
|
||||
|
||||
function mapConvexProduct(product: ConvexProductDoc): Product {
|
||||
return {
|
||||
id: product._id,
|
||||
stripeId: product.stripeProductId ?? product._id,
|
||||
name: product.name,
|
||||
description: product.description ?? null,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
images: product.images ?? [],
|
||||
metadata: product.metadata,
|
||||
priceId: product.stripePriceId,
|
||||
}
|
||||
}
|
||||
|
||||
function mapConvexManual(manual: ConvexManualDoc): Manual {
|
||||
return {
|
||||
filename: manual.filename,
|
||||
path: manual.manualUrl || manual.path,
|
||||
manufacturer: manual.manufacturer,
|
||||
category: manual.category,
|
||||
size: manual.size,
|
||||
lastModified: manual.lastModified ? new Date(manual.lastModified) : undefined,
|
||||
searchTerms: manual.searchTerms,
|
||||
commonNames: manual.commonNames,
|
||||
thumbnailUrl: manual.thumbnailUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConvexProducts(): Promise<Product[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const products = await fetchQuery(api.products.listActive, {})
|
||||
return (products as ConvexProductDoc[]).map(mapConvexProduct)
|
||||
}
|
||||
|
||||
export async function listConvexAdminProducts(search?: string): Promise<Product[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const products = await fetchQuery(api.products.listAdmin, { search })
|
||||
return (products as ConvexProductDoc[]).map(mapConvexProduct)
|
||||
}
|
||||
|
||||
export async function getConvexProduct(id: string): Promise<Product | null> {
|
||||
if (!hasConvexUrl()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const products = await fetchQuery(api.products.listAdmin, {})
|
||||
const match = (products as ConvexProductDoc[]).find((product) => {
|
||||
return product._id === id || product.stripeProductId === id
|
||||
})
|
||||
|
||||
return match ? mapConvexProduct(match) : null
|
||||
}
|
||||
|
||||
export async function listConvexManuals(): Promise<Manual[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const manuals = await fetchQuery(api.manuals.list, {})
|
||||
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
||||
}
|
||||
|
||||
export async function getConvexManualDashboard() {
|
||||
if (!hasConvexUrl()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await fetchQuery(api.manuals.dashboard, {})
|
||||
}
|
||||
|
||||
export async function getConvexOrderMetrics() {
|
||||
if (!hasConvexUrl()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await fetchQuery(api.orders.getMetrics, {})
|
||||
}
|
||||
|
||||
export async function listConvexOrders(options?: {
|
||||
status?: ConvexAdminOrder["status"]
|
||||
search?: string
|
||||
}): Promise<ConvexAdminOrder[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
return await fetchQuery(api.orders.listAdmin, {
|
||||
status: options?.status,
|
||||
search: options?.search,
|
||||
})
|
||||
}
|
||||
53
lib/manuals-storage.ts
Normal file
53
lib/manuals-storage.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
function cleanBaseUrl(value: string) {
|
||||
return value.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function getSiteBaseUrl() {
|
||||
return cleanBaseUrl(
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.VOICE_ASSISTANT_SITE_URL ||
|
||||
businessConfig.website,
|
||||
)
|
||||
}
|
||||
|
||||
export function getManualsAssetBaseUrl() {
|
||||
return cleanBaseUrl(process.env.NEXT_PUBLIC_MANUALS_BASE_URL || `${getSiteBaseUrl()}/manuals`)
|
||||
}
|
||||
|
||||
export function getThumbnailsAssetBaseUrl() {
|
||||
return cleanBaseUrl(process.env.NEXT_PUBLIC_THUMBNAILS_BASE_URL || `${getSiteBaseUrl()}/thumbnails`)
|
||||
}
|
||||
|
||||
export function getManualsAssetSource() {
|
||||
return process.env.NEXT_PUBLIC_MANUALS_BASE_URL && process.env.NEXT_PUBLIC_THUMBNAILS_BASE_URL
|
||||
? "external-object-storage"
|
||||
: "site-static"
|
||||
}
|
||||
|
||||
export function buildManualAssetUrl(relativePath: string) {
|
||||
const encodedPath = relativePath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(decodeURIComponentSafe(segment)))
|
||||
.join("/")
|
||||
|
||||
return `${getManualsAssetBaseUrl()}/${encodedPath}`
|
||||
}
|
||||
|
||||
export function buildThumbnailAssetUrl(relativePath: string) {
|
||||
const encodedPath = relativePath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(decodeURIComponentSafe(segment)))
|
||||
.join("/")
|
||||
|
||||
return `${getThumbnailsAssetBaseUrl()}/${encodedPath}`
|
||||
}
|
||||
|
||||
function decodeURIComponentSafe(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.1017.0",
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@livekit/agents": "^1.2.1",
|
||||
|
|
@ -96,6 +97,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"
|
||||
},
|
||||
|
|
|
|||
3189
pnpm-lock.yaml
3189
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,8 @@ import { ConvexHttpClient } from "convex/browser"
|
|||
import { makeFunctionReference } from "convex/server"
|
||||
import { scanManuals } from "../lib/manuals"
|
||||
import { buildManualAssetUrl, buildThumbnailAssetUrl, getManualsAssetSource } from "../lib/manuals-storage"
|
||||
import { businessConfig } from "../lib/seo-config"
|
||||
import { getSiteDomain } from "../lib/site-config"
|
||||
|
||||
loadEnv({ path: ".env.local" })
|
||||
loadEnv({ path: ".env.staging", override: false })
|
||||
|
|
@ -20,6 +22,12 @@ type ManualUpsertInput = {
|
|||
thumbnailUrl?: string
|
||||
manualUrl?: string
|
||||
hasParts?: boolean
|
||||
assetSource?: string
|
||||
sourcePath?: string
|
||||
sourceSite?: string
|
||||
sourceDomain?: string
|
||||
siteVisibility?: string[]
|
||||
importBatch?: string
|
||||
}
|
||||
|
||||
const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
|
||||
|
|
@ -45,7 +53,16 @@ function readConvexUrl() {
|
|||
}
|
||||
|
||||
function shouldUploadAssets() {
|
||||
return Boolean(process.env.CLOUDFLARE_R2_ACCESS_KEY_ID && process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY)
|
||||
const accessKey =
|
||||
process.env.CLOUDFLARE_R2_ACCESS_KEY_ID ||
|
||||
process.env.AWS_ACCESS_KEY_ID ||
|
||||
process.env.AWS_ACCESS_KEY
|
||||
const secret =
|
||||
process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ||
|
||||
process.env.AWS_SECRET_ACCESS_KEY ||
|
||||
process.env.AWS_SECRET_KEY
|
||||
|
||||
return Boolean(accessKey && secret)
|
||||
}
|
||||
|
||||
function runAssetUpload() {
|
||||
|
|
@ -59,7 +76,11 @@ function runAssetUpload() {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeManualForConvex(manual: Awaited<ReturnType<typeof scanManuals>>[number]) {
|
||||
function normalizeManualForConvex(
|
||||
manual: Awaited<ReturnType<typeof scanManuals>>[number],
|
||||
importBatch: string,
|
||||
) {
|
||||
const siteDomain = getSiteDomain()
|
||||
const manualUrl = buildManualAssetUrl(manual.path)
|
||||
const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined
|
||||
|
||||
|
|
@ -75,6 +96,12 @@ function normalizeManualForConvex(manual: Awaited<ReturnType<typeof scanManuals>
|
|||
thumbnailUrl,
|
||||
manualUrl,
|
||||
hasParts: Boolean(manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))),
|
||||
assetSource: getManualsAssetSource(),
|
||||
sourcePath: manual.path,
|
||||
sourceSite: businessConfig.name,
|
||||
sourceDomain: siteDomain,
|
||||
siteVisibility: [siteDomain],
|
||||
importBatch,
|
||||
} satisfies ManualUpsertInput
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +118,7 @@ async function main() {
|
|||
const manuals = await scanManuals()
|
||||
const selectedManuals = args.limit ? manuals.slice(0, args.limit) : manuals
|
||||
const importBatch = new Date().toISOString()
|
||||
const payload = selectedManuals.map((manual) => normalizeManualForConvex(manual))
|
||||
const payload = selectedManuals.map((manual) => normalizeManualForConvex(manual, importBatch))
|
||||
|
||||
console.log("[manuals-sync] scanned manuals", {
|
||||
total: manuals.length,
|
||||
|
|
|
|||
Loading…
Reference in a new issue