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
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json pnpm-lock.yaml ./
|
||||||
RUN npm ci --legacy-peer-deps
|
RUN npm install -g pnpm
|
||||||
|
RUN pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl wget
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client'
|
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client'
|
||||||
|
import { getConvexManualDashboard } from '@/lib/convex-service'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
|
|
@ -74,7 +75,7 @@ async function loadDashboardData(): Promise<DashboardData> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ManualsDashboardPage() {
|
export default async function ManualsDashboardPage() {
|
||||||
const data = await loadDashboardData()
|
const data = (await getConvexManualDashboard()) || (await loadDashboardData())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
<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 { Metadata } from 'next'
|
||||||
import { businessConfig } from '@/lib/seo-config'
|
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 {
|
import {
|
||||||
scanManuals,
|
scanManuals,
|
||||||
groupManuals,
|
groupManuals,
|
||||||
|
|
@ -86,8 +87,9 @@ export default async function ManualsPage() {
|
||||||
const manufacturerAliases = getManufacturerAliases()
|
const manufacturerAliases = getManufacturerAliases()
|
||||||
const includePaymentComponents = shouldIncludePaymentComponents(siteDomain)
|
const includePaymentComponents = shouldIncludePaymentComponents(siteDomain)
|
||||||
|
|
||||||
// Scan manuals directory
|
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||||
const allManuals = await scanManuals()
|
const convexManuals = await listConvexManuals()
|
||||||
|
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||||
|
|
||||||
// Filtering chain:
|
// Filtering chain:
|
||||||
// 1. Filter by MDB (existing)
|
// 1. Filter by MDB (existing)
|
||||||
|
|
@ -168,7 +170,7 @@ export default async function ManualsPage() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ManualsPageClient
|
<ManualsPageShell
|
||||||
manuals={manuals}
|
manuals={manuals}
|
||||||
groupedManuals={groupedManuals}
|
groupedManuals={groupedManuals}
|
||||||
manufacturers={manufacturers}
|
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"
|
"seo:interactive": "node scripts/seo-internal-link-tool.js interactive"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-sesv2": "^3.1017.0",
|
||||||
"@aws-sdk/client-s3": "^3.0.0",
|
"@aws-sdk/client-s3": "^3.0.0",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@livekit/agents": "^1.2.1",
|
"@livekit/agents": "^1.2.1",
|
||||||
|
|
@ -96,6 +97,7 @@
|
||||||
"stripe": "^17.0.0",
|
"stripe": "^17.0.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"usesend-js": "^1.6.3",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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 { makeFunctionReference } from "convex/server"
|
||||||
import { scanManuals } from "../lib/manuals"
|
import { scanManuals } from "../lib/manuals"
|
||||||
import { buildManualAssetUrl, buildThumbnailAssetUrl, getManualsAssetSource } from "../lib/manuals-storage"
|
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.local" })
|
||||||
loadEnv({ path: ".env.staging", override: false })
|
loadEnv({ path: ".env.staging", override: false })
|
||||||
|
|
@ -20,6 +22,12 @@ type ManualUpsertInput = {
|
||||||
thumbnailUrl?: string
|
thumbnailUrl?: string
|
||||||
manualUrl?: string
|
manualUrl?: string
|
||||||
hasParts?: boolean
|
hasParts?: boolean
|
||||||
|
assetSource?: string
|
||||||
|
sourcePath?: string
|
||||||
|
sourceSite?: string
|
||||||
|
sourceDomain?: string
|
||||||
|
siteVisibility?: string[]
|
||||||
|
importBatch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
|
const UPSERT_MANUALS = makeFunctionReference<"mutation">("manuals:upsertMany")
|
||||||
|
|
@ -45,7 +53,16 @@ function readConvexUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldUploadAssets() {
|
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() {
|
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 manualUrl = buildManualAssetUrl(manual.path)
|
||||||
const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined
|
const thumbnailUrl = manual.thumbnailUrl ? buildThumbnailAssetUrl(manual.thumbnailUrl) : undefined
|
||||||
|
|
||||||
|
|
@ -75,6 +96,12 @@ function normalizeManualForConvex(manual: Awaited<ReturnType<typeof scanManuals>
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
manualUrl,
|
manualUrl,
|
||||||
hasParts: Boolean(manual.searchTerms?.some((term) => term.toLowerCase().includes("parts"))),
|
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
|
} satisfies ManualUpsertInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +118,7 @@ async function main() {
|
||||||
const manuals = await scanManuals()
|
const manuals = await scanManuals()
|
||||||
const selectedManuals = args.limit ? manuals.slice(0, args.limit) : manuals
|
const selectedManuals = args.limit ? manuals.slice(0, args.limit) : manuals
|
||||||
const importBatch = new Date().toISOString()
|
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", {
|
console.log("[manuals-sync] scanned manuals", {
|
||||||
total: manuals.length,
|
total: manuals.length,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue