Product sharing fix
This commit is contained in:
parent
8354a90698
commit
1ff77cb459
@ -7,11 +7,14 @@ COPY package*.json prisma.config.ts ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies needed for build)
|
# Install ALL dependencies (including devDependencies needed for build)
|
||||||
RUN npm ci && npx prisma generate
|
RUN npm ci
|
||||||
|
|
||||||
# Copy application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time)
|
# Copy application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Regenerate Prisma Client with final schema before building Next.js
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle)
|
# Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,8 @@ client.connect()
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Syncing database schema with Prisma (db push)..."
|
echo "Running Prisma migrations (migrate deploy)..."
|
||||||
npx prisma db push --accept-data-loss
|
npx prisma migrate deploy
|
||||||
|
|
||||||
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
|
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
|
||||||
echo "Seeding predefined email templates..."
|
echo "Seeding predefined email templates..."
|
||||||
|
|||||||
@ -28,6 +28,10 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "*.ngrok-free.dev",
|
hostname: "*.ngrok-free.dev",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "junohub-product-images.s3.us-east-1.amazonaws.com",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Allow ngrok origin for development (fixes cross-origin warnings)
|
// Allow ngrok origin for development (fixes cross-origin warnings)
|
||||||
|
|||||||
995
package-lock.json
generated
995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,6 +13,7 @@
|
|||||||
"@auth/core": "^0.41.0",
|
"@auth/core": "^0.41.0",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.989.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.34.3",
|
"@upstash/redis": "^1.34.3",
|
||||||
"aws-cli": "^0.0.2",
|
"aws-cli": "^0.0.2",
|
||||||
|
"aws-sdk": "^2.1693.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
-- Enable pgvector extension and create index for knowledge chunk embeddings
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
|
||||||
|
|
||||||
-- Adjust the embedding column on KnowledgeChunk to use a concrete vector dimension.
|
|
||||||
-- NOTE: Update 1536 to match the actual embedding size used by your Gemini model.
|
|
||||||
ALTER TABLE "KnowledgeChunk"
|
|
||||||
ALTER COLUMN "embedding" TYPE vector(1536);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx"
|
|
||||||
ON "KnowledgeChunk"
|
|
||||||
USING ivfflat ("embedding" vector_cosine_ops)
|
|
||||||
WITH (lists = 100)
|
|
||||||
WHERE "storeId" IS NOT NULL;
|
|
||||||
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
-- Migrate KnowledgeChunk embeddings from 1536 dimensions (Gemini) to 1024 dimensions (Bedrock Titan V2)
|
|
||||||
--
|
|
||||||
-- NOTE: This migration changes the vector dimension. Existing embeddings will need to be
|
|
||||||
-- re-embedded using the re-embedding script after this migration runs.
|
|
||||||
--
|
|
||||||
-- Strategy: Drop the index, change column type (will truncate/pad existing vectors),
|
|
||||||
-- then recreate the index with the new dimension.
|
|
||||||
|
|
||||||
-- Drop the existing index
|
|
||||||
DROP INDEX IF EXISTS "KnowledgeChunk_embedding_storeId_idx";
|
|
||||||
|
|
||||||
-- Change the embedding column dimension from 1536 to 1024
|
|
||||||
-- PostgreSQL will automatically truncate vectors longer than 1024 or pad shorter ones
|
|
||||||
ALTER TABLE "KnowledgeChunk"
|
|
||||||
ALTER COLUMN "embedding" TYPE vector(1024);
|
|
||||||
|
|
||||||
-- Recreate the index with the new dimension
|
|
||||||
CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx"
|
|
||||||
ON "KnowledgeChunk"
|
|
||||||
USING ivfflat ("embedding" vector_cosine_ops)
|
|
||||||
WITH (lists = 100)
|
|
||||||
WHERE "storeId" IS NOT NULL;
|
|
||||||
657
prisma/migrations/20260216152711_init_schema/migration.sql
Normal file
657
prisma/migrations/20260216152711_init_schema/migration.sql
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
-- Enable pgvector extension for KnowledgeChunk.embedding
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('VENDOR', 'SUPPLIER', 'ADMIN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'CONNECTED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductSyncStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "NotificationType" AS ENUM ('INVITATION_ACCEPTED', 'INVITATION_RECEIVED', 'CONNECTION_STATUS_CHANGED', 'ORDER_RECEIVED', 'PRODUCT_UPDATED', 'PRODUCT_SYNC_REQUESTED', 'PRODUCT_SYNC_ACCEPTED', 'PRODUCT_SYNC_REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FlaggedEmailPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KnowledgeDocumentStatus" AS ENUM ('UPLOADED', 'PROCESSING', 'READY', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KnowledgeChunkSourceType" AS ENUM ('DOCUMENT', 'FAQ');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"emailVerified" TIMESTAMP(3),
|
||||||
|
"image" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'VENDOR',
|
||||||
|
"activeStoreId" TEXT,
|
||||||
|
"mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"mfaSecret" TEXT,
|
||||||
|
"chatPublicKey" TEXT,
|
||||||
|
"chatPrivateKeyEncrypted" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionToken" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationToken" (
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Store" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"businessName" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"timezone" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL,
|
||||||
|
"shopifyStoreName" TEXT NOT NULL,
|
||||||
|
"shopifyDomain" TEXT NOT NULL,
|
||||||
|
"shopifyApiKey" TEXT NOT NULL,
|
||||||
|
"shopifyApiSecret" TEXT NOT NULL,
|
||||||
|
"shopifyAccessToken" TEXT NOT NULL,
|
||||||
|
"onboardingComplete" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Store_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CachedProduct" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopifyProductId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"vendor" TEXT,
|
||||||
|
"productType" TEXT,
|
||||||
|
"status" TEXT,
|
||||||
|
"variants" JSONB NOT NULL,
|
||||||
|
"images" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "CachedProduct_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CachedOrder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopifyOrderId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"orderNumber" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"totalPrice" TEXT NOT NULL,
|
||||||
|
"currency" TEXT,
|
||||||
|
"financialStatus" TEXT,
|
||||||
|
"fulfillmentStatus" TEXT,
|
||||||
|
"lineItems" JSONB NOT NULL,
|
||||||
|
"customer" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"shopifyCreatedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "CachedOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CachedCustomer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopifyCustomerId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"ordersCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalSpent" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"shopifyCreatedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "CachedCustomer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SyncMetadata" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"resourceType" TEXT NOT NULL,
|
||||||
|
"lastSyncAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"syncStatus" TEXT NOT NULL DEFAULT 'success',
|
||||||
|
"recordCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "SyncMetadata_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SupplierProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SupplierProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Connection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"supplierId" TEXT NOT NULL,
|
||||||
|
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Connection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Invitation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"supplierId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"supplierId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"price" DOUBLE PRECISION NOT NULL,
|
||||||
|
"sku" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SupplierProductOffer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"supplierId" TEXT NOT NULL,
|
||||||
|
"price" DOUBLE PRECISION NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SupplierProductOffer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductSync" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"supplierId" TEXT NOT NULL,
|
||||||
|
"shopifyProductId" TEXT NOT NULL,
|
||||||
|
"shopifyProductTitle" TEXT NOT NULL,
|
||||||
|
"shopifyProductData" JSONB,
|
||||||
|
"status" "ProductSyncStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"requestedBy" TEXT NOT NULL,
|
||||||
|
"acceptedAt" TIMESTAMP(3),
|
||||||
|
"rejectedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductSync_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GmailIntegration" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT NOT NULL,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GmailIntegration_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Notification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" "NotificationType" NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"readAt" TIMESTAMP(3),
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EmailLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT,
|
||||||
|
"to" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"bodyPreview" TEXT,
|
||||||
|
"isAutomated" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"trigger" TEXT,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"providerMessageId" TEXT,
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FlaggedEmail" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"from" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"priority" "FlaggedEmailPriority" NOT NULL DEFAULT 'MEDIUM',
|
||||||
|
"messageId" TEXT,
|
||||||
|
"flaggedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "FlaggedEmail_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "KnowledgeDocument" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"status" "KnowledgeDocumentStatus" NOT NULL DEFAULT 'UPLOADED',
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"storagePath" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "KnowledgeDocument_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FaqItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"question" TEXT NOT NULL,
|
||||||
|
"answer" TEXT NOT NULL,
|
||||||
|
"tags" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FaqItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "KnowledgeChunk" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"documentId" TEXT,
|
||||||
|
"faqItemId" TEXT,
|
||||||
|
"sourceType" "KnowledgeChunkSourceType" NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"embedding" vector NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "KnowledgeChunk_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Template" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"intent" TEXT NOT NULL,
|
||||||
|
"mood" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"variables" JSONB,
|
||||||
|
"isHtml" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ChatMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"senderId" TEXT NOT NULL,
|
||||||
|
"receiverId" TEXT NOT NULL,
|
||||||
|
"encryptedContent" TEXT NOT NULL,
|
||||||
|
"encryptedKeyForSender" TEXT NOT NULL,
|
||||||
|
"encryptedKeyForReceiver" TEXT NOT NULL,
|
||||||
|
"iv" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PushSubscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Store_userId_idx" ON "Store"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CachedProduct_storeId_idx" ON "CachedProduct"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CachedProduct_shopifyProductId_storeId_key" ON "CachedProduct"("shopifyProductId", "storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CachedOrder_storeId_idx" ON "CachedOrder"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CachedOrder_shopifyCreatedAt_idx" ON "CachedOrder"("shopifyCreatedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CachedOrder_shopifyOrderId_storeId_key" ON "CachedOrder"("shopifyOrderId", "storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CachedCustomer_storeId_idx" ON "CachedCustomer"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CachedCustomer_shopifyCustomerId_storeId_key" ON "CachedCustomer"("shopifyCustomerId", "storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SyncMetadata_storeId_idx" ON "SyncMetadata"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SyncMetadata_storeId_resourceType_key" ON "SyncMetadata"("storeId", "resourceType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SupplierProfile_userId_key" ON "SupplierProfile"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Connection_storeId_supplierId_key" ON "Connection"("storeId", "supplierId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Invitation_token_key" ON "Invitation"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Invitation_token_idx" ON "Invitation"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Invitation_supplierId_idx" ON "Invitation"("supplierId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Invitation_storeId_idx" ON "Invitation"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SupplierProductOffer_storeId_idx" ON "SupplierProductOffer"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SupplierProductOffer_supplierId_idx" ON "SupplierProductOffer"("supplierId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SupplierProductOffer_connectionId_idx" ON "SupplierProductOffer"("connectionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SupplierProductOffer_productId_storeId_key" ON "SupplierProductOffer"("productId", "storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductSync_storeId_idx" ON "ProductSync"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductSync_supplierId_idx" ON "ProductSync"("supplierId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductSync_status_idx" ON "ProductSync"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductSync_shopifyProductId_idx" ON "ProductSync"("shopifyProductId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductSync_storeId_supplierId_shopifyProductId_key" ON "ProductSync"("storeId", "supplierId", "shopifyProductId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GmailIntegration_userId_key" ON "GmailIntegration"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GmailIntegration_userId_idx" ON "GmailIntegration"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EmailLog_storeId_sentAt_idx" ON "EmailLog"("storeId", "sentAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EmailLog_userId_sentAt_idx" ON "EmailLog"("userId", "sentAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FlaggedEmail_storeId_idx" ON "FlaggedEmail"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FlaggedEmail_storeId_flaggedAt_idx" ON "FlaggedEmail"("storeId", "flaggedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "KnowledgeDocument_storeId_idx" ON "KnowledgeDocument"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaqItem_storeId_idx" ON "FaqItem"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "KnowledgeChunk_storeId_idx" ON "KnowledgeChunk"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "KnowledgeChunk_documentId_idx" ON "KnowledgeChunk"("documentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "KnowledgeChunk_faqItemId_idx" ON "KnowledgeChunk"("faqItemId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Template_storeId_idx" ON "Template"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ChatMessage_senderId_receiverId_idx" ON "ChatMessage"("senderId", "receiverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ChatMessage_receiverId_senderId_idx" ON "ChatMessage"("receiverId", "senderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Store" ADD CONSTRAINT "Store_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CachedProduct" ADD CONSTRAINT "CachedProduct_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CachedOrder" ADD CONSTRAINT "CachedOrder_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CachedCustomer" ADD CONSTRAINT "CachedCustomer_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SyncMetadata" ADD CONSTRAINT "SyncMetadata_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SupplierProfile" ADD CONSTRAINT "SupplierProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Connection" ADD CONSTRAINT "Connection_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Connection" ADD CONSTRAINT "Connection_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Product" ADD CONSTRAINT "Product_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductSync" ADD CONSTRAINT "ProductSync_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductSync" ADD CONSTRAINT "ProductSync_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GmailIntegration" ADD CONSTRAINT "GmailIntegration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EmailLog" ADD CONSTRAINT "EmailLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EmailLog" ADD CONSTRAINT "EmailLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FlaggedEmail" ADD CONSTRAINT "FlaggedEmail_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KnowledgeDocument" ADD CONSTRAINT "KnowledgeDocument_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FaqItem" ADD CONSTRAINT "FaqItem_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "KnowledgeDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_faqItemId_fkey" FOREIGN KEY ("faqItemId") REFERENCES "FaqItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@ -27,7 +27,8 @@ model User {
|
|||||||
mfaEnabled Boolean @default(false)
|
mfaEnabled Boolean @default(false)
|
||||||
mfaSecret String? // Encrypted TOTP secret
|
mfaSecret String? // Encrypted TOTP secret
|
||||||
|
|
||||||
chatPublicKey String?
|
chatPublicKey String?
|
||||||
|
chatPrivateKeyEncrypted String? @db.Text
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
sentMessages ChatMessage[] @relation("SentMessages")
|
sentMessages ChatMessage[] @relation("SentMessages")
|
||||||
@ -110,6 +111,7 @@ model Store {
|
|||||||
syncMetadata SyncMetadata[]
|
syncMetadata SyncMetadata[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
productSyncs ProductSync[]
|
productSyncs ProductSync[]
|
||||||
|
supplierProductOffers SupplierProductOffer[]
|
||||||
knowledgeDocuments KnowledgeDocument[]
|
knowledgeDocuments KnowledgeDocument[]
|
||||||
faqItems FaqItem[]
|
faqItems FaqItem[]
|
||||||
knowledgeChunks KnowledgeChunk[]
|
knowledgeChunks KnowledgeChunk[]
|
||||||
@ -217,6 +219,7 @@ model SupplierProfile {
|
|||||||
products Product[]
|
products Product[]
|
||||||
invitations Invitation[]
|
invitations Invitation[]
|
||||||
productSyncs ProductSync[]
|
productSyncs ProductSync[]
|
||||||
|
supplierProductOffers SupplierProductOffer[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -235,6 +238,7 @@ model Connection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([storeId, supplierId])
|
@@unique([storeId, supplierId])
|
||||||
|
supplierProductOffers SupplierProductOffer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Invitation {
|
model Invitation {
|
||||||
@ -276,6 +280,30 @@ model Product {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
offers SupplierProductOffer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupplierProductOffer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
connectionId String
|
||||||
|
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
|
supplierId String
|
||||||
|
supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
price Float
|
||||||
|
quantity Int
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([productId, storeId])
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([supplierId])
|
||||||
|
@@index([connectionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProductSyncStatus {
|
enum ProductSyncStatus {
|
||||||
|
|||||||
208
src/app/api/supplier/vendors/[connectionId]/offers/route.ts
vendored
Normal file
208
src/app/api/supplier/vendors/[connectionId]/offers/route.ts
vendored
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/vendors/[connectionId]/offers
|
||||||
|
* List all products this supplier has offered to a specific vendor connection
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ connectionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectionId } = await params
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.connection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access SupplierProductOffer model (camelCase in Prisma Client)
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Database model not available. Please regenerate Prisma Client." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const offers = await (prisma as any).supplierProductOffer.findMany({
|
||||||
|
where: {
|
||||||
|
connectionId: connection.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
offers
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching supplier product offers:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch supplier product offers" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/supplier/vendors/[connectionId]/offers
|
||||||
|
* Create a new product offer from supplier to this vendor
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ connectionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectionId } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
const { productId, price, quantity } = body as {
|
||||||
|
productId?: string
|
||||||
|
price?: number
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!productId || price === undefined || quantity === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "productId, price and quantity are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity <= 0 || price < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Price must be non-negative and quantity must be greater than zero" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.connection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.status !== "CONNECTED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "You can only provide products to connected vendors" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the product belongs to this supplier
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: {
|
||||||
|
id: productId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Product not found or does not belong to this supplier" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing offer for this product + store
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Database model not available. Please regenerate Prisma Client." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
|
||||||
|
where: {
|
||||||
|
productId,
|
||||||
|
storeId: connection.storeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingOffer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This product has already been provided to this vendor" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const offer = await (prisma as any).supplierProductOffer.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
storeId: connection.storeId,
|
||||||
|
connectionId: connection.id,
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
price,
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Product provided to vendor successfully",
|
||||||
|
offer
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating supplier product offer:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to provide product to vendor" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,6 +2,69 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/vendors/[connectionId]
|
||||||
|
* Get a single vendor connection with store details for the authenticated supplier
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ connectionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectionId } = await params
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.connection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
shopifyStoreName: true,
|
||||||
|
email: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ connection })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching vendor connection:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch vendor connection" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/supplier/vendors/[connectionId]
|
* PUT /api/supplier/vendors/[connectionId]
|
||||||
* Update connection status (accept/reject)
|
* Update connection status (accept/reject)
|
||||||
|
|||||||
158
src/app/api/supplier/vendors/offers/[offerId]/route.ts
vendored
Normal file
158
src/app/api/supplier/vendors/offers/[offerId]/route.ts
vendored
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/supplier/vendors/offers/[offerId]
|
||||||
|
* Update an existing supplier product offer (price, quantity)
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ offerId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { offerId } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
const { price, quantity } = body as {
|
||||||
|
price?: number
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price === undefined && quantity === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "At least one of price or quantity must be provided" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity !== undefined && quantity <= 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Quantity must be greater than zero" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price !== undefined && price < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Price must be non-negative" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Database model not available. Please regenerate Prisma Client." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
|
||||||
|
where: {
|
||||||
|
id: offerId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingOffer) {
|
||||||
|
return NextResponse.json({ message: "Offer not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const updatedOffer = await (prisma as any).supplierProductOffer.update({
|
||||||
|
where: { id: offerId },
|
||||||
|
data: {
|
||||||
|
...(price !== undefined ? { price } : {}),
|
||||||
|
...(quantity !== undefined ? { quantity } : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Offer updated successfully",
|
||||||
|
offer: updatedOffer
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating supplier product offer:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update supplier product offer" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/supplier/vendors/offers/[offerId]
|
||||||
|
* Withdraw an existing supplier product offer from a vendor
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ offerId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { offerId } = await params
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the offer belongs to this supplier
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Database model not available. Please regenerate Prisma Client." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
|
||||||
|
where: {
|
||||||
|
id: offerId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingOffer) {
|
||||||
|
return NextResponse.json({ message: "Offer not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).supplierProductOffer.delete({
|
||||||
|
where: { id: offerId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Offer withdrawn successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting supplier product offer:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to withdraw supplier product offer" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
src/app/api/uploads/supplier-product-image/route.ts
Normal file
55
src/app/api/uploads/supplier-product-image/route.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { uploadSupplierProductImage } from "@/lib/s3"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Missing file" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve supplier profile so we can scope keys per-supplier
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Supplier profile not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
const { url } = await uploadSupplierProductImage({
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
fileBuffer: buffer,
|
||||||
|
contentType: file.type || undefined,
|
||||||
|
originalName: file.name
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ url })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SUPPLIER_PRODUCT_IMAGE_UPLOAD]", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to upload image" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
53
src/app/api/user/chat-key/route.ts
Normal file
53
src/app/api/user/chat-key/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
}) as { chatPrivateKeyEncrypted?: string | null } | null
|
||||||
|
|
||||||
|
if (!user?.chatPrivateKeyEncrypted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ hasBackup: false },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = decryptString(user.chatPrivateKeyEncrypted)
|
||||||
|
if (!decrypted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ hasBackup: false },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ hasBackup: true, privateKey: decrypted },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CHAT_KEY_GET]", error)
|
||||||
|
return new NextResponse("Internal Error", { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { encryptString } from "@/lib/encryption"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -9,15 +10,31 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Unauthorized", { status: 401 })
|
return new NextResponse("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { publicKey } = await req.json()
|
const { publicKey, privateKey } = await req.json() as {
|
||||||
|
publicKey?: string
|
||||||
|
privateKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
return new NextResponse("Missing publicKey", { status: 400 })
|
return new NextResponse("Missing publicKey", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chatPrivateKeyEncrypted: string | undefined
|
||||||
|
if (privateKey) {
|
||||||
|
try {
|
||||||
|
chatPrivateKeyEncrypted = encryptString(privateKey)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[CHAT_SETUP] Failed to encrypt private key", err)
|
||||||
|
return new NextResponse("Failed to encrypt private key", { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { chatPublicKey: publicKey }
|
data: {
|
||||||
|
chatPublicKey: publicKey,
|
||||||
|
...(chatPrivateKeyEncrypted ? { chatPrivateKeyEncrypted } : {})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
141
src/app/api/vendors/synced-products/[id]/route.ts
vendored
Normal file
141
src/app/api/vendors/synced-products/[id]/route.ts
vendored
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vendors/synced-products/[id]
|
||||||
|
* Get a single product visible on the vendor's New Products page.
|
||||||
|
* The ID may correspond to either:
|
||||||
|
* - A ProductSync (vendor-initiated sync), or
|
||||||
|
* - A SupplierProductOffer (supplier-provided product)
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
// Get vendor's active store
|
||||||
|
const vendorStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!vendorStore) {
|
||||||
|
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to find a ProductSync
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const sync = await (prisma as any).productSync.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
storeId: vendorStore.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
const productData = sync.shopifyProductData || {}
|
||||||
|
const product = {
|
||||||
|
id: sync.id,
|
||||||
|
syncId: sync.id,
|
||||||
|
shopifyProductId: sync.shopifyProductId,
|
||||||
|
title: sync.shopifyProductTitle,
|
||||||
|
description: productData.body_html || productData.description || null,
|
||||||
|
price: productData.variants?.[0]?.price || "0",
|
||||||
|
sku: productData.variants?.[0]?.sku || null,
|
||||||
|
imageUrl: productData.image?.src || productData.images?.[0]?.src || null,
|
||||||
|
supplier: {
|
||||||
|
id: sync.supplier.id,
|
||||||
|
companyName: sync.supplier.companyName
|
||||||
|
},
|
||||||
|
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||||
|
isSynced: true,
|
||||||
|
source: "synced",
|
||||||
|
shopifyData: productData
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ product })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try SupplierProductOffer
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
return NextResponse.json({ message: "Product not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const offer = await (prisma as any).supplierProductOffer.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
storeId: vendorStore.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
price: true,
|
||||||
|
sku: true,
|
||||||
|
imageUrl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!offer) {
|
||||||
|
return NextResponse.json({ message: "Product not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = {
|
||||||
|
id: offer.id,
|
||||||
|
syncId: null,
|
||||||
|
shopifyProductId: null,
|
||||||
|
title: offer.product.title,
|
||||||
|
description: offer.product.description,
|
||||||
|
price: offer.price.toFixed(2),
|
||||||
|
sku: offer.product.sku,
|
||||||
|
imageUrl: offer.product.imageUrl,
|
||||||
|
supplier: {
|
||||||
|
id: offer.supplier.id,
|
||||||
|
companyName: offer.supplier.companyName
|
||||||
|
},
|
||||||
|
syncedAt: offer.createdAt,
|
||||||
|
isSynced: false,
|
||||||
|
source: "supplier_offer",
|
||||||
|
offerQuantity: offer.quantity,
|
||||||
|
offerId: offer.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching synced product:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch product" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
68
src/app/api/vendors/synced-products/route.ts
vendored
68
src/app/api/vendors/synced-products/route.ts
vendored
@ -4,8 +4,10 @@ import { prisma } from "@/lib/prisma"
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/vendors/synced-products
|
* GET /api/vendors/synced-products
|
||||||
* Get all synced products for the vendor (read-only)
|
* Get all products available to the vendor (read-only)
|
||||||
* These are products that vendors have synced with suppliers
|
* Includes:
|
||||||
|
* - Products that vendors have synced with suppliers (ProductSync ACCEPTED)
|
||||||
|
* - Products that suppliers have provided directly to this vendor (SupplierProductOffer)
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@ -27,7 +29,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ products: [] })
|
return NextResponse.json({ products: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all ACCEPTED product syncs for this store
|
// Get all ACCEPTED product syncs for this store (vendor-initiated)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -65,12 +67,70 @@ export async function GET() {
|
|||||||
},
|
},
|
||||||
syncedAt: sync.acceptedAt || sync.createdAt,
|
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||||
isSynced: true, // Flag to indicate this is a synced product
|
isSynced: true, // Flag to indicate this is a synced product
|
||||||
|
source: "synced",
|
||||||
// Include full Shopify product data for reference
|
// Include full Shopify product data for reference
|
||||||
shopifyData: productData
|
shopifyData: productData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ products: syncedProducts })
|
// Get supplier-provided products for this store
|
||||||
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
|
// Return only synced products if SupplierProductOffer model is missing
|
||||||
|
return NextResponse.json({ products: syncedProducts })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const supplierOffers = await (prisma as any).supplierProductOffer.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: vendorStore.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
price: true,
|
||||||
|
sku: true,
|
||||||
|
imageUrl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const supplierProducts = supplierOffers.map((offer: any) => ({
|
||||||
|
id: offer.id,
|
||||||
|
syncId: null,
|
||||||
|
shopifyProductId: null,
|
||||||
|
title: offer.product.title,
|
||||||
|
description: offer.product.description,
|
||||||
|
// Store as string for consistency with existing response
|
||||||
|
price: offer.price.toFixed(2),
|
||||||
|
sku: offer.product.sku,
|
||||||
|
imageUrl: offer.product.imageUrl,
|
||||||
|
supplier: {
|
||||||
|
id: offer.supplier.id,
|
||||||
|
companyName: offer.supplier.companyName
|
||||||
|
},
|
||||||
|
syncedAt: offer.createdAt,
|
||||||
|
isSynced: false,
|
||||||
|
source: "supplier_offer",
|
||||||
|
offerQuantity: offer.quantity,
|
||||||
|
offerId: offer.id
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allProducts = [...syncedProducts, ...supplierProducts]
|
||||||
|
|
||||||
|
return NextResponse.json({ products: allProducts })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching synced products:", error)
|
console.error("Error fetching synced products:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -55,9 +55,7 @@ export default async function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
|
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<div className="hidden xs:block">
|
<NotificationBell />
|
||||||
<NotificationBell />
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex flex-col text-right">
|
<div className="hidden sm:flex flex-col text-right">
|
||||||
<span className="text-xs font-bold text-white truncate max-w-[120px]">
|
<span className="text-xs font-bold text-white truncate max-w-[120px]">
|
||||||
{session.user?.name || "Vendor User"}
|
{session.user?.name || "Vendor User"}
|
||||||
|
|||||||
@ -8,14 +8,16 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Eye,
|
Eye,
|
||||||
Building2,
|
Building2,
|
||||||
Calendar
|
Calendar,
|
||||||
|
X,
|
||||||
|
Info
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
interface SyncedProduct {
|
interface SyncedProduct {
|
||||||
id: string
|
id: string
|
||||||
syncId: string
|
syncId: string | null
|
||||||
shopifyProductId: string
|
shopifyProductId: string | null
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
price: string
|
price: string
|
||||||
@ -27,13 +29,19 @@ interface SyncedProduct {
|
|||||||
}
|
}
|
||||||
syncedAt: string
|
syncedAt: string
|
||||||
isSynced: boolean
|
isSynced: boolean
|
||||||
|
source?: "synced" | "supplier_offer"
|
||||||
shopifyData?: any
|
shopifyData?: any
|
||||||
|
offerQuantity?: number
|
||||||
|
offerId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewProductsPage() {
|
export default function NewProductsPage() {
|
||||||
const [products, setProducts] = useState<SyncedProduct[]>([])
|
const [products, setProducts] = useState<SyncedProduct[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<SyncedProduct | null>(null)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [detailError, setDetailError] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSyncedProducts()
|
fetchSyncedProducts()
|
||||||
@ -54,11 +62,33 @@ export default function NewProductsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openProductDetail = async (product: SyncedProduct) => {
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setDetailError("")
|
||||||
|
setDetailLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/vendors/synced-products/${product.id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || "Failed to load product")
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setSelectedProduct(data.product || product)
|
||||||
|
} catch (err) {
|
||||||
|
setDetailError(err instanceof Error ? err.message : "Failed to load product")
|
||||||
|
setSelectedProduct(product)
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">New Products</h1>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">New Products</h1>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">Products synced with your suppliers</p>
|
<p className="text-sm md:text-base text-slate-400 mt-1">
|
||||||
|
Products available from your connected suppliers
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -88,9 +118,11 @@ export default function NewProductsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<div
|
<button
|
||||||
key={product.id}
|
key={product.id}
|
||||||
className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden hover:border-indigo-500/50 transition-colors group"
|
type="button"
|
||||||
|
onClick={() => openProductDetail(product)}
|
||||||
|
className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden hover:border-indigo-500/50 transition-colors group text-left"
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-48 bg-white/10 overflow-hidden">
|
<div className="relative w-full h-48 bg-white/10 overflow-hidden">
|
||||||
{product.imageUrl ? (
|
{product.imageUrl ? (
|
||||||
@ -105,9 +137,9 @@ export default function NewProductsPage() {
|
|||||||
<ImageIcon className="w-12 h-12 text-slate-500" />
|
<ImageIcon className="w-12 h-12 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
<span className="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded-full text-xs font-bold uppercase">
|
<span className="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded-full text-xs font-bold uppercase">
|
||||||
Synced
|
{product.source === "supplier_offer" ? "From Supplier" : "Synced"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,7 +156,9 @@ export default function NewProductsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold text-white">${product.price}</p>
|
<p className="text-2xl font-bold text-white">
|
||||||
|
${parseFloat(product.price).toFixed(2)}
|
||||||
|
</p>
|
||||||
{product.sku && (
|
{product.sku && (
|
||||||
<p className="text-xs text-slate-500 font-mono">SKU: {product.sku}</p>
|
<p className="text-xs text-slate-500 font-mono">SKU: {product.sku}</p>
|
||||||
)}
|
)}
|
||||||
@ -136,17 +170,146 @@ export default function NewProductsPage() {
|
|||||||
<Building2 className="w-4 h-4" />
|
<Building2 className="w-4 h-4" />
|
||||||
<span className="truncate">{product.supplier.companyName}</span>
|
<span className="truncate">{product.supplier.companyName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
|
||||||
<Calendar className="w-3 h-3" />
|
<div className="flex items-center gap-2">
|
||||||
<span>Synced {new Date(product.syncedAt).toLocaleDateString()}</span>
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{product.source === "supplier_offer"
|
||||||
|
? `Provided ${new Date(product.syncedAt).toLocaleDateString()}`
|
||||||
|
: `Synced ${new Date(product.syncedAt).toLocaleDateString()}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{product.source === "supplier_offer" && product.offerQuantity !== undefined && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
Qty: {product.offerQuantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedProduct && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">{selectedProduct.title}</h2>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{selectedProduct.source === "supplier_offer"
|
||||||
|
? "Provided by supplier"
|
||||||
|
: "Synced product"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setDetailError("")
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
{detailLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm mb-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Loading latest details...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailError && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-300">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span>{detailError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="relative w-full h-56 bg-white/10 rounded-xl overflow-hidden">
|
||||||
|
{selectedProduct.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={selectedProduct.imageUrl}
|
||||||
|
alt={selectedProduct.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||||
|
<ImageIcon className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-sm text-slate-300">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Price
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
${parseFloat(selectedProduct.price).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{selectedProduct.source === "supplier_offer" &&
|
||||||
|
selectedProduct.offerQuantity !== undefined && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Quantity available from supplier:{" "}
|
||||||
|
{selectedProduct.offerQuantity}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedProduct.sku && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
SKU
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-xs text-slate-200">
|
||||||
|
{selectedProduct.sku}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Supplier
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-200">
|
||||||
|
{selectedProduct.supplier.companyName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{selectedProduct.source === "supplier_offer"
|
||||||
|
? `Provided on ${new Date(
|
||||||
|
selectedProduct.syncedAt
|
||||||
|
).toLocaleString()}`
|
||||||
|
: `Synced on ${new Date(
|
||||||
|
selectedProduct.syncedAt
|
||||||
|
).toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProduct.description && (
|
||||||
|
<div className="mt-2 text-sm text-slate-300 space-y-1">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{selectedProduct.description.replace(/<[^>]*>/g, "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -247,7 +247,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[250px] md:h-[350px] w-full">
|
<div className="h-[250px] md:h-[350px] w-full min-w-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
|
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
|
|||||||
@ -24,19 +24,52 @@ export default async function SupplierLayout({
|
|||||||
|
|
||||||
// Ensure supplier profile exists, create if not
|
// Ensure supplier profile exists, create if not
|
||||||
const { prisma } = await import("@/lib/prisma")
|
const { prisma } = await import("@/lib/prisma")
|
||||||
|
|
||||||
|
// Safety guard: make sure the corresponding User row exists in our application database.
|
||||||
|
// This can be missing after a database reset or if auth is backed by a different store.
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: session.user.id,
|
||||||
|
name: session.user.name,
|
||||||
|
email: session.user.email,
|
||||||
|
image: (session.user as { image?: string | null })?.image ?? null,
|
||||||
|
role: session.user.role,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let supplierProfile = await prisma.supplierProfile.findUnique({
|
let supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
where: { userId: session.user.id }
|
where: { userId: session.user.id }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!supplierProfile) {
|
if (!supplierProfile) {
|
||||||
// Create default supplier profile
|
// Create default supplier profile, but guard against inconsistent data
|
||||||
supplierProfile = await prisma.supplierProfile.create({
|
try {
|
||||||
data: {
|
supplierProfile = await prisma.supplierProfile.create({
|
||||||
userId: session.user.id,
|
data: {
|
||||||
companyName: session.user.name || "My Company",
|
userId: session.user.id,
|
||||||
description: null
|
companyName: session.user.name || "My Company",
|
||||||
|
description: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && (error as { code?: string }).code === "P2003") {
|
||||||
|
console.error(
|
||||||
|
"[SUPPLIER_LAYOUT] Failed to create supplier profile due to missing user row",
|
||||||
|
{ userId: session.user.id, error }
|
||||||
|
)
|
||||||
|
// Session says user exists but DB row is missing; surface a clear error instead of looping redirects.
|
||||||
|
throw new Error(
|
||||||
|
"Your supplier account is missing required data in the database. Please try logging out and back in, or contact support."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export default function SupplierProductsPage() {
|
|||||||
sku: "",
|
sku: "",
|
||||||
imageUrl: ""
|
imageUrl: ""
|
||||||
})
|
})
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -93,6 +94,7 @@ export default function SupplierProductsPage() {
|
|||||||
sku: "",
|
sku: "",
|
||||||
imageUrl: ""
|
imageUrl: ""
|
||||||
})
|
})
|
||||||
|
setImageFile(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +107,7 @@ export default function SupplierProductsPage() {
|
|||||||
sku: product.sku || "",
|
sku: product.sku || "",
|
||||||
imageUrl: product.imageUrl || ""
|
imageUrl: product.imageUrl || ""
|
||||||
})
|
})
|
||||||
|
setImageFile(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +147,23 @@ export default function SupplierProductsPage() {
|
|||||||
: "/api/supplier/products"
|
: "/api/supplier/products"
|
||||||
const method = editingProduct ? "PUT" : "POST"
|
const method = editingProduct ? "PUT" : "POST"
|
||||||
|
|
||||||
|
// If a new image file is selected, upload it first to get a URL
|
||||||
|
let finalImageUrl = formData.imageUrl || null
|
||||||
|
if (imageFile) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("file", imageFile)
|
||||||
|
const uploadRes = await fetch("/api/uploads/supplier-product-image", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd
|
||||||
|
})
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const data = await uploadRes.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || "Failed to upload image")
|
||||||
|
}
|
||||||
|
const uploadData = await uploadRes.json()
|
||||||
|
finalImageUrl = uploadData.url || null
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -152,7 +172,7 @@ export default function SupplierProductsPage() {
|
|||||||
description: formData.description || null,
|
description: formData.description || null,
|
||||||
price: parseFloat(formData.price),
|
price: parseFloat(formData.price),
|
||||||
sku: formData.sku || null,
|
sku: formData.sku || null,
|
||||||
imageUrl: formData.imageUrl || null
|
imageUrl: finalImageUrl
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -378,14 +398,31 @@ export default function SupplierProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-400 mb-2">Image URL</label>
|
<label className="block text-sm font-medium text-slate-400 mb-2">Product image</label>
|
||||||
<input
|
<div className="space-y-2">
|
||||||
type="url"
|
<input
|
||||||
value={formData.imageUrl}
|
type="file"
|
||||||
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
accept="image/*"
|
||||||
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
onChange={(e) => {
|
||||||
placeholder="https://example.com/image.jpg"
|
const file = e.target.files?.[0] || null
|
||||||
/>
|
setImageFile(file)
|
||||||
|
}}
|
||||||
|
className="block w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-500 file:text-white hover:file:bg-indigo-600"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Optional: you can also paste an external URL instead of uploading.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.imageUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
If you upload a file and also provide a URL, the uploaded image will be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
|
|||||||
629
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
Normal file
629
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
Normal file
@ -0,0 +1,629 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2
|
||||||
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
interface StoreSummary {
|
||||||
|
id: string
|
||||||
|
businessName: string
|
||||||
|
shopifyDomain: string | null
|
||||||
|
shopifyStoreName: string | null
|
||||||
|
email?: string | null
|
||||||
|
user: {
|
||||||
|
name: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionDetail {
|
||||||
|
id: string
|
||||||
|
status: "PENDING" | "CONNECTED" | "REJECTED"
|
||||||
|
createdAt: string
|
||||||
|
store: StoreSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
sku: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offer {
|
||||||
|
id: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
createdAt: string
|
||||||
|
product: Product
|
||||||
|
store: {
|
||||||
|
id: string
|
||||||
|
businessName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplierProduct {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
price: number
|
||||||
|
sku: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
isSynced?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplierVendorDetailPage() {
|
||||||
|
const params = useParams<{ connectionId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [connection, setConnection] = useState<ConnectionDetail | null>(null)
|
||||||
|
const [offers, setOffers] = useState<Offer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [success, setSuccess] = useState("")
|
||||||
|
|
||||||
|
const [showProvideModal, setShowProvideModal] = useState(false)
|
||||||
|
const [products, setProducts] = useState<SupplierProduct[]>([])
|
||||||
|
const [productsLoading, setProductsLoading] = useState(false)
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState<string>("")
|
||||||
|
const [priceInput, setPriceInput] = useState<string>("")
|
||||||
|
const [quantityInput, setQuantityInput] = useState<string>("")
|
||||||
|
const [submittingOffer, setSubmittingOffer] = useState(false)
|
||||||
|
|
||||||
|
const connectionId = params?.connectionId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectionId) return
|
||||||
|
fetchData()
|
||||||
|
}, [connectionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (success) {
|
||||||
|
const timer = setTimeout(() => setSuccess(""), 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [success])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
const timer = setTimeout(() => setError(""), 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
const [connRes, offersRes] = await Promise.all([
|
||||||
|
fetch(`/api/supplier/vendors/${connectionId}`),
|
||||||
|
fetch(`/api/supplier/vendors/${connectionId}/offers`)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!connRes.ok) {
|
||||||
|
const data = await connRes.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || "Failed to load vendor")
|
||||||
|
}
|
||||||
|
|
||||||
|
const connData = await connRes.json()
|
||||||
|
setConnection(connData.connection)
|
||||||
|
|
||||||
|
if (offersRes.ok) {
|
||||||
|
const offersData = await offersRes.json()
|
||||||
|
setOffers(offersData.offers || [])
|
||||||
|
} else {
|
||||||
|
setOffers([])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load vendor")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openProvideModal = async () => {
|
||||||
|
setShowProvideModal(true)
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
setSelectedProductId("")
|
||||||
|
setPriceInput("")
|
||||||
|
setQuantityInput("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProductsLoading(true)
|
||||||
|
const res = await fetch("/api/supplier/products")
|
||||||
|
if (!res.ok) throw new Error("Failed to load products")
|
||||||
|
const data = await res.json()
|
||||||
|
const allProducts: SupplierProduct[] = (data.products || []).filter(
|
||||||
|
(p: SupplierProduct) => !p.isSynced
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter out products already offered to this vendor
|
||||||
|
const offeredProductIds = new Set(offers.map((o) => o.product.id))
|
||||||
|
const availableProducts = allProducts.filter((p) => !offeredProductIds.has(p.id))
|
||||||
|
setProducts(availableProducts)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load products")
|
||||||
|
} finally {
|
||||||
|
setProductsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateOffer = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!connectionId || !selectedProductId) return
|
||||||
|
|
||||||
|
const price = parseFloat(priceInput)
|
||||||
|
const quantity = parseInt(quantityInput, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(price) || Number.isNaN(quantity)) {
|
||||||
|
setError("Please enter valid price and quantity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmittingOffer(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/vendors/${connectionId}/offers`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId: selectedProductId,
|
||||||
|
price,
|
||||||
|
quantity
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || "Failed to provide product")
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(data.message || "Product provided to vendor successfully")
|
||||||
|
setShowProvideModal(false)
|
||||||
|
setSelectedProductId("")
|
||||||
|
setPriceInput("")
|
||||||
|
setQuantityInput("")
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to provide product")
|
||||||
|
} finally {
|
||||||
|
setSubmittingOffer(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateOffer = async (offerId: string, price: number, quantity: number) => {
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ price, quantity })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || "Failed to update offer")
|
||||||
|
}
|
||||||
|
setSuccess(data.message || "Offer updated successfully")
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update offer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWithdrawOffer = async (offerId: string) => {
|
||||||
|
if (!confirm("Are you sure you want to withdraw this product from the vendor?")) return
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || "Failed to withdraw offer")
|
||||||
|
}
|
||||||
|
setSuccess(data.message || "Offer withdrawn successfully")
|
||||||
|
setOffers((prev) => prev.filter((o) => o.id !== offerId))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to withdraw offer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProduct = products.find((p) => p.id === selectedProductId) || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/supplier/vendors")}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||||
|
Vendor Details
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm md:text-base text-slate-400 mt-1">
|
||||||
|
View store information and provide products to this vendor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-emerald-500/20 border border-emerald-500/30 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span className="text-sm text-emerald-400 font-medium">{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||||
|
<span className="text-sm text-red-400 font-medium">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading || !connection ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white/5 rounded-xl p-6 border border-white/10 lg:col-span-1">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">
|
||||||
|
{connection.store.businessName}
|
||||||
|
</h2>
|
||||||
|
{connection.store.shopifyStoreName && (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{connection.store.shopifyStoreName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<span>{connection.store.user.email}</span>
|
||||||
|
</div>
|
||||||
|
{connection.store.shopifyDomain && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="truncate">{connection.store.shopifyDomain}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Connected on{" "}
|
||||||
|
{new Date(connection.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5 text-slate-400" />
|
||||||
|
Products provided to this vendor
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openProvideModal}
|
||||||
|
disabled={connection.status !== "CONNECTED"}
|
||||||
|
className="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Provide Product
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{offers.length === 0 ? (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-6 text-sm text-slate-400 flex flex-col items-center justify-center gap-2">
|
||||||
|
<Package className="w-8 h-8 opacity-30" />
|
||||||
|
<p>No products have been provided to this vendor yet.</p>
|
||||||
|
<p>Click "Provide Product" to send your first product.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.id}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-xl p-4 flex flex-col md:flex-row md:items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||||
|
{offer.product.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={offer.product.imageUrl}
|
||||||
|
alt={offer.product.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||||
|
<Package className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{offer.product.title}
|
||||||
|
</p>
|
||||||
|
{offer.product.sku && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
SKU: {offer.product.sku}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Provided on{" "}
|
||||||
|
{new Date(offer.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Price for this vendor
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
${offer.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Quantity
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{offer.quantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:flex-col md:items-stretch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateOffer(
|
||||||
|
offer.id,
|
||||||
|
offer.price,
|
||||||
|
offer.quantity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex-1 px-3 py-2 bg-white/10 hover:bg-white/15 text-slate-200 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleWithdrawOffer(offer.id)}
|
||||||
|
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProvideModal && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
|
<h2 className="text-lg font-bold text-white">
|
||||||
|
Provide product to {connection?.store.businessName}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowProvideModal(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateOffer} className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 px-6 py-4 overflow-y-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-slate-300">
|
||||||
|
Select product from your catalog
|
||||||
|
</label>
|
||||||
|
{productsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10 text-slate-400">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||||
|
Loading products...
|
||||||
|
</div>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
You have no available products to provide. Create a product in
|
||||||
|
your Products tab first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||||
|
{products.map((product) => (
|
||||||
|
<button
|
||||||
|
key={product.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProductId(product.id)
|
||||||
|
setPriceInput(product.price.toString())
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||||
|
selectedProductId === product.id
|
||||||
|
? "border-indigo-500 bg-indigo-500/10"
|
||||||
|
: "border-white/10 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative w-10 h-10 rounded-md overflow-hidden bg-white/10 flex-shrink-0">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-white truncate">
|
||||||
|
{product.title}
|
||||||
|
</p>
|
||||||
|
{product.sku && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
SKU: {product.sku}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-300 font-medium">
|
||||||
|
${product.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!selectedProduct && (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-slate-500 text-sm bg-white/5 border border-dashed border-white/10 rounded-xl px-4 py-6">
|
||||||
|
<p className="font-medium text-slate-300 mb-1">
|
||||||
|
Select a product to configure pricing
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 text-center">
|
||||||
|
Choose a product from your catalog on the left. Price and total
|
||||||
|
items will appear here after you make a selection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProduct && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
Price for this vendor
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={priceInput}
|
||||||
|
onChange={(e) => setPriceInput(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Base catalog price: ${selectedProduct.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
Total items (quantity)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={quantityInput}
|
||||||
|
onChange={(e) => setQuantityInput(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
placeholder="e.g. 100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 bg-white/5 border border-white/10 rounded-lg p-3 text-xs text-slate-400 space-y-1">
|
||||||
|
<p className="font-semibold text-slate-200">
|
||||||
|
Preview: {selectedProduct.title}
|
||||||
|
</p>
|
||||||
|
{selectedProduct.description && (
|
||||||
|
<p className="line-clamp-3">
|
||||||
|
{selectedProduct.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowProvideModal(false)}
|
||||||
|
className="px-4 py-2 rounded-lg border border-white/10 text-slate-200 hover:bg-white/5 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
submittingOffer ||
|
||||||
|
!selectedProductId ||
|
||||||
|
!priceInput ||
|
||||||
|
!quantityInput
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{submittingOffer ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Send product
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
30
src/app/supplier/vendors/page.tsx
vendored
30
src/app/supplier/vendors/page.tsx
vendored
@ -437,9 +437,15 @@ function SupplierVendorsContent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredConnections.map((connection) => (
|
{filteredConnections.map((connection) => (
|
||||||
<div
|
<button
|
||||||
key={connection.id}
|
key={connection.id}
|
||||||
className="bg-white/5 rounded-xl p-6 border border-white/10 hover:border-white/20 transition-colors"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (connection.status === "CONNECTED") {
|
||||||
|
router.push(`/supplier/vendors/${connection.id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-white/5 rounded-xl p-6 border border-white/10 hover:border-white/20 transition-colors text-left w-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -454,7 +460,11 @@ function SupplierVendorsContent() {
|
|||||||
</div>
|
</div>
|
||||||
{connection.status !== "PENDING" && (
|
{connection.status !== "PENDING" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(connection.id)}
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(connection.id)
|
||||||
|
}}
|
||||||
className="p-1 hover:bg-red-500/20 rounded text-slate-400 hover:text-red-400 transition-colors"
|
className="p-1 hover:bg-red-500/20 rounded text-slate-400 hover:text-red-400 transition-colors"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
>
|
>
|
||||||
@ -494,14 +504,22 @@ function SupplierVendorsContent() {
|
|||||||
{connection.status === "PENDING" && (
|
{connection.status === "PENDING" && (
|
||||||
<div className="flex gap-2 mt-4 pt-4 border-t border-white/10">
|
<div className="flex gap-2 mt-4 pt-4 border-t border-white/10">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(connection.id, "CONNECTED")}
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleStatusChange(connection.id, "CONNECTED")
|
||||||
|
}}
|
||||||
className="flex-1 px-3 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(connection.id, "REJECTED")}
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleStatusChange(connection.id, "REJECTED")
|
||||||
|
}}
|
||||||
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
@ -509,7 +527,7 @@ function SupplierVendorsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { X, Menu } from "lucide-react"
|
import { X, Menu } from "lucide-react"
|
||||||
@ -15,7 +16,11 @@ export function MobileSidebar() {
|
|||||||
const isLight = theme === "light"
|
const isLight = theme === "light"
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Close sidebar when route changes
|
// Close sidebar when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -52,77 +57,80 @@ export function MobileSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{isOpen && (
|
<AnimatePresence mode="wait">
|
||||||
<>
|
{isOpen && (
|
||||||
<motion.div
|
<>
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
key="sidebar-backdrop"
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
key="sidebar-backdrop"
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[9999] md:hidden pointer-events-auto"
|
transition={{ duration: 0.2 }}
|
||||||
onClick={() => setIsOpen(false)}
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999] md:hidden pointer-events-auto"
|
||||||
/>
|
onClick={() => setIsOpen(false)}
|
||||||
<motion.div
|
/>
|
||||||
initial={{ x: -288 }}
|
<motion.div
|
||||||
animate={{ x: 0 }}
|
initial={{ x: -288 }}
|
||||||
exit={{ x: -288 }}
|
animate={{ x: 0 }}
|
||||||
key="sidebar-content"
|
exit={{ x: -288 }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
key="sidebar-content"
|
||||||
className={cn(
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
"fixed inset-y-0 left-0 w-72 backdrop-blur-xl border-r z-[10000] md:hidden flex flex-col pointer-events-auto",
|
className={cn(
|
||||||
isLight
|
"fixed inset-y-0 left-0 w-72 backdrop-blur-xl border-r z-[100000] md:hidden flex flex-col pointer-events-auto",
|
||||||
? "bg-white/95 border-slate-200"
|
isLight
|
||||||
: "bg-slate-900/95 border-white/10"
|
? "bg-white/95 border-slate-200"
|
||||||
)}
|
: "bg-slate-900/95 border-white/10"
|
||||||
>
|
)}
|
||||||
{/* Header with close button */}
|
>
|
||||||
<div className={cn(
|
{/* Header with close button */}
|
||||||
"flex items-center justify-between p-4 border-b",
|
<div className={cn(
|
||||||
isLight ? "border-slate-100" : "border-white/10"
|
"flex items-center justify-between p-4 border-b",
|
||||||
)}>
|
isLight ? "border-slate-100" : "border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
)}>
|
||||||
<div className={cn(
|
<div className="flex items-center gap-2">
|
||||||
"w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border",
|
<div className={cn(
|
||||||
isLight
|
"w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border",
|
||||||
? "border-slate-200"
|
isLight
|
||||||
: "border-white/10"
|
? "border-slate-200"
|
||||||
)}>
|
: "border-white/10"
|
||||||
<Image
|
)}>
|
||||||
src="/logo.png"
|
<Image
|
||||||
alt="JUNOHUB Logo"
|
src="/logo.png"
|
||||||
width={36}
|
alt="JUNOHUB Logo"
|
||||||
height={36}
|
width={36}
|
||||||
className="object-cover"
|
height={36}
|
||||||
/>
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className={cn(
|
||||||
|
"text-xl font-bold tracking-tight",
|
||||||
|
isLight ? "text-slate-900" : "text-white"
|
||||||
|
)}>JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className={cn(
|
<button
|
||||||
"text-xl font-bold tracking-tight",
|
onClick={() => setIsOpen(false)}
|
||||||
isLight ? "text-slate-900" : "text-white"
|
className={cn(
|
||||||
)}>JUNOHUB</h1>
|
"p-2 rounded-lg transition-all border shadow-sm",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100"
|
||||||
|
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
||||||
|
)}
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Sidebar Content */}
|
||||||
onClick={() => setIsOpen(false)}
|
<div className="flex-1 overflow-y-auto">
|
||||||
className={cn(
|
<Sidebar showLogo={false} className="!bg-transparent !border-none" />
|
||||||
"p-2 rounded-lg transition-all border shadow-sm",
|
</div>
|
||||||
isLight
|
</motion.div>
|
||||||
? "bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100"
|
</>
|
||||||
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
)}
|
||||||
)}
|
</AnimatePresence>,
|
||||||
aria-label="Close menu"
|
document.body
|
||||||
>
|
)}
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/* Sidebar Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<Sidebar showLogo={false} className="!bg-transparent !border-none" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { X, Menu, Zap } from "lucide-react"
|
import { X, Menu } from "lucide-react"
|
||||||
import { SupplierSidebar } from "./SupplierSidebar"
|
import { SupplierSidebar } from "./SupplierSidebar"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
export function MobileSupplierSidebar() {
|
export function MobileSupplierSidebar() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Close sidebar when route changes
|
// Close sidebar when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setIsOpen(false), 0)
|
const timer = setTimeout(() => setIsOpen(false), 0)
|
||||||
@ -40,43 +48,55 @@ export function MobileSupplierSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{isOpen && (
|
<AnimatePresence mode="wait">
|
||||||
<>
|
{isOpen && (
|
||||||
<motion.div
|
<>
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
onClick={() => setIsOpen(false)}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[200] md:hidden"
|
key="supplier-sidebar-backdrop"
|
||||||
/>
|
transition={{ duration: 0.2 }}
|
||||||
<motion.div
|
onClick={() => setIsOpen(false)}
|
||||||
initial={{ x: -288 }}
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999] md:hidden"
|
||||||
animate={{ x: 0 }}
|
/>
|
||||||
exit={{ x: -288 }}
|
<motion.div
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
initial={{ x: -288 }}
|
||||||
className="fixed left-0 top-0 bottom-0 w-72 bg-slate-900/95 backdrop-blur-xl border-r border-white/10 z-[210] md:hidden overflow-y-auto"
|
animate={{ x: 0 }}
|
||||||
>
|
exit={{ x: -288 }}
|
||||||
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
key="supplier-sidebar-content"
|
||||||
<div className="flex items-center gap-2">
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center">
|
className="fixed left-0 top-0 bottom-0 w-72 bg-slate-900/95 backdrop-blur-xl border-r border-white/10 z-[100000] md:hidden overflow-y-auto"
|
||||||
<Zap className="w-5 h-5 text-white fill-white" />
|
>
|
||||||
|
<div className="p-4 flex items-center justify-between border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border border-white/10">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="JUNOHUB Logo"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-bold text-white">JUNOHUB</h1>
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-white transition-colors"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<SupplierSidebar showLogo={false} />
|
||||||
onClick={() => setIsOpen(false)}
|
</motion.div>
|
||||||
className="p-2 rounded-lg hover:bg-white/10 text-white transition-colors"
|
</>
|
||||||
aria-label="Close menu"
|
)}
|
||||||
>
|
</AnimatePresence>,
|
||||||
<X className="w-5 h-5" />
|
document.body
|
||||||
</button>
|
)}
|
||||||
</div>
|
|
||||||
<SupplierSidebar />
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Users,
|
Users,
|
||||||
Mail,
|
Mail,
|
||||||
Zap,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const supplierRoutes = [
|
const supplierRoutes = [
|
||||||
@ -45,17 +45,29 @@ const supplierRoutes = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SupplierSidebar() {
|
interface SupplierSidebarProps {
|
||||||
|
showLogo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplierSidebar({ showLogo = true }: SupplierSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 py-4 flex flex-col h-full text-white">
|
<div className="space-y-4 py-4 flex flex-col h-full text-white">
|
||||||
<div className="px-6 py-2 flex items-center mb-6">
|
{showLogo && (
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center mr-3 backdrop-blur-sm">
|
<div className="px-6 py-2 flex items-center mb-6">
|
||||||
<Zap className="w-5 h-5 text-white fill-white" />
|
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center mr-3 shadow-md border border-white/10">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="JUNOHUB Logo"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
||||||
{supplierRoutes.map((route) => (
|
{supplierRoutes.map((route) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { getSession } from "next-auth/react"
|
import { getSession, useSession } from "next-auth/react"
|
||||||
import { useChatCrypto, PRIVATE_KEY_STORAGE_KEY, PUBLIC_KEY_STORAGE_KEY } from "@/hooks/useChatCrypto"
|
import { useChatCrypto, PRIVATE_KEY_STORAGE_KEY, PUBLIC_KEY_STORAGE_KEY } from "@/hooks/useChatCrypto"
|
||||||
import { ChatCrypto } from "@/lib/chat-crypto"
|
import { ChatCrypto } from "@/lib/chat-crypto"
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
@ -69,7 +69,9 @@ export function ChatView() {
|
|||||||
const isSupplier = pathname.startsWith("/supplier")
|
const isSupplier = pathname.startsWith("/supplier")
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const isLight = theme === "light"
|
const isLight = theme === "light"
|
||||||
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto()
|
const { data: session } = useSession()
|
||||||
|
const userId = (session?.user as { id?: string })?.id
|
||||||
|
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto(userId)
|
||||||
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
@ -95,6 +97,11 @@ export function ChatView() {
|
|||||||
const handleSetup = async () => {
|
const handleSetup = async () => {
|
||||||
try {
|
try {
|
||||||
setContactsError(null)
|
setContactsError(null)
|
||||||
|
// Ensure we have a user id before trying to set up keys
|
||||||
|
if (!userId) {
|
||||||
|
setContactsError("Session not ready. Please refresh the page and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
await setupKeys()
|
await setupKeys()
|
||||||
await getSession()
|
await getSession()
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
@ -106,7 +113,7 @@ export function ChatView() {
|
|||||||
|
|
||||||
// Auto-setup chat keys when user has none (e.g. supplier who just accepted invitation)
|
// Auto-setup chat keys when user has none (e.g. supplier who just accepted invitation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current) return
|
if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current || !userId) return
|
||||||
autoSetupAttemptedRef.current = true
|
autoSetupAttemptedRef.current = true
|
||||||
setContactsError(null)
|
setContactsError(null)
|
||||||
setupKeys()
|
setupKeys()
|
||||||
|
|||||||
@ -19,16 +19,40 @@ export function useChatCrypto(userId?: string) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const stored = localStorage.getItem(privateKeyKey)
|
|
||||||
|
const stored = typeof window !== "undefined" ? localStorage.getItem(privateKeyKey) : null
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setPrivateKey(stored)
|
setPrivateKey(stored)
|
||||||
setHasKeys(true)
|
setHasKeys(true)
|
||||||
} else {
|
setLoading(false)
|
||||||
setPrivateKey(null)
|
return
|
||||||
setHasKeys(false)
|
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
}, [userId, privateKeyKey])
|
// No local key – try to restore from server backup
|
||||||
|
; (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/user/chat-key", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.hasBackup && data.privateKey && typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(privateKeyKey, data.privateKey as string)
|
||||||
|
localStorage.setItem(publicKeyKey, (data.publicKey as string | undefined) ?? localStorage.getItem(publicKeyKey) ?? "")
|
||||||
|
setPrivateKey(data.privateKey as string)
|
||||||
|
setHasKeys(true)
|
||||||
|
} else {
|
||||||
|
setPrivateKey(null)
|
||||||
|
setHasKeys(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("restore chat key failed", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [userId, privateKeyKey, publicKeyKey])
|
||||||
|
|
||||||
const setupKeys = useCallback(async () => {
|
const setupKeys = useCallback(async () => {
|
||||||
if (!userId) throw new Error("User ID required for chat setup")
|
if (!userId) throw new Error("User ID required for chat setup")
|
||||||
@ -44,7 +68,7 @@ export function useChatCrypto(userId?: string) {
|
|||||||
const res = await fetch("/api/user/chat-setup", {
|
const res = await fetch("/api/user/chat-setup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ publicKey: keys.publicKey })
|
body: JSON.stringify({ publicKey: keys.publicKey, privateKey: keys.privateKey })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Failed to save public key")
|
if (!res.ok) throw new Error("Failed to save public key")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { sendPushNotification } from "@/lib/push"
|
||||||
|
|
||||||
// Keep this union in sync with the NotificationType enum in prisma/schema.prisma
|
// Keep this union in sync with the NotificationType enum in prisma/schema.prisma
|
||||||
type NotificationType =
|
type NotificationType =
|
||||||
@ -18,6 +19,7 @@ interface CreateNotificationParams {
|
|||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,7 +30,8 @@ export async function createNotification({
|
|||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
metadata
|
metadata,
|
||||||
|
url
|
||||||
}: CreateNotificationParams) {
|
}: CreateNotificationParams) {
|
||||||
try {
|
try {
|
||||||
// Use dynamic access to handle environments where the Notification model
|
// Use dynamic access to handle environments where the Notification model
|
||||||
@ -43,6 +46,18 @@ export async function createNotification({
|
|||||||
metadata: metadata ? metadata : undefined
|
metadata: metadata ? metadata : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fire-and-forget push notification for this event, if push is enabled
|
||||||
|
if (url) {
|
||||||
|
sendPushNotification(userId, {
|
||||||
|
title,
|
||||||
|
body: message,
|
||||||
|
url
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.error("Error sending push notification for notification", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return notification
|
return notification
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating notification", error)
|
logger.error("Error creating notification", error)
|
||||||
@ -68,7 +83,8 @@ export async function notifyInvitationAccepted(
|
|||||||
connectionId,
|
connectionId,
|
||||||
supplierName,
|
supplierName,
|
||||||
storeName
|
storeName
|
||||||
}
|
},
|
||||||
|
url: "/dashboard/suppliers/my"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,7 +106,8 @@ export async function notifyInvitationReceived(
|
|||||||
invitationId,
|
invitationId,
|
||||||
vendorName,
|
vendorName,
|
||||||
storeName
|
storeName
|
||||||
}
|
},
|
||||||
|
url: "/supplier/vendors"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +132,8 @@ export async function notifyConnectionStatusChanged(
|
|||||||
connectionId,
|
connectionId,
|
||||||
status,
|
status,
|
||||||
otherPartyName
|
otherPartyName
|
||||||
}
|
},
|
||||||
|
url: "/dashboard"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +155,8 @@ export async function notifyProductSyncRequested(
|
|||||||
syncId,
|
syncId,
|
||||||
vendorName,
|
vendorName,
|
||||||
productTitle
|
productTitle
|
||||||
}
|
},
|
||||||
|
url: "/supplier/vendors"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +183,7 @@ export async function notifyProductSyncStatusChanged(
|
|||||||
supplierName,
|
supplierName,
|
||||||
productTitle,
|
productTitle,
|
||||||
status
|
status
|
||||||
}
|
},
|
||||||
|
url: "/dashboard/new-products"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/lib/s3.ts
Normal file
75
src/lib/s3.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// Prefer S3-specific env vars, fall back to generic AWS ones
|
||||||
|
const REGION =
|
||||||
|
process.env.SUPPLIER_PRODUCT_IMAGES_REGION ||
|
||||||
|
process.env.AWS_S3_REGION ||
|
||||||
|
process.env.AWS_REGION ||
|
||||||
|
process.env.AWS_DEFAULT_REGION ||
|
||||||
|
"us-east-1"
|
||||||
|
|
||||||
|
const BUCKET =
|
||||||
|
process.env.SUPPLIER_PRODUCT_IMAGES_BUCKET ||
|
||||||
|
process.env.AWS_S3_BUCKET_NAME ||
|
||||||
|
""
|
||||||
|
|
||||||
|
const ACCESS_KEY_ID = process.env.AWS_S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID
|
||||||
|
const SECRET_ACCESS_KEY = process.env.AWS_S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
if (!BUCKET) {
|
||||||
|
// We intentionally don't throw here to avoid crashing the app in environments
|
||||||
|
// where S3 hasn't been configured yet. The upload API will check and fail
|
||||||
|
// gracefully with a 500 + clear message.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
"[S3] SUPPLIER_PRODUCT_IMAGES_BUCKET or AWS_S3_BUCKET_NAME is not set. Supplier image uploads will fail."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const s3Client = new S3Client({
|
||||||
|
region: REGION,
|
||||||
|
credentials:
|
||||||
|
ACCESS_KEY_ID && SECRET_ACCESS_KEY
|
||||||
|
? {
|
||||||
|
accessKeyId: ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: SECRET_ACCESS_KEY
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function uploadSupplierProductImage(params: {
|
||||||
|
supplierId: string
|
||||||
|
fileBuffer: Buffer
|
||||||
|
contentType?: string
|
||||||
|
originalName?: string
|
||||||
|
}) {
|
||||||
|
if (!BUCKET) {
|
||||||
|
throw new Error("S3 bucket is not configured for supplier product images")
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionFromType =
|
||||||
|
params.contentType && params.contentType.includes("/")
|
||||||
|
? params.contentType.split("/")[1]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
(params.originalName && params.originalName.split(".").pop()) ||
|
||||||
|
extensionFromType ||
|
||||||
|
"bin"
|
||||||
|
|
||||||
|
const key = `supplier-products/${params.supplierId}/${crypto.randomUUID()}.${ext}`
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: BUCKET,
|
||||||
|
Key: key,
|
||||||
|
Body: params.fileBuffer,
|
||||||
|
ContentType: params.contentType
|
||||||
|
})
|
||||||
|
|
||||||
|
await s3Client.send(command)
|
||||||
|
|
||||||
|
const url = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`
|
||||||
|
return { key, url }
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user