Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e756b6553 | |||
| bdb5e0fe55 |
@@ -0,0 +1 @@
|
||||
{}
|
||||
+7
-2
@@ -3,8 +3,13 @@ import type { Config } from "drizzle-kit";
|
||||
export default {
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle/migrations",
|
||||
driver: "pg",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
host: process.env.DB_HOST!,
|
||||
port: Number(process.env.DB_PORT!),
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
ssl: false,
|
||||
},
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Current sql file was generated after introspecting the database
|
||||
-- If you want to run this migration please uncomment this code before executing migrations
|
||||
/*
|
||||
CREATE TABLE "product_category_mappings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"feed_name" varchar(255),
|
||||
"feed_category_value" varchar(255),
|
||||
"canonical_category_id" integer,
|
||||
"confidence_score" double precision,
|
||||
"last_reviewed_by" varchar(255),
|
||||
"last_reviewed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"parent_id" integer,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
CONSTRAINT "categories_slug_key" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "products" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"brand" varchar(255),
|
||||
"description" text,
|
||||
"upc" varchar(32),
|
||||
"mpn" varchar(64),
|
||||
"canonical_category_id" integer,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "offers" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"product_id" integer,
|
||||
"feed_name" varchar(255) NOT NULL,
|
||||
"feed_sku" varchar(255),
|
||||
"price" numeric(10, 2),
|
||||
"url" text,
|
||||
"in_stock" boolean,
|
||||
"vendor" varchar(255),
|
||||
"last_seen_at" timestamp DEFAULT now(),
|
||||
"raw_data" jsonb,
|
||||
CONSTRAINT "offers_product_id_feed_name_feed_sku_key" UNIQUE("product_id","feed_name","feed_sku"),
|
||||
CONSTRAINT "offers_feed_unique" UNIQUE("feed_name","feed_sku")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "offer_price_history" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"offer_id" integer,
|
||||
"price" numeric(10, 2) NOT NULL,
|
||||
"seen_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feeds" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"url" text,
|
||||
"last_imported_at" timestamp,
|
||||
CONSTRAINT "feeds_name_key" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "product_attributes" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"product_id" integer,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"value" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "product_category_mappings" ADD CONSTRAINT "product_category_mappings_canonical_category_id_fkey" FOREIGN KEY ("canonical_category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_canonical_category_id_fkey" FOREIGN KEY ("canonical_category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "offers" ADD CONSTRAINT "offers_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "offer_price_history" ADD CONSTRAINT "offer_price_history_offer_id_fkey" FOREIGN KEY ("offer_id") REFERENCES "public"."offers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "product_attributes" ADD CONSTRAINT "product_attributes_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
|
||||
*/
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP TABLE "product_category_mappings" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "categories" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "products" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "offers" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "offer_price_history" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "feeds" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "product_attributes" CASCADE;
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"prevId": "",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.product_category_mappings": {
|
||||
"name": "product_category_mappings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"feed_name": {
|
||||
"name": "feed_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"feed_category_value": {
|
||||
"name": "feed_category_value",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"canonical_category_id": {
|
||||
"name": "canonical_category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"confidence_score": {
|
||||
"name": "confidence_score",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_reviewed_by": {
|
||||
"name": "last_reviewed_by",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_reviewed_at": {
|
||||
"name": "last_reviewed_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"product_category_mappings_canonical_category_id_fkey": {
|
||||
"name": "product_category_mappings_canonical_category_id_fkey",
|
||||
"tableFrom": "product_category_mappings",
|
||||
"tableTo": "categories",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"canonical_category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.categories": {
|
||||
"name": "categories",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"categories_parent_id_fkey": {
|
||||
"name": "categories_parent_id_fkey",
|
||||
"tableFrom": "categories",
|
||||
"tableTo": "categories",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"parent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"categories_slug_key": {
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"nullsNotDistinct": false,
|
||||
"name": "categories_slug_key"
|
||||
}
|
||||
},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.products": {
|
||||
"name": "products",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"brand": {
|
||||
"name": "brand",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"upc": {
|
||||
"name": "upc",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"mpn": {
|
||||
"name": "mpn",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"canonical_category_id": {
|
||||
"name": "canonical_category_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"products_canonical_category_id_fkey": {
|
||||
"name": "products_canonical_category_id_fkey",
|
||||
"tableFrom": "products",
|
||||
"tableTo": "categories",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"canonical_category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.offers": {
|
||||
"name": "offers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"feed_name": {
|
||||
"name": "feed_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"feed_sku": {
|
||||
"name": "feed_sku",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "numeric(10, 2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"in_stock": {
|
||||
"name": "in_stock",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"vendor": {
|
||||
"name": "vendor",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"raw_data": {
|
||||
"name": "raw_data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"offers_product_id_fkey": {
|
||||
"name": "offers_product_id_fkey",
|
||||
"tableFrom": "offers",
|
||||
"tableTo": "products",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"product_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"offers_product_id_feed_name_feed_sku_key": {
|
||||
"columns": [
|
||||
"product_id",
|
||||
"feed_name",
|
||||
"feed_sku"
|
||||
],
|
||||
"nullsNotDistinct": false,
|
||||
"name": "offers_product_id_feed_name_feed_sku_key"
|
||||
},
|
||||
"offers_feed_unique": {
|
||||
"columns": [
|
||||
"feed_name",
|
||||
"feed_sku"
|
||||
],
|
||||
"nullsNotDistinct": false,
|
||||
"name": "offers_feed_unique"
|
||||
}
|
||||
},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.offer_price_history": {
|
||||
"name": "offer_price_history",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"offer_id": {
|
||||
"name": "offer_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "numeric(10, 2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seen_at": {
|
||||
"name": "seen_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"offer_price_history_offer_id_fkey": {
|
||||
"name": "offer_price_history_offer_id_fkey",
|
||||
"tableFrom": "offer_price_history",
|
||||
"tableTo": "offers",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"offer_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.feeds": {
|
||||
"name": "feeds",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_imported_at": {
|
||||
"name": "last_imported_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"feeds_name_key": {
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"nullsNotDistinct": false,
|
||||
"name": "feeds_name_key"
|
||||
}
|
||||
},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.product_attributes": {
|
||||
"name": "product_attributes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"product_attributes_product_id_fkey": {
|
||||
"name": "product_attributes_product_id_fkey",
|
||||
"tableFrom": "product_attributes",
|
||||
"tableTo": "products",
|
||||
"schemaTo": "public",
|
||||
"columnsFrom": [
|
||||
"product_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {},
|
||||
"policies": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "919e511e-3cff-4bd0-b4ec-20865db2d1d3",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1751488452074,
|
||||
"tag": "0000_luxuriant_albert_cleary",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1751488491929,
|
||||
"tag": "0001_superb_umar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { categories, productCategoryMappings, products, offers, offerPriceHistory, productAttributes } from "./schema";
|
||||
|
||||
export const productCategoryMappingsRelations = relations(productCategoryMappings, ({one}) => ({
|
||||
category: one(categories, {
|
||||
fields: [productCategoryMappings.canonicalCategoryId],
|
||||
references: [categories.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const categoriesRelations = relations(categories, ({one, many}) => ({
|
||||
productCategoryMappings: many(productCategoryMappings),
|
||||
category: one(categories, {
|
||||
fields: [categories.parentId],
|
||||
references: [categories.id],
|
||||
relationName: "categories_parentId_categories_id"
|
||||
}),
|
||||
categories: many(categories, {
|
||||
relationName: "categories_parentId_categories_id"
|
||||
}),
|
||||
products: many(products),
|
||||
}));
|
||||
|
||||
export const productsRelations = relations(products, ({one, many}) => ({
|
||||
category: one(categories, {
|
||||
fields: [products.canonicalCategoryId],
|
||||
references: [categories.id]
|
||||
}),
|
||||
offers: many(offers),
|
||||
productAttributes: many(productAttributes),
|
||||
}));
|
||||
|
||||
export const offersRelations = relations(offers, ({one, many}) => ({
|
||||
product: one(products, {
|
||||
fields: [offers.productId],
|
||||
references: [products.id]
|
||||
}),
|
||||
offerPriceHistories: many(offerPriceHistory),
|
||||
}));
|
||||
|
||||
export const offerPriceHistoryRelations = relations(offerPriceHistory, ({one}) => ({
|
||||
offer: one(offers, {
|
||||
fields: [offerPriceHistory.offerId],
|
||||
references: [offers.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const productAttributesRelations = relations(productAttributes, ({one}) => ({
|
||||
product: one(products, {
|
||||
fields: [productAttributes.productId],
|
||||
references: [products.id]
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,108 @@
|
||||
import { pgTable, foreignKey, serial, varchar, integer, doublePrecision, timestamp, unique, text, numeric, boolean, jsonb } from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
|
||||
|
||||
export const productCategoryMappings = pgTable("product_category_mappings", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
feedName: varchar("feed_name", { length: 255 }),
|
||||
feedCategoryValue: varchar("feed_category_value", { length: 255 }),
|
||||
canonicalCategoryId: integer("canonical_category_id"),
|
||||
confidenceScore: doublePrecision("confidence_score"),
|
||||
lastReviewedBy: varchar("last_reviewed_by", { length: 255 }),
|
||||
lastReviewedAt: timestamp("last_reviewed_at", { mode: 'string' }),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.canonicalCategoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "product_category_mappings_canonical_category_id_fkey"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const categories = pgTable("categories", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
parentId: integer("parent_id"),
|
||||
slug: varchar({ length: 255 }).notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: "categories_parent_id_fkey"
|
||||
}),
|
||||
unique("categories_slug_key").on(table.slug),
|
||||
]);
|
||||
|
||||
export const products = pgTable("products", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
brand: varchar({ length: 255 }),
|
||||
description: text(),
|
||||
upc: varchar({ length: 32 }),
|
||||
mpn: varchar({ length: 64 }),
|
||||
canonicalCategoryId: integer("canonical_category_id"),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.canonicalCategoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "products_canonical_category_id_fkey"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const offers = pgTable("offers", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
productId: integer("product_id"),
|
||||
feedName: varchar("feed_name", { length: 255 }).notNull(),
|
||||
feedSku: varchar("feed_sku", { length: 255 }),
|
||||
price: numeric({ precision: 10, scale: 2 }),
|
||||
url: text(),
|
||||
inStock: boolean("in_stock"),
|
||||
vendor: varchar({ length: 255 }),
|
||||
lastSeenAt: timestamp("last_seen_at", { mode: 'string' }).defaultNow(),
|
||||
rawData: jsonb("raw_data"),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.productId],
|
||||
foreignColumns: [products.id],
|
||||
name: "offers_product_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("offers_product_id_feed_name_feed_sku_key").on(table.productId, table.feedName, table.feedSku),
|
||||
unique("offers_feed_unique").on(table.feedName, table.feedSku),
|
||||
]);
|
||||
|
||||
export const offerPriceHistory = pgTable("offer_price_history", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
offerId: integer("offer_id"),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
seenAt: timestamp("seen_at", { mode: 'string' }).defaultNow(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.offerId],
|
||||
foreignColumns: [offers.id],
|
||||
name: "offer_price_history_offer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const feeds = pgTable("feeds", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
url: text(),
|
||||
lastImportedAt: timestamp("last_imported_at", { mode: 'string' }),
|
||||
}, (table) => [
|
||||
unique("feeds_name_key").on(table.name),
|
||||
]);
|
||||
|
||||
export const productAttributes = pgTable("product_attributes", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
productId: integer("product_id"),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
value: varchar({ length: 255 }).notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.productId],
|
||||
foreignColumns: [products.id],
|
||||
name: "product_attributes_product_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
|
||||
"frontMatter.taxonomy.contentTypes": [
|
||||
{
|
||||
"name": "default",
|
||||
"pageBundle": false,
|
||||
"previewPath": null,
|
||||
"fields": [
|
||||
{
|
||||
"title": "Title",
|
||||
"name": "title",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Description",
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Publishing date",
|
||||
"name": "date",
|
||||
"type": "datetime",
|
||||
"default": "{{now}}",
|
||||
"isPublishDate": true
|
||||
},
|
||||
{
|
||||
"title": "Content preview",
|
||||
"name": "preview",
|
||||
"type": "image"
|
||||
},
|
||||
{
|
||||
"title": "Is in draft",
|
||||
"name": "draft",
|
||||
"type": "draft"
|
||||
},
|
||||
{
|
||||
"title": "Tags",
|
||||
"name": "tags",
|
||||
"type": "tags"
|
||||
},
|
||||
{
|
||||
"title": "Categories",
|
||||
"name": "categories",
|
||||
"type": "categories"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"frontMatter.framework.id": "next",
|
||||
"frontMatter.content.publicFolder": "public",
|
||||
"frontMatter.preview.host": "http://localhost:3000"
|
||||
}
|
||||
Generated
+66
-4
@@ -16,7 +16,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"daisyui": "^4.7.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^14.2.30",
|
||||
@@ -33,6 +32,7 @@
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"typescript": "^5"
|
||||
@@ -159,7 +159,8 @@
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="
|
||||
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
@@ -197,6 +198,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
||||
"integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==",
|
||||
"deprecated": "Merged into tsx: https://tsx.is",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.18.20",
|
||||
"source-map-support": "^0.5.21"
|
||||
@@ -209,6 +211,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -224,6 +227,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -239,6 +243,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -254,6 +259,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -269,6 +275,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -284,6 +291,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -299,6 +307,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -314,6 +323,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -329,6 +339,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -344,6 +355,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -359,6 +371,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -374,6 +387,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -389,6 +403,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -404,6 +419,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -419,6 +435,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -434,6 +451,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -449,6 +467,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -464,6 +483,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -479,6 +499,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -494,6 +515,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -509,6 +531,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -524,6 +547,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -536,6 +560,7 @@
|
||||
"version": "0.18.20",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
||||
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
@@ -573,6 +598,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
|
||||
"integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==",
|
||||
"deprecated": "Merged into tsx: https://tsx.is",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@esbuild-kit/core-utils": "^3.3.2",
|
||||
"get-tsconfig": "^4.7.0"
|
||||
@@ -585,6 +611,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -600,6 +627,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -615,6 +643,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -630,6 +659,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -645,6 +675,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -660,6 +691,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -675,6 +707,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -690,6 +723,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -705,6 +739,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -720,6 +755,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -735,6 +771,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -750,6 +787,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -765,6 +803,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -780,6 +819,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -795,6 +835,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -810,6 +851,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -825,6 +867,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -840,6 +883,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -855,6 +899,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -870,6 +915,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -885,6 +931,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -900,6 +947,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -915,6 +963,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -930,6 +979,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -945,6 +995,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2652,7 +2703,8 @@
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
@@ -2985,6 +3037,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -3063,6 +3116,8 @@
|
||||
"version": "0.31.4",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz",
|
||||
"integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||
@@ -3399,6 +3454,7 @@
|
||||
"version": "0.25.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
|
||||
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
@@ -3438,6 +3494,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
@@ -4136,6 +4193,7 @@
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
|
||||
"integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
@@ -5059,7 +5117,8 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
@@ -6038,6 +6097,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
@@ -6297,6 +6357,7 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6313,6 +6374,7 @@
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
|
||||
+1
-1
@@ -17,7 +17,6 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"daisyui": "^4.7.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^14.2.30",
|
||||
@@ -34,6 +33,7 @@
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ForgotPasswordPage() {
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
|
||||
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
|
||||
Enter your email address and we'll send you a link to reset your password.<br/>
|
||||
<span className="text-blue-600 font-semibold">(This feature is not yet implemented.)</span>
|
||||
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
@@ -38,7 +38,7 @@ export default function ForgotPasswordPage() {
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Back to login</Link>
|
||||
<Link href="/account/login" className="text-primary hover:underline text-sm">Back to login</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Or{' '}
|
||||
<Link href="/account/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
<Link href="/account/register" className="font-medium text-primary hover:text-blue-500">
|
||||
Sign Up For Free
|
||||
</Link>
|
||||
</p>
|
||||
@@ -109,7 +109,7 @@ export default function LoginPage() {
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-4 w-4 text-primary focus:ring-blue-500 border-gray-300 rounded"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
@@ -118,7 +118,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link href="/account/forgot-password" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
<Link href="/account/forgot-password" className="font-medium text-primary hover:text-blue-500">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function RegisterPage() {
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/account/login" className="text-blue-600 hover:underline text-sm">Already have an account? Sign in</Link>
|
||||
<Link href="/account/login" className="text-primary hover:underline text-sm">Already have an account? Sign in</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -261,6 +261,9 @@ const getProductCategoryForComponent = (componentName: string): string => {
|
||||
return componentMap[componentName] || 'Lower Receiver';
|
||||
};
|
||||
|
||||
// Add a slugify helper at the top of the file
|
||||
const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||
|
||||
export { buildGroups };
|
||||
export default function BuildPage() {
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
@@ -404,7 +407,7 @@ export default function BuildPage() {
|
||||
</div>
|
||||
<div className="text-center flex flex-col items-center md:flex-row md:justify-center md:items-center gap-2">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600">${actualTotalCost.toFixed(2)}</div>
|
||||
<div className="text-2xl font-bold text-primary">${actualTotalCost.toFixed(2)}</div>
|
||||
<div className="text-sm text-gray-500">Total Cost</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -616,9 +619,6 @@ export default function BuildPage() {
|
||||
<span className="text-sm">{getSortIcon('estimatedPrice')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Selected Product
|
||||
</th>
|
||||
@@ -638,7 +638,7 @@ export default function BuildPage() {
|
||||
<React.Fragment key={group.name}>
|
||||
{/* Group Header */}
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={7} className="px-6 py-2">
|
||||
<td colSpan={5} className="px-6 py-2">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">{group.name}</h3>
|
||||
@@ -673,7 +673,7 @@ export default function BuildPage() {
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<Link
|
||||
href={`/products/${selected.id}`}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
className="text-primary hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{selected.name}
|
||||
</Link>
|
||||
@@ -709,11 +709,6 @@ export default function BuildPage() {
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-500 max-w-xs">
|
||||
{component.notes}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{selected ? (
|
||||
<button
|
||||
@@ -724,7 +719,7 @@ export default function BuildPage() {
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
|
||||
href={`/parts/${slugify(getProductCategoryForComponent(component.name))}`}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Find Parts
|
||||
@@ -739,7 +734,7 @@ export default function BuildPage() {
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<td colSpan={5} className="px-6 py-12 text-center">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">No components found</div>
|
||||
<div className="text-sm">Try adjusting your filters or search terms</div>
|
||||
@@ -757,7 +752,7 @@ export default function BuildPage() {
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {sortedComponents.length} of {allComponents.length} components
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
<span className="ml-2 text-primary">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||
<div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">In Progress</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function MyBuildsPage() {
|
||||
<div className="text-sm text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{inProgressMyBuilds}</div>
|
||||
<div className="text-2xl font-bold text-primary">{inProgressMyBuilds}</div>
|
||||
<div className="text-sm text-gray-500">In Progress</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function LandingPage() {
|
||||
<div className="mt-10 flex items-top gap-x-6">
|
||||
<Link
|
||||
href="/build"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-base font-semibold px-6 py-3 rounded-md transition-colors"
|
||||
className="bg-gray-900 hover:bg-gray-950 text-white text-base font-semibold px-6 py-3 transition-colors"
|
||||
>
|
||||
Get Building
|
||||
</Link>
|
||||
@@ -52,6 +52,7 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Beta Tester CTA */}
|
||||
|
||||
<BetaTester />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
const columns = [
|
||||
'name',
|
||||
'brand',
|
||||
'description',
|
||||
'slug',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
export default function PartsCategoryPage() {
|
||||
const params = useParams();
|
||||
const categoryParam = params?.category as string;
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(50);
|
||||
|
||||
// Filter state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [subcategory, setSubcategory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/products?page=1&limit=10000`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setProducts(data.data || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message || 'Error fetching products');
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Get unique filter options from all products in this category
|
||||
const filteredByCategory = useMemo(() => {
|
||||
if (!categoryParam) return [];
|
||||
// Normalize category param to slug (kebab-case, lowercased)
|
||||
const slugify = (str: string) => str?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
|
||||
const paramSlug = slugify(categoryParam);
|
||||
return products.filter(p => {
|
||||
const cat = p.category || '';
|
||||
const catSlug = slugify(cat);
|
||||
// Match by slug, by lowercased, or by plural/singular
|
||||
return (
|
||||
catSlug === paramSlug ||
|
||||
cat.toLowerCase() === categoryParam.toLowerCase() ||
|
||||
cat.toLowerCase() === categoryParam.toLowerCase().replace(/s$/, '') ||
|
||||
cat.toLowerCase().replace(/s$/, '') === categoryParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
}, [products, categoryParam]);
|
||||
|
||||
const brandOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.brand).filter(Boolean))).sort(), [filteredByCategory]);
|
||||
const departmentOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.department).filter(Boolean))).sort(), [filteredByCategory]);
|
||||
const subcategoryOptions = useMemo(() => Array.from(new Set(filteredByCategory.map(p => p.subcategory).filter(Boolean))).sort(), [filteredByCategory]);
|
||||
|
||||
// Further filter by sidebar filters
|
||||
const filteredProducts = useMemo(() => {
|
||||
return filteredByCategory.filter(p =>
|
||||
(!brand || p.brand === brand) &&
|
||||
(!department || p.department === department) &&
|
||||
(!subcategory || p.subcategory === subcategory)
|
||||
);
|
||||
}, [filteredByCategory, brand, department, subcategory]);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredProducts.length / limit);
|
||||
const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => { setPage(1); }, [brand, department, subcategory, limit, categoryParam]);
|
||||
|
||||
// If category is not found
|
||||
if (!categoryParam) {
|
||||
return <div className="max-w-7xl mx-auto px-4 py-8 text-red-600 font-bold">Category not found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50">
|
||||
<div className="max-w-7xl mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
|
||||
{/* Debug block */}
|
||||
<div className="mb-4 p-2 bg-yellow-100 text-xs text-yellow-900 rounded">
|
||||
<div>categoryParam: <b>{categoryParam}</b></div>
|
||||
<div>First 5 categories in products: {[...new Set(products.map(p => p.category))].slice(0,5).join(', ')}</div>
|
||||
</div>
|
||||
{/* Sidebar Filters */}
|
||||
<aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Department</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={department} onChange={e => setDepartment(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Products Table */}
|
||||
<section className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-4 capitalize">{categoryParam} Parts</h1>
|
||||
<div className="overflow-x-auto border rounded bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||
) : paginatedProducts.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||
) : (
|
||||
paginatedProducts
|
||||
.filter(product => columns.some(col => product[col] && String(product[col]).trim() !== ''))
|
||||
.map((product, i) => (
|
||||
<tr key={product.id || i} className="border-b hover:bg-zinc-50">
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||
{typeof product[col] === 'object' && product[col] !== null
|
||||
? JSON.stringify(product[col])
|
||||
: product[col] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>Prev</button>
|
||||
<span>Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>Next</button>
|
||||
<select
|
||||
className="ml-4 border rounded px-2 py-1"
|
||||
value={limit}
|
||||
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||
>
|
||||
{[25, 50, 100, 200].map(opt => (
|
||||
<option key={opt} value={opt}>{opt} / page</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+193
-618
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon, CheckIcon, XMarkIcon, TableCellsIcon, Squares2X2Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||
@@ -12,7 +12,6 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useBuildStore } from '@/store/useBuildStore';
|
||||
import { buildGroups } from '../build/page';
|
||||
import { categoryToComponentType, standardizedComponentTypes, mapToBuilderType, builderCategories, subcategoryMapping } from './categoryMapping';
|
||||
|
||||
// Product type (copied from mock/product for type safety)
|
||||
type Product = {
|
||||
@@ -170,7 +169,7 @@ const Dropdown = ({
|
||||
{option.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -222,11 +221,6 @@ const getComponentCategory = (productCategory: string): string => {
|
||||
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
|
||||
};
|
||||
|
||||
// Map product categories to specific checklist component names
|
||||
const getMatchingComponentName = (productCategory: string): string => {
|
||||
return categoryToComponentType[productCategory] || '';
|
||||
};
|
||||
|
||||
// Pagination Component
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
@@ -344,640 +338,221 @@ const Pagination = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
// --- Canonical Category Fetch ---
|
||||
const useCanonicalCategories = () => {
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState('all');
|
||||
const [selectedSubcategoryId, setSelectedSubcategoryId] = useState('all');
|
||||
const [selectedBrand, setSelectedBrand] = useState('All');
|
||||
const [selectedVendor, setSelectedVendor] = useState('All');
|
||||
const [priceRange, setPriceRange] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRestriction, setSelectedRestriction] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||
const [addedPartIds, setAddedPartIds] = useState<string[]>([]);
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
|
||||
const selectedParts = useBuildStore((state) => state.selectedParts);
|
||||
const removePartForComponent = useBuildStore((state) => state.removePartForComponent);
|
||||
|
||||
// Fetch live data from /api/test-products
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch('/api/test-products')
|
||||
fetch('/api/categories')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
// Map API data to Product type
|
||||
const mapped: Product[] = data.data.slice(0, 50).map((item: any) => ({
|
||||
id: item.uuid,
|
||||
name: item.productName,
|
||||
description: item.shortDescription || item.longDescription || '',
|
||||
longDescription: item.longDescription,
|
||||
image_url: item.imageUrl || item.thumbUrl || '/window.svg',
|
||||
images: [item.imageUrl, item.thumbUrl].filter(Boolean),
|
||||
brand: {
|
||||
id: item.brandName || 'unknown',
|
||||
name: item.brandName || 'Unknown',
|
||||
logo: item.brandLogoImage || '',
|
||||
},
|
||||
category: {
|
||||
id: item.category || 'unknown',
|
||||
name: item.category || 'Unknown',
|
||||
},
|
||||
subcategory: item.subcategory,
|
||||
offers: [
|
||||
{
|
||||
price: parseFloat(item.salePrice || item.retailPrice || '0'),
|
||||
url: item.buyLink || '',
|
||||
vendor: {
|
||||
name: 'Brownells', // Static for now, or parse from buyLink if needed
|
||||
logo: '',
|
||||
},
|
||||
inStock: true,
|
||||
shipping: '',
|
||||
},
|
||||
],
|
||||
restrictions: {}, // Could infer from department/category if needed
|
||||
slug: item.slug || '',
|
||||
}));
|
||||
setProducts(mapped);
|
||||
// Log unique categories for mapping
|
||||
const uniqueCategories = Array.from(new Set(mapped.map(p => p.category.name)));
|
||||
console.log('Unique categories from live data:', uniqueCategories);
|
||||
} else {
|
||||
setError('No data returned from API');
|
||||
}
|
||||
setCategories(data.data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
return { categories, loading };
|
||||
};
|
||||
|
||||
// --- Flatten category tree for dropdowns ---
|
||||
function flattenCategories(categories: any[], parent: any = null, depth = 0): any[] {
|
||||
let result: any[] = [];
|
||||
for (const cat of categories) {
|
||||
result.push({ ...cat, depth, parent });
|
||||
if (cat.children && cat.children.length > 0) {
|
||||
result = result.concat(flattenCategories(cat.children, cat, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Helper: Get all descendant category IDs ---
|
||||
function getDescendantCategoryIds(categories: any[], selectedId: string): string[] {
|
||||
const result: string[] = [];
|
||||
function traverse(nodes: any[]) {
|
||||
for (const node of nodes) {
|
||||
if (String(node.id) === selectedId) {
|
||||
collect(node);
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
function collect(node: any) {
|
||||
result.push(String(node.id));
|
||||
if (node.children && node.children.length > 0) {
|
||||
for (const child of node.children) {
|
||||
collect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
traverse(categories);
|
||||
return result;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
'name',
|
||||
'brand',
|
||||
'description',
|
||||
'slug',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
export default function PartsPage() {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(50);
|
||||
|
||||
// Filter state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [subcategory, setSubcategory] = useState('');
|
||||
|
||||
// Category data from canonical API
|
||||
const { categories, loading: categoriesLoading } = useCanonicalCategories();
|
||||
const flatCategories = useMemo(() => flattenCategories(categories), [categories]);
|
||||
|
||||
// Updated filter options
|
||||
const categoryOptions = useMemo(
|
||||
() => flatCategories.filter(cat => cat.parentId === null).map(cat => ({ value: String(cat.id), label: cat.name })),
|
||||
[flatCategories]
|
||||
);
|
||||
const subcategoryOptions = useMemo(
|
||||
() => flatCategories.filter(cat => cat.parentId === parseInt(category)).map(cat => ({ value: String(cat.id), label: cat.name })),
|
||||
[flatCategories, category]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/products?page=1&limit=10000`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setProducts(data.data || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(String(err));
|
||||
setError(err.message || 'Error fetching products');
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Extract unique values for dropdowns from live data
|
||||
const categories = [{ id: 'all', name: 'All Categories' }, ...builderCategories.map(cat => ({ id: cat.id, name: cat.name }))];
|
||||
const brands = [{ value: 'All', label: 'All Brands' }, ...Array.from(new Set(products.map(part => part.brand.name))).map(name => ({ value: name, label: name }))];
|
||||
const vendors = [{ value: 'All', label: 'All Vendors' }, ...Array.from(new Set(products.flatMap(part => part.offers.map(offer => offer.vendor.name)))).map(name => ({ value: name, label: name }))];
|
||||
// Get unique filter options from all products
|
||||
const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
|
||||
const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
|
||||
|
||||
// Read category from URL parameter on page load
|
||||
useEffect(() => {
|
||||
const categoryParam = searchParams.get('category');
|
||||
if (categoryParam && categories.some(c => c.id === categoryParam)) {
|
||||
setSelectedCategoryId(categoryParam);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, categories.map(c => c.id).join(',')]);
|
||||
// Filter products before rendering (match by category/subcategory ID if available)
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(p =>
|
||||
(!brand || p.brandName === brand) &&
|
||||
(!department || p.department === department) &&
|
||||
(!category || String(p.categoryId) === category) &&
|
||||
(!subcategory || String(p.subcategoryId) === subcategory)
|
||||
);
|
||||
}, [products, brand, department, category, subcategory]);
|
||||
|
||||
const selectedCategory = builderCategories.find(cat => cat.id === selectedCategoryId);
|
||||
const subcategoryOptions = selectedCategory
|
||||
? [{ id: 'all', name: 'All Subcategories' }, ...selectedCategory.subcategories]
|
||||
: [];
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredProducts.length / limit);
|
||||
const paginatedProducts = filteredProducts.slice((page - 1) * limit, page * limit);
|
||||
|
||||
// Filter parts based on selected criteria
|
||||
const filteredParts = products.filter(part => {
|
||||
const mappedSubcat = subcategoryMapping[part.subcategory || ''];
|
||||
const matchesCategory = selectedCategoryId === 'all' || (selectedCategory && selectedCategory.subcategories.some(sub => sub.id === mappedSubcat));
|
||||
const matchesSubcategory = selectedSubcategoryId === 'all' || mappedSubcat === selectedSubcategoryId;
|
||||
const matchesBrand = selectedBrand === 'All' || part.brand.name === selectedBrand;
|
||||
const matchesVendor = selectedVendor === 'All' || part.offers.some(offer => offer.vendor.name === selectedVendor);
|
||||
const matchesSearch = !searchTerm ||
|
||||
part.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
part.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
part.brand.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Restriction filter logic (no real data, so always true)
|
||||
let matchesRestriction = true;
|
||||
if (selectedRestriction) {
|
||||
matchesRestriction = false;
|
||||
}
|
||||
|
||||
// Price range filtering
|
||||
let matchesPrice = true;
|
||||
if (priceRange) {
|
||||
const lowestPrice = Math.min(...part.offers.map(offer => offer.price));
|
||||
switch (priceRange) {
|
||||
case 'under-100':
|
||||
matchesPrice = lowestPrice < 100;
|
||||
break;
|
||||
case '100-300':
|
||||
matchesPrice = lowestPrice >= 100 && lowestPrice <= 300;
|
||||
break;
|
||||
case '300-500':
|
||||
matchesPrice = lowestPrice > 300 && lowestPrice <= 500;
|
||||
break;
|
||||
case 'over-500':
|
||||
matchesPrice = lowestPrice > 500;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matchesCategory && matchesSubcategory && matchesBrand && matchesVendor && matchesSearch && matchesPrice && matchesRestriction;
|
||||
});
|
||||
|
||||
// Sort parts
|
||||
const sortedParts = [...filteredParts].sort((a, b) => {
|
||||
let aValue: any, bValue: any;
|
||||
if (sortField === 'price') {
|
||||
aValue = Math.min(...a.offers.map(offer => offer.price));
|
||||
bValue = Math.min(...b.offers.map(offer => offer.price));
|
||||
} else if (sortField === 'category') {
|
||||
aValue = a.category.name.toLowerCase();
|
||||
bValue = b.category.name.toLowerCase();
|
||||
} else {
|
||||
aValue = a.name.toLowerCase();
|
||||
bValue = b.name.toLowerCase();
|
||||
}
|
||||
if (sortDirection === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(sortedParts.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedParts = sortedParts.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [selectedCategoryId, selectedSubcategoryId, selectedBrand, selectedVendor, searchTerm, priceRange, selectedRestriction, sortField, sortDirection]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) {
|
||||
return '↕️';
|
||||
}
|
||||
return sortDirection === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategoryId('all');
|
||||
setSelectedSubcategoryId('all');
|
||||
setSelectedBrand('All');
|
||||
setSelectedVendor('All');
|
||||
setSearchTerm('');
|
||||
setPriceRange('');
|
||||
setSelectedRestriction('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = selectedCategoryId !== 'all' || selectedBrand !== 'All' || selectedVendor !== 'All' || searchTerm || priceRange || selectedRestriction;
|
||||
|
||||
// RestrictionBadge for table view (show NFA/SBR/Suppressor/State)
|
||||
const getRestrictionFlags = (restrictions?: Product['restrictions']) => {
|
||||
const flags: string[] = [];
|
||||
if (restrictions?.nfa) flags.push('NFA');
|
||||
if (restrictions?.sbr) flags.push('SBR');
|
||||
if (restrictions?.suppressor) flags.push('Suppressor');
|
||||
if (restrictions?.stateRestrictions && restrictions.stateRestrictions.length > 0) flags.push('State Restrictions');
|
||||
return flags;
|
||||
};
|
||||
|
||||
const handleAdd = (part: Product) => {
|
||||
setAddedPartIds((prev) => [...prev, part.id]);
|
||||
setTimeout(() => setAddedPartIds((prev) => prev.filter((id) => id !== part.id)), 1500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (products.length) {
|
||||
const uniqueCategories = Array.from(new Set(products.map(p => p.category?.name)));
|
||||
const uniqueSubcategories = Array.from(new Set(products.map(p => p.subcategory)));
|
||||
console.log('Unique categories:', uniqueCategories);
|
||||
console.log('Unique subcategories:', uniqueSubcategories);
|
||||
}
|
||||
}, [products]);
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => { setPage(1); }, [brand, department, category, subcategory, limit]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50">
|
||||
{/* Page Title */}
|
||||
<div className="bg-white border-b border-zinc-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-zinc-900">
|
||||
Parts Catalog
|
||||
{selectedCategory && selectedCategoryId !== 'all' && (
|
||||
<span className="text-primary-600 ml-2 text-2xl">
|
||||
- {selectedCategory.name}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-zinc-600 mt-2">
|
||||
{selectedCategory && selectedCategoryId !== 'all'
|
||||
? `Showing ${selectedCategory.name} parts for your build`
|
||||
: 'Browse and filter firearm parts for your build'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white border-b border-zinc-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
{/* Search Row */}
|
||||
<div className="mb-3 flex justify-end">
|
||||
<div className={`transition-all duration-300 ease-in-out flex justify-end ${isSearchExpanded ? 'w-1/2' : 'w-auto'}`}>
|
||||
{isSearchExpanded ? (
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<div className="flex-1 max-w-md">
|
||||
<SearchInput
|
||||
label=""
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search parts..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSearchExpanded(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors flex-shrink-0"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsSearchExpanded(true)}
|
||||
className="p-2 text-zinc-500 hover:text-zinc-700 transition-colors rounded-lg hover:bg-zinc-100"
|
||||
aria-label="Open search"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 lg:grid-cols-7 gap-3">
|
||||
{/* Category Dropdown */}
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Category"
|
||||
value={selectedCategoryId}
|
||||
onChange={setSelectedCategoryId}
|
||||
options={categories.map(c => ({ value: c.id, label: c.name }))}
|
||||
placeholder="All categories"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subcategory Dropdown (only if a category is selected) */}
|
||||
{selectedCategory && selectedCategoryId !== 'all' && (
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Subcategory"
|
||||
value={selectedSubcategoryId}
|
||||
onChange={setSelectedSubcategoryId}
|
||||
options={subcategoryOptions.map(s => ({ value: s.id, label: s.name }))}
|
||||
placeholder="All subcategories"
|
||||
/>
|
||||
<>
|
||||
<main className="min-h-screen bg-zinc-50">
|
||||
<div className="max-w-full mx-0 px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row gap-8">
|
||||
{/* Sidebar Filters */}
|
||||
<aside className="w-full md:w-64 flex-shrink-0 md:sticky md:top-8 z-10 bg-white border border-zinc-200 rounded-lg p-4 h-fit mb-6 md:mb-0">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Category</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={category} onChange={e => { setCategory(e.target.value); setSubcategory(''); }}>
|
||||
<option value="">All</option>
|
||||
{categoryOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px] w-full" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{subcategoryOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand Dropdown */}
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Brand"
|
||||
value={selectedBrand}
|
||||
onChange={setSelectedBrand}
|
||||
options={brands}
|
||||
placeholder="All brands"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor Dropdown */}
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Vendor"
|
||||
value={selectedVendor}
|
||||
onChange={setSelectedVendor}
|
||||
options={vendors}
|
||||
placeholder="All vendors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="col-span-1">
|
||||
<Listbox value={priceRange} onChange={setPriceRange}>
|
||||
<div className="relative">
|
||||
<Listbox.Label className="block text-sm font-medium text-zinc-700 mb-1">
|
||||
Price Range
|
||||
</Listbox.Label>
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-zinc-300 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
|
||||
<span className="block truncate text-zinc-900">
|
||||
{priceRange === '' ? 'Select price range' :
|
||||
priceRange === 'under-100' ? 'Under $100' :
|
||||
priceRange === '100-300' ? '$100 - $300' :
|
||||
priceRange === '300-500' ? '$300 - $500' :
|
||||
priceRange === 'over-500' ? '$500+' : priceRange}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-4 w-4 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as="div"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<Listbox.Options>
|
||||
{[
|
||||
{ value: '', label: 'Select price range' },
|
||||
{ value: 'under-100', label: 'Under $100' },
|
||||
{ value: '100-300', label: '$100 - $300' },
|
||||
{ value: '300-500', label: '$300 - $500' },
|
||||
{ value: 'over-500', label: '$500+' }
|
||||
].map((option, optionIdx) => (
|
||||
<Listbox.Option
|
||||
key={optionIdx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-blue-100 text-blue-900' : 'text-zinc-900'
|
||||
}`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
{/* Restriction Filter */}
|
||||
<div className="col-span-1">
|
||||
<Dropdown
|
||||
label="Restriction"
|
||||
value={selectedRestriction}
|
||||
onChange={setSelectedRestriction}
|
||||
options={restrictionOptions}
|
||||
placeholder="All restrictions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="col-span-1 flex items-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
className={`w-full px-3 py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-sm ${
|
||||
hasActiveFilters
|
||||
? 'bg-accent-600 hover:bg-accent-700 text-white'
|
||||
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<XMarkIcon className="h-3.5 w-3.5" />
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parts Display */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* View Toggle and Results Count */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="text-sm text-zinc-700">
|
||||
{loading ? 'Loading...' : `Showing ${startIndex + 1}-${Math.min(endIndex, sortedParts.length)} of ${sortedParts.length} parts`}
|
||||
{hasActiveFilters && !loading && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(filtered)
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="ml-2 text-red-500">{error}</span>}
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-600">View:</span>
|
||||
<div className="flex">
|
||||
<button
|
||||
className={`px-3 py-1 text-sm border rounded-l ${viewMode === 'table' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
|
||||
onClick={() => setViewMode('table')}
|
||||
aria-label="Table view"
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-sm border border-l-0 rounded-r ${viewMode === 'cards' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 hover:bg-gray-50'}`}
|
||||
onClick={() => setViewMode('cards')}
|
||||
aria-label="Card view"
|
||||
>
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table View */}
|
||||
{viewMode === 'table' && (
|
||||
<div className="bg-white shadow-sm rounded-lg overflow-hidden border border-zinc-200">
|
||||
<div className="overflow-x-auto max-h-screen overflow-y-auto">
|
||||
<table className="min-w-full divide-y divide-zinc-200">
|
||||
<thead className="bg-zinc-50 sticky top-0 z-10 shadow-sm">
|
||||
</aside>
|
||||
{/* Products Table */}
|
||||
<section className="flex-1">
|
||||
<div className="overflow-x-auto border rounded bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
|
||||
Product
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider cursor-pointer hover:bg-zinc-100"
|
||||
onClick={() => handleSort('price')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Price</span>
|
||||
<span className="text-sm">{getSortIcon('price')}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-zinc-200">
|
||||
{paginatedParts.map((part) => (
|
||||
<tr key={part.id} className="hover:bg-zinc-50 transition-colors">
|
||||
<td className="px-0 py-2 flex items-center gap-2 align-top">
|
||||
<div className="w-12 h-12 flex-shrink-0 rounded bg-zinc-100 overflow-hidden flex items-center justify-center border border-zinc-200">
|
||||
<Image src={Array.isArray(part.images) && (part.images as string[]).length > 0 ? (part.images as string[])[0] : '/window.svg'} alt={part.name} width={48} height={48} className="object-contain w-12 h-12" />
|
||||
</div>
|
||||
<div className="max-w-md break-words whitespace-normal">
|
||||
<Link href={`/products/${part.slug}`} className="text-sm font-semibold text-blue-600 hover:underline">
|
||||
{part.name}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{part.category.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-zinc-900">
|
||||
${Math.min(...part.offers.map(offer => offer.price)).toFixed(2)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{(() => {
|
||||
// Find if this part is already selected for any component
|
||||
const selectedComponentId = Object.entries(selectedParts).find(([_, selectedPart]) => selectedPart?.id === part.id)?.[0];
|
||||
|
||||
// Find the appropriate component based on category match
|
||||
const matchingComponentName = getMatchingComponentName(part.category.name);
|
||||
const matchingComponent = (buildGroups as {components: any[]}[]).flatMap((group) => group.components).find((component: any) =>
|
||||
component.name === matchingComponentName && !selectedParts[component.id]
|
||||
);
|
||||
|
||||
if (selectedComponentId) {
|
||||
return (
|
||||
<button
|
||||
className="border border-gray-300 hover:bg-gray-50 text-gray-700 px-3 py-1 rounded-md text-sm font-medium transition-colors"
|
||||
onClick={() => removePartForComponent(selectedComponentId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
);
|
||||
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
|
||||
return (
|
||||
<button
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors flex items-center gap-1"
|
||||
onClick={() => {
|
||||
selectPartForComponent(matchingComponent.id, {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
image_url: part.image_url,
|
||||
brand: part.brand,
|
||||
category: part.category,
|
||||
offers: part.offers,
|
||||
});
|
||||
router.push('/build');
|
||||
}}
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
<span className="text-xs font-normal">to build</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-200 text-gray-500 text-xs">N/A</span>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||
) : paginatedProducts.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||
) : (
|
||||
paginatedProducts
|
||||
.filter(product => columns.some(col => product[col] && String(product[col]).trim() !== ''))
|
||||
.map((product, i) => (
|
||||
<tr key={product.id || i} className="border-b hover:bg-zinc-50">
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||
{typeof product[col] === 'object' && product[col] !== null
|
||||
? JSON.stringify(product[col])
|
||||
: product[col] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onItemsPerPageChange={(items) => {
|
||||
setItemsPerPage(items);
|
||||
setCurrentPage(1); // Reset to first page when changing items per page
|
||||
}}
|
||||
totalItems={sortedParts.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card View */}
|
||||
{viewMode === 'cards' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{paginatedParts.map((part) => (
|
||||
<ProductCard key={part.id} product={part} onAdd={() => handleAdd(part)} added={addedPartIds.includes(part.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact Restriction Legend */}
|
||||
<div className="mt-8 pt-4 border-t border-zinc-200">
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-zinc-500">
|
||||
<span className="font-medium">Restrictions:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-600 text-white">🔒NFA</div>
|
||||
<span>National Firearms Act</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-600 text-white">📏SBR</div>
|
||||
<span>Short Barrel Rifle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-purple-600 text-white">🔇Suppressor</div>
|
||||
<span>Sound Suppressor</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-600 text-white">🏪FFL</div>
|
||||
<span>FFL Required</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-600 text-black">🗺️State</div>
|
||||
<span>State Restrictions</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-600 text-white">🥁High Cap</div>
|
||||
<span>High Capacity</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-green-600 text-white">🤝SilencerShop</div>
|
||||
<span>SilencerShop Partner</span>
|
||||
</div>
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>Prev</button>
|
||||
<span>Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>Next</button>
|
||||
<select
|
||||
className="ml-4 border rounded px-2 py-1"
|
||||
value={limit}
|
||||
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||
>
|
||||
{[25, 50, 100, 200].map(opt => (
|
||||
<option key={opt} value={opt}>{opt} / page</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-2 text-zinc-500">Total: {filteredProducts.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import RestrictionAlert from '@/components/RestrictionAlert';
|
||||
import { StarIcon } from '@heroicons/react/20/solid';
|
||||
import { StarIcon, HomeIcon } from '@heroicons/react/20/solid';
|
||||
import Image from 'next/image';
|
||||
import { useBuildStore } from '@/store/useBuildStore';
|
||||
|
||||
@@ -82,6 +82,13 @@ export default function ProductDetailsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Breadcrumb pages array
|
||||
const pages = [
|
||||
{ name: 'Parts', href: '/parts', current: false },
|
||||
{ name: product.category.name, href: `/parts?category=${encodeURIComponent(product.category.name)}`, current: false },
|
||||
{ name: product.name, href: '#', current: true },
|
||||
];
|
||||
|
||||
const allImages = product.images && product.images.length > 0
|
||||
? product.images
|
||||
: [product.image_url];
|
||||
@@ -126,14 +133,48 @@ export default function ProductDetailsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="text-sm breadcrumbs mb-6">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/parts">Parts</a></li>
|
||||
<li><a href={`/parts?category=${product.category.name}`}>{product.category.name}</a></li>
|
||||
<li>{product.name}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<nav aria-label="Breadcrumb" className="flex mb-4">
|
||||
<ol role="list" className="flex space-x-2 rounded px-2 py-1 ">
|
||||
<li className="flex">
|
||||
<div className="flex items-center">
|
||||
<a href="/" className="text-zinc-900 hover:text-primary">
|
||||
<HomeIcon aria-hidden="true" className="w-4 h-4 shrink-0" />
|
||||
<span className="sr-only">Home</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{pages.map((page, idx) => (
|
||||
<li key={page.name} className="flex">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 44"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
|
||||
</svg>
|
||||
{page.current ? (
|
||||
<span
|
||||
aria-current="page"
|
||||
className="ml-2 text-xs font-medium text-primary"
|
||||
>
|
||||
{page.name}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={page.href}
|
||||
className="ml-2 text-xs font-medium text-zinc-500 hover:text-primary"
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Restriction Alert */}
|
||||
{(product.restrictions?.nfa || product.restrictions?.sbr || product.restrictions?.suppressor) && (
|
||||
@@ -213,7 +254,7 @@ export default function ProductDetailsPage() {
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
<div className="text-3xl font-bold text-primary">
|
||||
${lowestPrice.toFixed(2)}
|
||||
</div>
|
||||
{lowestPrice !== highestPrice && (
|
||||
@@ -235,13 +276,16 @@ export default function ProductDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* Add to Build Button */}
|
||||
<div className="flex gap-4">
|
||||
<button className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}>
|
||||
<div className="flex gap-4 items-center">
|
||||
<button className="bg-primary hover:bg-primary/80 text-white font-medium py-2 px-4 rounded-md transition-colors flex-1" onClick={handleAddToBuild}>
|
||||
Add to Current Build
|
||||
</button>
|
||||
<button className="border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium py-2 px-4 rounded-md transition-colors">
|
||||
<span className="flex items-center text-xs text-zinc-500 hover:text-primary cursor-pointer select-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4 mr-1">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75v-1.5A2.25 2.25 0 0015 3h-6a2.25 2.25 0 00-2.25 2.25v15l6-3 6 3v-7.5" />
|
||||
</svg>
|
||||
Save for Later
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{addSuccess && (
|
||||
<div className="mt-2 text-green-600 font-medium">Added to build!</div>
|
||||
@@ -278,7 +322,7 @@ export default function ProductDetailsPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
${offer.price.toFixed(2)}
|
||||
</div>
|
||||
{offer.inStock !== undefined && (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
HomeIcon,
|
||||
UsersIcon,
|
||||
XMarkIcon,
|
||||
CubeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
@@ -30,6 +31,7 @@ const navigation = [
|
||||
{ name: 'Dashboard', href: '/admin', icon: HomeIcon },
|
||||
{ name: 'Users', href: '/admin/users', icon: UsersIcon },
|
||||
{ name: 'Category Mapping', href: '/admin/category-mapping', icon: ChartPieIcon },
|
||||
{ name: 'Products', href: '/admin/products', icon: CubeIcon },
|
||||
// { name: 'Settings', href: '/admin/settings', icon: Cog6ToothIcon }, // optional/future
|
||||
];
|
||||
const userNavigation = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
|
||||
import CategoryTreeTest from '@/components/CategoryTreeTest';
|
||||
|
||||
type Mapping = {
|
||||
id: number;
|
||||
@@ -161,6 +162,7 @@ export default function CategoryMappingAdmin() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<CategoryTreeTest />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export default function AdminDashboard() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Users</p>
|
||||
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div className="mt-4 flex items-center text-sm">
|
||||
<Activity className="w-4 h-4 text-blue-500 mr-1" />
|
||||
<span className="text-blue-600">{stats.activeUsers}</span>
|
||||
<span className="text-primary">{stats.activeUsers}</span>
|
||||
<span className="text-gray-500 ml-1">active users</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
// Core columns for performance and usability
|
||||
const columns = [
|
||||
'uuid',
|
||||
'sku',
|
||||
'brandName',
|
||||
'productName',
|
||||
'department',
|
||||
'category',
|
||||
'subcategory',
|
||||
'retailPrice',
|
||||
'salePrice',
|
||||
'imageUrl',
|
||||
];
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [subcategory, setSubcategory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/products?page=${page}&limit=${limit}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setProducts(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message || 'Error fetching products');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [page, limit]);
|
||||
|
||||
// Get unique filter options from current page's products
|
||||
const brandOptions = useMemo(() => Array.from(new Set(products.map(p => p.brandName).filter(Boolean))).sort(), [products]);
|
||||
const departmentOptions = useMemo(() => Array.from(new Set(products.map(p => p.department).filter(Boolean))).sort(), [products]);
|
||||
const categoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.category).filter(Boolean))).sort(), [products]);
|
||||
const subcategoryOptions = useMemo(() => Array.from(new Set(products.map(p => p.subcategory).filter(Boolean))).sort(), [products]);
|
||||
|
||||
// Filter products before rendering
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(p =>
|
||||
(!brand || p.brandName === brand) &&
|
||||
(!department || p.department === department) &&
|
||||
(!category || p.category === category) &&
|
||||
(!subcategory || p.subcategory === subcategory)
|
||||
);
|
||||
}, [products, brand, department, category, subcategory]);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => { setPage(1); }, [brand, department, category, subcategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Admin Products</h1>
|
||||
{error && <div className="text-red-600 mb-4">{error}</div>}
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 mb-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Brand</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px]" value={brand} onChange={e => setBrand(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{brandOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Department</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px]" value={department} onChange={e => setDepartment(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{departmentOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Category</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px]" value={category} onChange={e => setCategory(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{categoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1">Subcategory</label>
|
||||
<select className="border rounded px-2 py-1 min-w-[120px]" value={subcategory} onChange={e => setSubcategory(e.target.value)}>
|
||||
<option value="">All</option>
|
||||
{subcategoryOptions.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto border rounded bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-2 py-2 border-b text-left font-semibold bg-zinc-50">{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">Loading...</td></tr>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="text-center py-8">No products found.</td></tr>
|
||||
) : (
|
||||
filteredProducts.map((product, i) => (
|
||||
<tr key={product.uuid || i} className="border-b hover:bg-zinc-50">
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-2 py-1 max-w-xs truncate">
|
||||
{col === 'imageUrl' && product[col] ? (
|
||||
<img src={product[col]} alt="thumb" className="h-10 w-10 object-contain border rounded" />
|
||||
) : (
|
||||
product[col] ?? ''
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>Prev</button>
|
||||
<span>Page {page} of {Math.ceil(total / limit) || 1}</span>
|
||||
<button
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page * limit >= total}
|
||||
>Next</button>
|
||||
<select
|
||||
className="ml-4 border rounded px-2 py-1"
|
||||
value={limit}
|
||||
onChange={e => { setLimit(Number(e.target.value)); setPage(1); }}
|
||||
>
|
||||
{[25, 50, 100, 200].map(opt => (
|
||||
<option key={opt} value={opt}>{opt} / page</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-2 text-zinc-500">Total: {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default async function AdminUsersPage() {
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">{usersList.length}</div>
|
||||
<div className="text-2xl font-bold text-primary">{usersList.length}</div>
|
||||
<div className="text-sm text-gray-500">Total Users</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
|
||||
@@ -4,5 +4,23 @@ import { product_categories } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
const allCategories = await db.select().from(product_categories);
|
||||
return NextResponse.json({ success: true, data: allCategories });
|
||||
|
||||
// Build a map of id -> category object (with children array)
|
||||
const categoryMap = Object.fromEntries(
|
||||
allCategories.map(cat => [cat.id, { ...cat, children: [] as any[] }])
|
||||
);
|
||||
|
||||
// Build the hierarchy
|
||||
const rootCategories: any[] = [];
|
||||
for (const cat of allCategories) {
|
||||
if (cat.parent_category_id) {
|
||||
if (categoryMap[cat.parent_category_id]) {
|
||||
categoryMap[cat.parent_category_id].children.push(categoryMap[cat.id]);
|
||||
}
|
||||
} else {
|
||||
rootCategories.push(categoryMap[cat.id]);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: rootCategories });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/db';
|
||||
import { bb_products } from '@/db/schema';
|
||||
import { products } from '@/db/schema';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
{ params }: { params: { slug: string } }
|
||||
) {
|
||||
try {
|
||||
const allProducts = await db.select().from(bb_products);
|
||||
const allProducts = await db.select().from(products);
|
||||
const mapped = allProducts.map((item: any) => ({
|
||||
id: item.uuid,
|
||||
name: item.productName,
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import { db } from '@/db';
|
||||
import { products } from '@/db/schema';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch('http://localhost:8080/api/products'); // <-- your Spring backend endpoint
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '');
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get total count using raw SQL
|
||||
const totalResult = await db.execute(sql`SELECT COUNT(*)::int AS count FROM products`);
|
||||
const total = Number(totalResult.rows?.[0]?.count || 0);
|
||||
|
||||
// Get paginated products
|
||||
const allProducts = await db.select().from(products).limit(limit).offset(offset);
|
||||
const mapped = allProducts.map((item: any) => ({
|
||||
...item,
|
||||
slug: slugify(item.productName || item.product_name || item.name || ''),
|
||||
}));
|
||||
return NextResponse.json({ success: true, data: mapped, total });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { db } from "@/db";
|
||||
import { bb_products } from "@/db/schema";
|
||||
import { products } from "@/db/schema";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const allProducts = await db.select().from(bb_products).limit(50);
|
||||
const allProducts = await db.select().from(products).limit(50);
|
||||
const mapped = allProducts.map((item: any) => ({
|
||||
id: item.uuid,
|
||||
name: item.productName,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning data-theme="pew">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.className} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function CategoryTree({ categories }: { categories: any[] }) {
|
||||
if (!categories || categories.length === 0) return null;
|
||||
return (
|
||||
<ul style={{ marginLeft: 16 }}>
|
||||
{categories.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<span>{cat.name}</span>
|
||||
{cat.children && cat.children.length > 0 && (
|
||||
<CategoryTree categories={cat.children} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoryTreeTest() {
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/product-categories")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setCategories(data.data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading categories...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-2">Product Category Hierarchy</h2>
|
||||
<CategoryTree categories={categories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export default function Navbar() {
|
||||
<>
|
||||
{/* Admin Banner - Moved to top */}
|
||||
{session?.user && (session.user as any)?.isAdmin && (
|
||||
<div className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-2 px-4 sm:px-8 relative z-30">
|
||||
<div className="w-full bg-gradient-to-r from-gray-600 to-gray-600 text-white py-2 px-4 sm:px-8 relative z-30">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheckIcon className="h-4 w-4" />
|
||||
@@ -92,13 +92,13 @@ export default function Navbar() {
|
||||
)}
|
||||
|
||||
{/* Top Bar */}
|
||||
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
||||
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
|
||||
<div className="w-full bg-[#4B6516] h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
|
||||
<Link href="/" className="font-bold text-lg tracking-tight text-white hover:underline focus:underline">Pew Builder</Link>
|
||||
<div className="relative">
|
||||
{loading ? null : session?.user ? (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold"
|
||||
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold text-white"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
@@ -177,21 +177,14 @@ export default function Navbar() {
|
||||
href={item.href}
|
||||
className={`px-2 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'text-blue-600 font-semibold underline underline-offset-4'
|
||||
: 'text-neutral-700 dark:text-neutral-200 hover:text-blue-600'
|
||||
? 'text-primary font-semibold underline underline-offset-4'
|
||||
: 'text-neutral-700 dark:text-neutral-200 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Search */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</span>
|
||||
<span className="text-lg font-bold text-blue-600 dark:text-blue-400">${lowestPrice.toFixed(2)}</span>
|
||||
<span className="text-lg font-bold text-primary dark:text-blue-400">${lowestPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
import { pgTableCreator, integer, varchar, text, numeric, timestamp, unique, check, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView, index, serial } from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { DATABASE_PREFIX as prefix } from "@/lib/constants";
|
||||
|
||||
export const pgTable = pgTableCreator((name) => (prefix == "" || prefix == null) ? name: `${prefix}_${name}`);
|
||||
///
|
||||
export const products = pgTable("products", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: text().notNull(),
|
||||
price: numeric().notNull(),
|
||||
resellerId: integer("reseller_id").notNull(),
|
||||
categoryId: integer("category_id").notNull(),
|
||||
stockQty: integer("stock_qty").default(0),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
export const categories = pgTable("categories", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "categories_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
parentCategoryId: integer("parent_category_id"),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
|
||||
export const productFeeds = pgTable("product_feeds", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "productfeeds_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
resellerId: integer("reseller_id").notNull(),
|
||||
feedUrl: varchar("feed_url", { length: 255 }).notNull(),
|
||||
lastUpdate: timestamp("last_update", { mode: 'string' }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
productFeedsUuidUnique: unique("product_feeds_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const userActivityLog = pgTable("user_activity_log", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "user_activity_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
userId: bigint("user_id", { mode: "number" }).notNull(),
|
||||
activity: text().notNull(),
|
||||
timestamp: timestamp({ mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const brands = pgTable("brands", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "brands_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
brandsUuidUnique: unique("brands_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const manufacturer = pgTable("manufacturer", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "manufacturer_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
manufacturerUuidUnique: unique("manufacturer_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const states = pgTable("states", {
|
||||
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "states_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
state: varchar({ length: 50 }),
|
||||
abbreviation: varchar({ length: 50 }),
|
||||
});
|
||||
|
||||
export const componentType = pgTable("component_type", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "component_type_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
componentTypeUuidUnique: unique("component_type_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const aeroPrecision = pgTable("aero_precision", {
|
||||
sku: text().primaryKey().notNull(),
|
||||
manufacturerId: text("manufacturer_id"),
|
||||
brandName: text("brand_name"),
|
||||
productName: text("product_name"),
|
||||
longDescription: text("long_description"),
|
||||
shortDescription: text("short_description"),
|
||||
department: text(),
|
||||
category: text(),
|
||||
subcategory: text(),
|
||||
thumbUrl: text("thumb_url"),
|
||||
imageUrl: text("image_url"),
|
||||
buyLink: text("buy_link"),
|
||||
keywords: text(),
|
||||
reviews: text(),
|
||||
retailPrice: numeric("retail_price"),
|
||||
salePrice: numeric("sale_price"),
|
||||
brandPageLink: text("brand_page_link"),
|
||||
brandLogoImage: text("brand_logo_image"),
|
||||
productPageViewTracking: text("product_page_view_tracking"),
|
||||
variantsXml: text("variants_xml"),
|
||||
mediumImageUrl: text("medium_image_url"),
|
||||
productContentWidget: text("product_content_widget"),
|
||||
googleCategorization: text("google_categorization"),
|
||||
itemBasedCommission: text("item_based_commission"),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
|
||||
export const compartment = pgTable("compartment", {
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
description: varchar({ length: 300 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
export const builds = pgTable("builds", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
accountId: integer("account_id").notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: text(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
buildsUuidUnique: unique("builds_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const bb_products = pgTable("bb_products", {
|
||||
uuid: uuid().defaultRandom().primaryKey().notNull(),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 500 }),
|
||||
department: varchar("DEPARTMENT", { length: 100 }),
|
||||
category: varchar("CATEGORY", { length: 100 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 100 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 500 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 500 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 500 }),
|
||||
keywords: varchar("KEYWORDS", { length: 500 }),
|
||||
reviews: varchar("REVIEWS", { length: 500 }),
|
||||
retailPrice: varchar("RETAIL_PRICE", { length: 50 }),
|
||||
salePrice: varchar("SALE_PRICE", { length: 50 }),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 500 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 500 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 500 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 200 }),
|
||||
fineline: varchar("FINELINE", { length: 200 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 100 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 500 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 500 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 500 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 500 }),
|
||||
itemBasedCommissionRate: varchar("ITEM_BASED_COMMISSION RATE", { length: 50 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
export const psa_old = pgTable("psa_old", {
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
|
||||
department: varchar("DEPARTMENT", { length: 50 }),
|
||||
category: varchar("CATEGORY", { length: 50 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 50 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 50 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 50 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 128 }),
|
||||
keywords: varchar("KEYWORDS", { length: 50 }),
|
||||
reviews: varchar("REVIEWS", { length: 50 }),
|
||||
retailPrice: real("RETAIL_PRICE"),
|
||||
salePrice: real("SALE_PRICE"),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
|
||||
fineline: varchar("FINELINE", { length: 50 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 50 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
export const psa = pgTable("psa", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "psa_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
|
||||
department: varchar("DEPARTMENT", { length: 50 }),
|
||||
category: varchar("CATEGORY", { length: 50 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 50 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 50 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 50 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 128 }),
|
||||
keywords: varchar("KEYWORDS", { length: 50 }),
|
||||
reviews: varchar("REVIEWS", { length: 50 }),
|
||||
retailPrice: real("RETAIL_PRICE"),
|
||||
salePrice: real("SALE_PRICE"),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
|
||||
fineline: varchar("FINELINE", { length: 50 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 50 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
|
||||
export const lipseycatalog = pgTable("lipseycatalog", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "lipseycatalog_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
itemno: varchar({ length: 20 }).notNull(),
|
||||
description1: text(),
|
||||
description2: text(),
|
||||
upc: varchar({ length: 20 }),
|
||||
manufacturermodelno: varchar({ length: 30 }),
|
||||
msrp: doublePrecision(),
|
||||
model: text(),
|
||||
calibergauge: text(),
|
||||
manufacturer: text(),
|
||||
type: text(),
|
||||
action: text(),
|
||||
barrellength: text(),
|
||||
capacity: text(),
|
||||
finish: text(),
|
||||
overalllength: text(),
|
||||
receiver: text(),
|
||||
safety: text(),
|
||||
sights: text(),
|
||||
stockframegrips: text(),
|
||||
magazine: text(),
|
||||
weight: text(),
|
||||
imagename: text(),
|
||||
chamber: text(),
|
||||
drilledandtapped: text(),
|
||||
rateoftwist: text(),
|
||||
itemtype: text(),
|
||||
additionalfeature1: text(),
|
||||
additionalfeature2: text(),
|
||||
additionalfeature3: text(),
|
||||
shippingweight: text(),
|
||||
boundbookmanufacturer: text(),
|
||||
boundbookmodel: text(),
|
||||
boundbooktype: text(),
|
||||
nfathreadpattern: text(),
|
||||
nfaattachmentmethod: text(),
|
||||
nfabaffletype: text(),
|
||||
silencercanbedisassembled: text(),
|
||||
silencerconstructionmaterial: text(),
|
||||
nfadbreduction: text(),
|
||||
silenceroutsidediameter: text(),
|
||||
nfaform3Caliber: text(),
|
||||
opticmagnification: text(),
|
||||
maintubesize: text(),
|
||||
adjustableobjective: text(),
|
||||
objectivesize: text(),
|
||||
opticadjustments: text(),
|
||||
illuminatedreticle: text(),
|
||||
reticle: text(),
|
||||
exclusive: text(),
|
||||
quantity: varchar({ length: 10 }).default(sql`NULL`),
|
||||
allocated: text(),
|
||||
onsale: text(),
|
||||
price: doublePrecision(),
|
||||
currentprice: doublePrecision(),
|
||||
retailmap: doublePrecision(),
|
||||
fflrequired: text(),
|
||||
sotrequired: text(),
|
||||
exclusivetype: text(),
|
||||
scopecoverincluded: text(),
|
||||
special: text(),
|
||||
sightstype: text(),
|
||||
case: text(),
|
||||
choke: text(),
|
||||
dbreduction: text(),
|
||||
family: text(),
|
||||
finishtype: text(),
|
||||
frame: text(),
|
||||
griptype: varchar({ length: 30 }),
|
||||
handgunslidematerial: text(),
|
||||
countryoforigin: varchar({ length: 4 }),
|
||||
itemlength: text(),
|
||||
itemwidth: text(),
|
||||
itemheight: text(),
|
||||
packagelength: doublePrecision(),
|
||||
packagewidth: doublePrecision(),
|
||||
packageheight: doublePrecision(),
|
||||
itemgroup: varchar({ length: 40 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
export const buildsComponents = pgTable("builds_components", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_components_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
buildId: integer("build_id").notNull(),
|
||||
productId: integer("product_id").notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
buildsComponentsUuidUnique: unique("builds_components_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const balResellers = pgTable("bal_resellers", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "resellers_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
websiteUrl: varchar("website_url", { length: 255 }),
|
||||
contactEmail: varchar("contact_email", { length: 100 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
balResellersUuidUnique: unique("bal_resellers_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable("verificationTokens", {
|
||||
identifier: varchar("identifier").notNull(),
|
||||
token: varchar("token").notNull(),
|
||||
expires: timestamp("expires").notNull(),
|
||||
});
|
||||
|
||||
export const authenticator = pgTable("authenticator", {
|
||||
credentialId: text().notNull(),
|
||||
userId: text().notNull(),
|
||||
providerAccountId: text().notNull(),
|
||||
credentialPublicKey: text().notNull(),
|
||||
counter: integer().notNull(),
|
||||
credentialDeviceType: text().notNull(),
|
||||
credentialBackedUp: boolean().notNull(),
|
||||
transports: text(),
|
||||
}, (table) => {
|
||||
return {
|
||||
authenticatorUserIdCredentialIdPk: primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
|
||||
authenticatorCredentialIdUnique: unique("authenticator_credentialID_unique").on(table.credentialId),
|
||||
}
|
||||
});
|
||||
|
||||
export const accounts = pgTable("accounts", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
uuid: uuid("uuid").defaultRandom(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
type: varchar("type").notNull(),
|
||||
provider: text().notNull(),
|
||||
providerAccountId: varchar("provider_account_id").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
tokenType: varchar("token_type"),
|
||||
idToken: text("id_token"),
|
||||
sessionState: varchar("session_state"),
|
||||
scope: text(),
|
||||
}
|
||||
);
|
||||
|
||||
/* export const vw_accounts = pgView("vw_accounts", {
|
||||
uuid: uuid().defaultRandom(),
|
||||
userId: text("user_id").notNull(),
|
||||
type: text().notNull(),
|
||||
provider: text().notNull(),
|
||||
providerAccountId: text("provider_account_id").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
tokenType: text("token_type"),
|
||||
scope: text(),
|
||||
idToken: text("id_token"),
|
||||
sessionState: text("session_state"),
|
||||
first_name: text("first_name"),
|
||||
last_name: text("last_name"),
|
||||
|
||||
},) */
|
||||
|
||||
/* From here down is the authentication library Lusia tables */
|
||||
|
||||
export const users = pgTable("user",
|
||||
{
|
||||
id: varchar("id", { length: 21 }).primaryKey(),
|
||||
name: varchar("name"),
|
||||
username: varchar({ length: 50 }),
|
||||
discordId: varchar("discord_id", { length: 255 }).unique(),
|
||||
email: varchar("email", { length: 255 }).unique().notNull(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
hashedPassword: varchar("hashed_password", { length: 255 }),
|
||||
first_name: varchar("first_name", { length: 50 }),
|
||||
last_name: varchar("last_name", { length: 50 }),
|
||||
full_name: varchar("full_name", { length: 50 }),
|
||||
profilePicture: varchar("profile_picture", { length: 255 }),
|
||||
image: text("image"),
|
||||
dateOfBirth: date("date_of_birth"),
|
||||
phoneNumber: varchar("phone_number", { length: 20 }),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
isAdmin: boolean("is_admin").default(false),
|
||||
lastLogin: timestamp("last_login", { mode: 'string' }),
|
||||
buildPrivacySetting: text("build_privacy_setting").default('public'),
|
||||
uuid: uuid().defaultRandom(),
|
||||
avatar: varchar("avatar", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripe_subscription_id", { length: 191 }),
|
||||
stripePriceId: varchar("stripe_price_id", { length: 191 }),
|
||||
stripeCustomerId: varchar("stripe_customer_id", { length: 191 }),
|
||||
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
|
||||
}, (table) => ({
|
||||
usersUsernameKey: unique("users_username_key").on(table.username),
|
||||
usersEmailKey: unique("users_email_key").on(table.email),
|
||||
usersBuildPrivacySettingCheck: check("users_build_privacy_setting_check", sql`build_privacy_setting = ANY (ARRAY['private'::text, 'public'::text])`),
|
||||
emailIdx: index("user_email_idx").on(table.email),
|
||||
discordIdx: index("user_discord_idx").on(table.discordId),
|
||||
}),
|
||||
);
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(),
|
||||
userId: varchar("userId", { length: 21 }).notNull(),
|
||||
expires: timestamp("expires", { withTimezone: true, mode: "date" }).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const emailVerificationCodes = pgTable(
|
||||
"email_verification_codes",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 21 }).unique().notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
code: varchar("code", { length: 8 }).notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("verification_code_user_idx").on(t.userId),
|
||||
emailIdx: index("verification_code_email_idx").on(t.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
"password_reset_tokens",
|
||||
{
|
||||
id: varchar("id", { length: 40 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 21 }).notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("password_token_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const posts = pgTable(
|
||||
"posts",
|
||||
{
|
||||
id: varchar("id", { length: 15 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
excerpt: varchar("excerpt", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
status: varchar("status", { length: 10, enum: ["draft", "published"] })
|
||||
.default("draft")
|
||||
.notNull(),
|
||||
tags: varchar("tags", { length: 255 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("post_user_idx").on(t.userId),
|
||||
createdAtIdx: index("post_created_at_idx").on(t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const postRelations = relations(posts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [posts.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Post = typeof posts.$inferSelect;
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
|
||||
export const vwUserSessions = pgView("vw_user_sessions", { id: varchar({ length: 255 }),
|
||||
userId: varchar("user_id", { length: 21 }),
|
||||
uId: varchar("u_id", { length: 21 }),
|
||||
uEmail: varchar("u_email", { length: 255 }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }),
|
||||
}).existing();
|
||||
//as(sql`SELECT s.id, s.user_id, u.id AS u_id, u.email AS u_email, s.expires_at, s.created_at, s.updated_at FROM sessions s, users u WHERE s.user_id::text = u.id::text`);
|
||||
|
||||
// Default Drizzle File
|
||||
|
||||
// import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// export const products = pgTable("products", {
|
||||
// id: serial("id").primaryKey(),
|
||||
// name: text("name").notNull(),
|
||||
// description: text("description"),
|
||||
// price: integer("price"),
|
||||
// createdAt: timestamp("created_at").defaultNow(),
|
||||
// // Add more fields as needed
|
||||
// });
|
||||
|
||||
export const affiliateCategoryMap = pgTable("affiliate_category_map", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "affiliate_category_map_id_seq", startWith: 1, increment: 1 }),
|
||||
feedname: varchar("feedname", { length: 100 }).notNull(),
|
||||
affiliatecategory: varchar("affiliatecategory", { length: 255 }).notNull(),
|
||||
buildercategoryid: integer("buildercategoryid").notNull(),
|
||||
notes: varchar("notes", { length: 255 }),
|
||||
});
|
||||
|
||||
export const product_categories = pgTable("product_categories", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "product_categories_id_seq", startWith: 1, increment: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
parent_category_id: integer("parent_category_id"),
|
||||
type: varchar({ length: 50 }),
|
||||
sort_order: integer("sort_order"),
|
||||
created_at: timestamp("created_at", { mode: 'string' }).defaultNow(),
|
||||
updated_at: timestamp("updated_at", { mode: 'string' }).defaultNow(),
|
||||
});
|
||||
+99
-562
@@ -1,571 +1,108 @@
|
||||
import { pgTableCreator, integer, varchar, text, numeric, timestamp, unique, check, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView, index, serial } from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { DATABASE_PREFIX as prefix } from "@/lib/constants";
|
||||
import { pgTable, foreignKey, serial, varchar, integer, doublePrecision, timestamp, unique, text, numeric, boolean, jsonb } from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const pgTable = pgTableCreator((name) => (prefix == "" || prefix == null) ? name: `${prefix}_${name}`);
|
||||
///
|
||||
export const products = pgTable("products", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
description: text().notNull(),
|
||||
price: numeric().notNull(),
|
||||
resellerId: integer("reseller_id").notNull(),
|
||||
categoryId: integer("category_id").notNull(),
|
||||
stockQty: integer("stock_qty").default(0),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
|
||||
export const productCategoryMappings = pgTable("product_category_mappings", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
feedName: varchar("feed_name", { length: 255 }),
|
||||
feedCategoryValue: varchar("feed_category_value", { length: 255 }),
|
||||
canonicalCategoryId: integer("canonical_category_id"),
|
||||
confidenceScore: doublePrecision("confidence_score"),
|
||||
lastReviewedBy: varchar("last_reviewed_by", { length: 255 }),
|
||||
lastReviewedAt: timestamp("last_reviewed_at", { mode: 'string' }),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.canonicalCategoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "product_category_mappings_canonical_category_id_fkey"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const categories = pgTable("categories", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "categories_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
parentCategoryId: integer("parent_category_id"),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
|
||||
export const productFeeds = pgTable("product_feeds", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "productfeeds_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
resellerId: integer("reseller_id").notNull(),
|
||||
feedUrl: varchar("feed_url", { length: 255 }).notNull(),
|
||||
lastUpdate: timestamp("last_update", { mode: 'string' }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
productFeedsUuidUnique: unique("product_feeds_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const userActivityLog = pgTable("user_activity_log", {
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "user_activity_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
userId: bigint("user_id", { mode: "number" }).notNull(),
|
||||
activity: text().notNull(),
|
||||
timestamp: timestamp({ mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const brands = pgTable("brands", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "brands_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
brandsUuidUnique: unique("brands_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const manufacturer = pgTable("manufacturer", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "manufacturer_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
manufacturerUuidUnique: unique("manufacturer_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const states = pgTable("states", {
|
||||
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "states_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
state: varchar({ length: 50 }),
|
||||
abbreviation: varchar({ length: 50 }),
|
||||
});
|
||||
|
||||
export const componentType = pgTable("component_type", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "component_type_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
componentTypeUuidUnique: unique("component_type_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const aeroPrecision = pgTable("aero_precision", {
|
||||
sku: text().primaryKey().notNull(),
|
||||
manufacturerId: text("manufacturer_id"),
|
||||
brandName: text("brand_name"),
|
||||
productName: text("product_name"),
|
||||
longDescription: text("long_description"),
|
||||
shortDescription: text("short_description"),
|
||||
department: text(),
|
||||
category: text(),
|
||||
subcategory: text(),
|
||||
thumbUrl: text("thumb_url"),
|
||||
imageUrl: text("image_url"),
|
||||
buyLink: text("buy_link"),
|
||||
keywords: text(),
|
||||
reviews: text(),
|
||||
retailPrice: numeric("retail_price"),
|
||||
salePrice: numeric("sale_price"),
|
||||
brandPageLink: text("brand_page_link"),
|
||||
brandLogoImage: text("brand_logo_image"),
|
||||
productPageViewTracking: text("product_page_view_tracking"),
|
||||
variantsXml: text("variants_xml"),
|
||||
mediumImageUrl: text("medium_image_url"),
|
||||
productContentWidget: text("product_content_widget"),
|
||||
googleCategorization: text("google_categorization"),
|
||||
itemBasedCommission: text("item_based_commission"),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
|
||||
export const compartment = pgTable("compartment", {
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
description: varchar({ length: 300 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
|
||||
export const builds = pgTable("builds", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
accountId: integer("account_id").notNull(),
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
parentId: integer("parent_id"),
|
||||
slug: varchar({ length: 255 }).notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: "categories_parent_id_fkey"
|
||||
}),
|
||||
unique("categories_slug_key").on(table.slug),
|
||||
]);
|
||||
|
||||
export const products = pgTable("products", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
brand: varchar({ length: 255 }),
|
||||
description: text(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
buildsUuidUnique: unique("builds_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
upc: varchar({ length: 32 }),
|
||||
mpn: varchar({ length: 64 }),
|
||||
canonicalCategoryId: integer("canonical_category_id"),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.canonicalCategoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "products_canonical_category_id_fkey"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const bb_products = pgTable("bb_products", {
|
||||
uuid: uuid().defaultRandom().primaryKey().notNull(),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 500 }),
|
||||
department: varchar("DEPARTMENT", { length: 100 }),
|
||||
category: varchar("CATEGORY", { length: 100 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 100 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 500 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 500 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 500 }),
|
||||
keywords: varchar("KEYWORDS", { length: 500 }),
|
||||
reviews: varchar("REVIEWS", { length: 500 }),
|
||||
retailPrice: varchar("RETAIL_PRICE", { length: 50 }),
|
||||
salePrice: varchar("SALE_PRICE", { length: 50 }),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 500 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 500 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 500 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 200 }),
|
||||
fineline: varchar("FINELINE", { length: 200 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 100 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 500 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 500 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 500 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 500 }),
|
||||
itemBasedCommissionRate: varchar("ITEM_BASED_COMMISSION RATE", { length: 50 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
export const offers = pgTable("offers", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
productId: integer("product_id"),
|
||||
feedName: varchar("feed_name", { length: 255 }).notNull(),
|
||||
feedSku: varchar("feed_sku", { length: 255 }),
|
||||
price: numeric({ precision: 10, scale: 2 }),
|
||||
url: text(),
|
||||
inStock: boolean("in_stock"),
|
||||
vendor: varchar({ length: 255 }),
|
||||
lastSeenAt: timestamp("last_seen_at", { mode: 'string' }).defaultNow(),
|
||||
rawData: jsonb("raw_data"),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.productId],
|
||||
foreignColumns: [products.id],
|
||||
name: "offers_product_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("offers_product_id_feed_name_feed_sku_key").on(table.productId, table.feedName, table.feedSku),
|
||||
unique("offers_feed_unique").on(table.feedName, table.feedSku),
|
||||
]);
|
||||
|
||||
export const psa_old = pgTable("psa_old", {
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
|
||||
department: varchar("DEPARTMENT", { length: 50 }),
|
||||
category: varchar("CATEGORY", { length: 50 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 50 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 50 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 50 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 128 }),
|
||||
keywords: varchar("KEYWORDS", { length: 50 }),
|
||||
reviews: varchar("REVIEWS", { length: 50 }),
|
||||
retailPrice: real("RETAIL_PRICE"),
|
||||
salePrice: real("SALE_PRICE"),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
|
||||
fineline: varchar("FINELINE", { length: 50 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 50 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
export const psa = pgTable("psa", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "psa_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
sku: varchar("SKU", { length: 50 }),
|
||||
manufacturerId: varchar("MANUFACTURER_ID", { length: 50 }),
|
||||
brandName: varchar("BRAND_NAME", { length: 50 }),
|
||||
productName: varchar("PRODUCT_NAME", { length: 255 }),
|
||||
longDescription: text("LONG_DESCRIPTION"),
|
||||
shortDescription: varchar("SHORT_DESCRIPTION", { length: 50 }),
|
||||
department: varchar("DEPARTMENT", { length: 50 }),
|
||||
category: varchar("CATEGORY", { length: 50 }),
|
||||
subcategory: varchar("SUBCATEGORY", { length: 50 }),
|
||||
thumbUrl: varchar("THUMB_URL", { length: 50 }),
|
||||
imageUrl: varchar("IMAGE_URL", { length: 50 }),
|
||||
buyLink: varchar("BUY_LINK", { length: 128 }),
|
||||
keywords: varchar("KEYWORDS", { length: 50 }),
|
||||
reviews: varchar("REVIEWS", { length: 50 }),
|
||||
retailPrice: real("RETAIL_PRICE"),
|
||||
salePrice: real("SALE_PRICE"),
|
||||
brandPageLink: varchar("BRAND_PAGE_LINK", { length: 50 }),
|
||||
brandLogoImage: varchar("BRAND_LOGO_IMAGE", { length: 50 }),
|
||||
productPageViewTracking: varchar("PRODUCT_PAGE_VIEW_TRACKING", { length: 256 }),
|
||||
parentGroupId: varchar("PARENT_GROUP_ID", { length: 50 }),
|
||||
fineline: varchar("FINELINE", { length: 50 }),
|
||||
superfineline: varchar("SUPERFINELINE", { length: 200 }),
|
||||
modelnumber: varchar("MODELNUMBER", { length: 50 }),
|
||||
caliber: varchar("CALIBER", { length: 200 }),
|
||||
upc: varchar("UPC", { length: 100 }),
|
||||
mediumImageUrl: varchar("MEDIUM_IMAGE_URL", { length: 50 }),
|
||||
productContentWidget: varchar("PRODUCT_CONTENT_WIDGET", { length: 256 }),
|
||||
googleCategorization: varchar("GOOGLE_CATEGORIZATION", { length: 50 }),
|
||||
itemBasedCommission: varchar("ITEM_BASED_COMMISSION", { length: 50 }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
});
|
||||
export const offerPriceHistory = pgTable("offer_price_history", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
offerId: integer("offer_id"),
|
||||
price: numeric({ precision: 10, scale: 2 }).notNull(),
|
||||
seenAt: timestamp("seen_at", { mode: 'string' }).defaultNow(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.offerId],
|
||||
foreignColumns: [offers.id],
|
||||
name: "offer_price_history_offer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const lipseycatalog = pgTable("lipseycatalog", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "lipseycatalog_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
itemno: varchar({ length: 20 }).notNull(),
|
||||
description1: text(),
|
||||
description2: text(),
|
||||
upc: varchar({ length: 20 }),
|
||||
manufacturermodelno: varchar({ length: 30 }),
|
||||
msrp: doublePrecision(),
|
||||
model: text(),
|
||||
calibergauge: text(),
|
||||
manufacturer: text(),
|
||||
type: text(),
|
||||
action: text(),
|
||||
barrellength: text(),
|
||||
capacity: text(),
|
||||
finish: text(),
|
||||
overalllength: text(),
|
||||
receiver: text(),
|
||||
safety: text(),
|
||||
sights: text(),
|
||||
stockframegrips: text(),
|
||||
magazine: text(),
|
||||
weight: text(),
|
||||
imagename: text(),
|
||||
chamber: text(),
|
||||
drilledandtapped: text(),
|
||||
rateoftwist: text(),
|
||||
itemtype: text(),
|
||||
additionalfeature1: text(),
|
||||
additionalfeature2: text(),
|
||||
additionalfeature3: text(),
|
||||
shippingweight: text(),
|
||||
boundbookmanufacturer: text(),
|
||||
boundbookmodel: text(),
|
||||
boundbooktype: text(),
|
||||
nfathreadpattern: text(),
|
||||
nfaattachmentmethod: text(),
|
||||
nfabaffletype: text(),
|
||||
silencercanbedisassembled: text(),
|
||||
silencerconstructionmaterial: text(),
|
||||
nfadbreduction: text(),
|
||||
silenceroutsidediameter: text(),
|
||||
nfaform3Caliber: text(),
|
||||
opticmagnification: text(),
|
||||
maintubesize: text(),
|
||||
adjustableobjective: text(),
|
||||
objectivesize: text(),
|
||||
opticadjustments: text(),
|
||||
illuminatedreticle: text(),
|
||||
reticle: text(),
|
||||
exclusive: text(),
|
||||
quantity: varchar({ length: 10 }).default(sql`NULL`),
|
||||
allocated: text(),
|
||||
onsale: text(),
|
||||
price: doublePrecision(),
|
||||
currentprice: doublePrecision(),
|
||||
retailmap: doublePrecision(),
|
||||
fflrequired: text(),
|
||||
sotrequired: text(),
|
||||
exclusivetype: text(),
|
||||
scopecoverincluded: text(),
|
||||
special: text(),
|
||||
sightstype: text(),
|
||||
case: text(),
|
||||
choke: text(),
|
||||
dbreduction: text(),
|
||||
family: text(),
|
||||
finishtype: text(),
|
||||
frame: text(),
|
||||
griptype: varchar({ length: 30 }),
|
||||
handgunslidematerial: text(),
|
||||
countryoforigin: varchar({ length: 4 }),
|
||||
itemlength: text(),
|
||||
itemwidth: text(),
|
||||
itemheight: text(),
|
||||
packagelength: doublePrecision(),
|
||||
packagewidth: doublePrecision(),
|
||||
packageheight: doublePrecision(),
|
||||
itemgroup: varchar({ length: 40 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
});
|
||||
export const feeds = pgTable("feeds", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
url: text(),
|
||||
lastImportedAt: timestamp("last_imported_at", { mode: 'string' }),
|
||||
}, (table) => [
|
||||
unique("feeds_name_key").on(table.name),
|
||||
]);
|
||||
|
||||
export const buildsComponents = pgTable("builds_components", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "build_components_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
buildId: integer("build_id").notNull(),
|
||||
productId: integer("product_id").notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
buildsComponentsUuidUnique: unique("builds_components_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const balResellers = pgTable("bal_resellers", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "resellers_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
websiteUrl: varchar("website_url", { length: 255 }),
|
||||
contactEmail: varchar("contact_email", { length: 100 }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||
uuid: uuid().defaultRandom(),
|
||||
}, (table) => {
|
||||
return {
|
||||
balResellersUuidUnique: unique("bal_resellers_uuid_unique").on(table.uuid),
|
||||
}
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable("verificationTokens", {
|
||||
identifier: varchar("identifier").notNull(),
|
||||
token: varchar("token").notNull(),
|
||||
expires: timestamp("expires").notNull(),
|
||||
});
|
||||
|
||||
export const authenticator = pgTable("authenticator", {
|
||||
credentialId: text().notNull(),
|
||||
userId: text().notNull(),
|
||||
providerAccountId: text().notNull(),
|
||||
credentialPublicKey: text().notNull(),
|
||||
counter: integer().notNull(),
|
||||
credentialDeviceType: text().notNull(),
|
||||
credentialBackedUp: boolean().notNull(),
|
||||
transports: text(),
|
||||
}, (table) => {
|
||||
return {
|
||||
authenticatorUserIdCredentialIdPk: primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
|
||||
authenticatorCredentialIdUnique: unique("authenticator_credentialID_unique").on(table.credentialId),
|
||||
}
|
||||
});
|
||||
|
||||
export const accounts = pgTable("accounts", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
uuid: uuid("uuid").defaultRandom(),
|
||||
userId: uuid("user_id").notNull(),
|
||||
type: varchar("type").notNull(),
|
||||
provider: text().notNull(),
|
||||
providerAccountId: varchar("provider_account_id").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
tokenType: varchar("token_type"),
|
||||
idToken: text("id_token"),
|
||||
sessionState: varchar("session_state"),
|
||||
scope: text(),
|
||||
}
|
||||
);
|
||||
|
||||
/* export const vw_accounts = pgView("vw_accounts", {
|
||||
uuid: uuid().defaultRandom(),
|
||||
userId: text("user_id").notNull(),
|
||||
type: text().notNull(),
|
||||
provider: text().notNull(),
|
||||
providerAccountId: text("provider_account_id").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
tokenType: text("token_type"),
|
||||
scope: text(),
|
||||
idToken: text("id_token"),
|
||||
sessionState: text("session_state"),
|
||||
first_name: text("first_name"),
|
||||
last_name: text("last_name"),
|
||||
|
||||
},) */
|
||||
|
||||
/* From here down is the authentication library Lusia tables */
|
||||
|
||||
export const users = pgTable("user",
|
||||
{
|
||||
id: varchar("id", { length: 21 }).primaryKey(),
|
||||
name: varchar("name"),
|
||||
username: varchar({ length: 50 }),
|
||||
discordId: varchar("discord_id", { length: 255 }).unique(),
|
||||
email: varchar("email", { length: 255 }).unique().notNull(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
hashedPassword: varchar("hashed_password", { length: 255 }),
|
||||
first_name: varchar("first_name", { length: 50 }),
|
||||
last_name: varchar("last_name", { length: 50 }),
|
||||
full_name: varchar("full_name", { length: 50 }),
|
||||
profilePicture: varchar("profile_picture", { length: 255 }),
|
||||
image: text("image"),
|
||||
dateOfBirth: date("date_of_birth"),
|
||||
phoneNumber: varchar("phone_number", { length: 20 }),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||
isAdmin: boolean("is_admin").default(false),
|
||||
lastLogin: timestamp("last_login", { mode: 'string' }),
|
||||
buildPrivacySetting: text("build_privacy_setting").default('public'),
|
||||
uuid: uuid().defaultRandom(),
|
||||
avatar: varchar("avatar", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripe_subscription_id", { length: 191 }),
|
||||
stripePriceId: varchar("stripe_price_id", { length: 191 }),
|
||||
stripeCustomerId: varchar("stripe_customer_id", { length: 191 }),
|
||||
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
|
||||
}, (table) => ({
|
||||
usersUsernameKey: unique("users_username_key").on(table.username),
|
||||
usersEmailKey: unique("users_email_key").on(table.email),
|
||||
usersBuildPrivacySettingCheck: check("users_build_privacy_setting_check", sql`build_privacy_setting = ANY (ARRAY['private'::text, 'public'::text])`),
|
||||
emailIdx: index("user_email_idx").on(table.email),
|
||||
discordIdx: index("user_discord_idx").on(table.discordId),
|
||||
}),
|
||||
);
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(),
|
||||
userId: varchar("userId", { length: 21 }).notNull(),
|
||||
expires: timestamp("expires", { withTimezone: true, mode: "date" }).notNull(),
|
||||
}
|
||||
);
|
||||
|
||||
export const emailVerificationCodes = pgTable(
|
||||
"email_verification_codes",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 21 }).unique().notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
code: varchar("code", { length: 8 }).notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("verification_code_user_idx").on(t.userId),
|
||||
emailIdx: index("verification_code_email_idx").on(t.email),
|
||||
}),
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
"password_reset_tokens",
|
||||
{
|
||||
id: varchar("id", { length: 40 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 21 }).notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("password_token_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const posts = pgTable(
|
||||
"posts",
|
||||
{
|
||||
id: varchar("id", { length: 15 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
excerpt: varchar("excerpt", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
status: varchar("status", { length: 10, enum: ["draft", "published"] })
|
||||
.default("draft")
|
||||
.notNull(),
|
||||
tags: varchar("tags", { length: 255 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("post_user_idx").on(t.userId),
|
||||
createdAtIdx: index("post_created_at_idx").on(t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const postRelations = relations(posts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [posts.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Post = typeof posts.$inferSelect;
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
|
||||
export const vwUserSessions = pgView("vw_user_sessions", { id: varchar({ length: 255 }),
|
||||
userId: varchar("user_id", { length: 21 }),
|
||||
uId: varchar("u_id", { length: 21 }),
|
||||
uEmail: varchar("u_email", { length: 255 }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp("created_at", { mode: 'string' }),
|
||||
updatedAt: timestamp("updated_at", { mode: 'string' }),
|
||||
}).existing();
|
||||
//as(sql`SELECT s.id, s.user_id, u.id AS u_id, u.email AS u_email, s.expires_at, s.created_at, s.updated_at FROM sessions s, users u WHERE s.user_id::text = u.id::text`);
|
||||
|
||||
// Default Drizzle File
|
||||
|
||||
// import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// export const products = pgTable("products", {
|
||||
// id: serial("id").primaryKey(),
|
||||
// name: text("name").notNull(),
|
||||
// description: text("description"),
|
||||
// price: integer("price"),
|
||||
// createdAt: timestamp("created_at").defaultNow(),
|
||||
// // Add more fields as needed
|
||||
// });
|
||||
|
||||
export const affiliateCategoryMap = pgTable("affiliate_category_map", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "affiliate_category_map_id_seq", startWith: 1, increment: 1 }),
|
||||
feedname: varchar("feedname", { length: 100 }).notNull(),
|
||||
affiliatecategory: varchar("affiliatecategory", { length: 255 }).notNull(),
|
||||
buildercategoryid: integer("buildercategoryid").notNull(),
|
||||
notes: varchar("notes", { length: 255 }),
|
||||
});
|
||||
|
||||
export const product_categories = pgTable("product_categories", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "product_categories_id_seq", startWith: 1, increment: 1 }),
|
||||
name: varchar({ length: 100 }).notNull(),
|
||||
parent_category_id: integer("parent_category_id"),
|
||||
type: varchar({ length: 50 }),
|
||||
sort_order: integer("sort_order"),
|
||||
created_at: timestamp("created_at", { mode: 'string' }).defaultNow(),
|
||||
updated_at: timestamp("updated_at", { mode: 'string' }).defaultNow(),
|
||||
});
|
||||
export const productAttributes = pgTable("product_attributes", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
productId: integer("product_id"),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
value: varchar({ length: 255 }).notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.productId],
|
||||
foreignColumns: [products.id],
|
||||
name: "product_attributes_product_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
+21
-1
@@ -1,8 +1,28 @@
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: { extend: {} },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "oklch(45% 0.124 130.933)",
|
||||
secondary: "oklch(37% 0.034 259.733)",
|
||||
accent: {
|
||||
100: "oklch(95% 0.013 255.508)",
|
||||
500: "oklch(70% 0.013 255.508)",
|
||||
600: "oklch(60% 0.013 255.508)",
|
||||
700: "oklch(50% 0.013 255.508)",
|
||||
},
|
||||
neutral: "oklch(14% 0.005 285.823)",
|
||||
base: {
|
||||
100: "oklch(100% 0 0)",
|
||||
200: "oklch(98% 0 0)",
|
||||
300: "oklch(95% 0 0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user