6 Commits

40 changed files with 2570 additions and 1277 deletions
+1
View File
@@ -0,0 +1 @@
{}
+7 -2
View File
@@ -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;
*/
+7
View File
@@ -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;
+493
View File
@@ -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": {}
}
}
+20
View File
@@ -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
}
]
}
+53
View File
@@ -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]
}),
}));
+108
View File
@@ -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"),
]);
+52
View File
@@ -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"
}
+66 -4
View File
@@ -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
View File
@@ -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"
@@ -31,14 +31,14 @@ export default function ForgotPasswordPage() {
/>
<button
type="submit"
className="w-full btn btn-primary"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm py-2 px-4 rounded-md transition-colors"
disabled={submitted}
>
{submitted ? 'Check your email' : 'Send reset link'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-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>
+7 -7
View File
@@ -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-primary-600 hover:text-primary-500">
<Link href="/account/register" className="font-medium text-primary hover:text-blue-500">
Sign Up For Free
</Link>
</p>
@@ -75,7 +75,7 @@ export default function LoginPage() {
required
value={email}
onChange={e => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
disabled={loading}
/>
@@ -92,7 +92,7 @@ export default function LoginPage() {
required
value={password}
onChange={e => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 placeholder-gray-500 dark:placeholder-zinc-400 text-gray-900 dark:text-white bg-white dark:bg-zinc-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
disabled={loading}
/>
@@ -109,7 +109,7 @@ export default function LoginPage() {
id="remember-me"
name="remember-me"
type="checkbox"
className="checkbox checkbox-primary"
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-primary-600 hover:text-primary-500">
<Link href="/account/forgot-password" className="font-medium text-primary hover:text-blue-500">
Forgot your password?
</Link>
</div>
@@ -127,7 +127,7 @@ export default function LoginPage() {
<div>
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
@@ -151,7 +151,7 @@ export default function LoginPage() {
<button
type="button"
onClick={handleGoogle}
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-zinc-700 rounded-md shadow-sm bg-white dark:bg-zinc-800 text-sm font-medium text-gray-500 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition"
className="flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-zinc-700 rounded-md shadow-sm bg-white dark:bg-zinc-800 text-sm font-medium text-gray-500 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition"
disabled={loading}
>
<span className="sr-only">Sign in with Google</span>
+2 -2
View File
@@ -79,14 +79,14 @@ export default function RegisterPage() {
{error && <div className="text-red-600 text-sm">{error}</div>}
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
disabled={loading}
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-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>
+14 -19
View File
@@ -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,11 +407,11 @@ 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
className="btn btn-outline btn-error ml-0 md:ml-4"
className="border border-red-300 hover:bg-red-50 text-red-700 ml-0 md:ml-4 px-4 py-2 rounded-md transition-colors"
onClick={() => setShowClearModal(true)}
>
Clear Build
@@ -429,13 +432,13 @@ export default function BuildPage() {
</Dialog.Description>
<div className="flex justify-end gap-2">
<button
className="btn btn-sm btn-ghost"
className="text-gray-700 hover:bg-gray-100 px-3 py-1 rounded-md text-sm transition-colors"
onClick={() => setShowClearModal(false)}
>
Cancel
</button>
<button
className="btn btn-sm btn-error"
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm transition-colors"
onClick={() => {
clearBuild();
setShowClearModal(false);
@@ -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,23 +709,18 @@ 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
className="btn btn-outline btn-sm"
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(component.id)}
>
Remove
</button>
) : (
<Link
href={`/parts?category=${encodeURIComponent(getProductCategoryForComponent(component.name))}`}
className="btn btn-primary btn-sm"
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
</Link>
@@ -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>
)}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+2 -1
View File
@@ -34,7 +34,7 @@ export default function LandingPage() {
<div className="mt-10 flex items-top gap-x-6">
<Link
href="/build"
className="btn btn-primary text-base font-semibold px-6"
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>
);
+184
View File
@@ -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>
);
}
+308 -597
View File
@@ -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 = {
@@ -159,7 +158,7 @@ const Dropdown = ({
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 text-primary-900' : 'text-zinc-900'
active ? 'bg-blue-100 text-blue-900' : 'text-zinc-900'
}`
}
value={option.value}
@@ -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,626 +221,338 @@ 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,
totalPages,
onPageChange,
itemsPerPage,
onItemsPerPageChange,
totalItems,
startIndex,
endIndex
}: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
itemsPerPage: number;
onItemsPerPageChange: (items: number) => void;
totalItems: number;
startIndex: number;
endIndex: number;
}) => {
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
return (
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 py-4 px-6 bg-white border-t border-zinc-200">
{/* Items per page selector */}
<div className="flex items-center gap-2 text-sm text-zinc-600">
<span>Show:</span>
<select
value={itemsPerPage}
onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
className="border border-zinc-300 rounded px-2 py-1 text-sm"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span>per page</span>
</div>
{/* Page info */}
<div className="text-sm text-zinc-600">
Showing {startIndex + 1} to {Math.min(endIndex, totalItems)} of {totalItems} results
</div>
{/* Pagination controls */}
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-zinc-300 rounded hover:bg-zinc-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{getPageNumbers().map((page, index) => (
<button
key={index}
onClick={() => typeof page === 'number' ? onPageChange(page) : null}
disabled={page === '...'}
className={`px-3 py-1 text-sm border rounded ${
page === currentPage
? 'bg-blue-600 text-white border-blue-600'
: page === '...'
? 'border-zinc-300 text-zinc-400 cursor-default'
: 'border-zinc-300 hover:bg-zinc-50'
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-zinc-300 rounded hover:bg-zinc-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
);
};
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);
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;
}
});
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-primary-100 text-primary-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-primary-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 ${sortedParts.length} of ${products.length} parts`}
{hasActiveFilters && !loading && (
<span className="ml-2 text-primary-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="btn-group">
<button
className={`btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<TableCellsIcon className="h-5 w-5" />
</button>
<button
className={`btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
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">
{sortedParts.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-primary 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-primary-100 text-primary-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="btn btn-outline btn-sm"
onClick={() => removePartForComponent(selectedComponentId)}
>
Remove
</button>
);
} else if (matchingComponent && !selectedParts[matchingComponent.id]) {
return (
<button
className="btn btn-neutral btn-sm 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>
{/* Table Footer */}
<div className="bg-zinc-50 px-6 py-3 border-t border-zinc-200">
<div className="flex items-center justify-between">
<div className="text-sm text-zinc-700">
Showing {sortedParts.length} of {products.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600">
(filtered)
</span>
)}
</div>
</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>
</div>
)}
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{sortedParts.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>
</section>
</div>
</div>
</main>
</main>
</>
);
}
+60 -16
View File
@@ -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-primary-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="btn btn-primary 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="btn btn-outline">
<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-primary-600">
<div className="text-2xl font-bold text-primary">
${offer.price.toFixed(2)}
</div>
{offer.inStock !== undefined && (
@@ -291,7 +335,7 @@ export default function ProductDetailsPage() {
href={offer.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors"
>
View Deal
</a>
+61 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { useSession } from 'next-auth/react';
import {
Dialog,
DialogBackdrop,
@@ -22,6 +23,7 @@ import {
HomeIcon,
UsersIcon,
XMarkIcon,
CubeIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
@@ -29,11 +31,12 @@ 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 = [
{ name: 'Your profile', href: '#' },
{ name: 'Sign out', href: '#' },
{ name: 'Your profile', href: '/account/profile' },
{ name: 'Sign out', href: '/api/auth/signout' },
];
function classNames(...classes: string[]) {
@@ -43,6 +46,40 @@ function classNames(...classes: string[]) {
export default function AdminNavbar({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const { data: session } = useSession();
// Get user display name
const getUserDisplayName = () => {
if (!session?.user) return 'Admin User';
const user = session.user as any;
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}
if (user.name) {
return user.name;
}
if (user.email) {
return user.email.split('@')[0]; // Use email prefix as fallback
}
return 'Admin User';
};
const getUserInitials = () => {
if (!session?.user) return 'A';
const user = session.user as any;
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
if (user.name) {
return user.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
}
if (user.email) {
return user.email[0].toUpperCase();
}
return 'A';
};
return (
<>
@@ -203,6 +240,15 @@ export default function AdminNavbar({ children }: { children: ReactNode }) {
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
/>
</form>
{/* Back to Site Button */}
<a
href="/"
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
<HomeIcon className="w-4 h-4 mr-2" />
Back to Site
</a>
<div className="flex items-center gap-x-4 lg:gap-x-6">
<button type="button" className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500">
<span className="sr-only">View notifications</span>
@@ -217,27 +263,32 @@ export default function AdminNavbar({ children }: { children: ReactNode }) {
<MenuButton className="relative flex items-center">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-50"
/>
<div className="size-8 rounded-full bg-indigo-600 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{getUserInitials()}
</span>
</div>
<span className="hidden lg:flex lg:items-center">
<span aria-hidden="true" className="ml-4 text-sm/6 font-semibold text-gray-900">
Tom Cook
{getUserDisplayName()}
</span>
<ChevronDownIcon aria-hidden="true" className="ml-2 size-5 text-gray-400" />
</span>
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 transition focus:outline-none data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
className="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 transition focus:outline-none data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
>
{session?.user && (
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
{session.user.email}
</div>
)}
{userNavigation.map((item) => (
<MenuItem key={item.name}>
<a
href={item.href}
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none"
className="block px-3 py-2 text-sm text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none hover:bg-gray-50"
>
{item.name}
</a>
+11
View File
@@ -0,0 +1,11 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function AdminProviders({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
</SessionProvider>
);
}
+2
View File
@@ -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>
);
}
+6 -3
View File
@@ -1,9 +1,12 @@
import AdminNavbar from './AdminNavbar';
import AdminProviders from './AdminProviders';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminNavbar>
{children}
</AdminNavbar>
<AdminProviders>
<AdminNavbar>
{children}
</AdminNavbar>
</AdminProviders>
);
}
+2 -2
View File
@@ -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>
+160
View File
@@ -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>
);
}
+1 -1
View File
@@ -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">
+19 -1
View File
@@ -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 });
}
+2 -2
View File
@@ -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,
+31 -4
View File
@@ -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 });
}
}
+2 -2
View File
@@ -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
View File
@@ -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>
+40
View File
@@ -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>
);
}
+38 -16
View File
@@ -6,6 +6,7 @@ import ThemeSwitcher from './ThemeSwitcher';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useState, ReactNode } from 'react';
import { ShieldCheckIcon } from '@heroicons/react/24/outline';
interface MenuItem {
label: string;
@@ -37,6 +38,14 @@ export default function Navbar() {
onClick: () => setMenuOpen(false),
}
: undefined,
// Admin link for admin users
session?.user && (session.user as any)?.isAdmin
? {
label: 'Admin Dashboard',
href: '/admin',
onClick: () => setMenuOpen(false),
}
: undefined,
session?.user
? {
label: 'Sign Out',
@@ -64,14 +73,32 @@ export default function Navbar() {
return (
<>
{/* Admin Banner - Moved to top */}
{session?.user && (session.user as any)?.isAdmin && (
<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" />
<span className="text-sm font-medium">Admin Panel</span>
</div>
<Link
href="/admin"
className="text-sm hover:text-purple-200 transition-colors underline underline-offset-2"
>
Go to Admin Dashboard
</Link>
</div>
</div>
)}
{/* 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)}
@@ -93,8 +120,10 @@ export default function Navbar() {
{session.user.email}
</div>
)}
{menuItems.map((item, idx) =>
item.custom ? (
{menuItems.map((item, idx) => {
if (!item) return null;
return item.custom ? (
<div key={idx} className="px-4 py-2 flex items-center justify-between">
<span className="text-sm text-neutral-700 dark:text-neutral-200">Theme</span>
{item.custom}
@@ -105,7 +134,7 @@ export default function Navbar() {
href={item.href}
className={`block px-4 py-2 text-sm rounded transition-colors ${
item.active
? 'bg-primary/10 text-primary font-semibold'
? 'bg-blue-100 text-blue-700 font-semibold'
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
onClick={item.onClick}
@@ -120,15 +149,15 @@ export default function Navbar() {
>
{item.label}
</button>
)
)}
);
})}
</div>
</div>
)}
</>
) : (
<button
className="btn btn-sm btn-primary font-semibold px-4 py-1"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-1 rounded-md text-sm transition-colors"
onClick={() => signIn()}
>
Sign In
@@ -156,13 +185,6 @@ export default function Navbar() {
</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>
</>
+19 -19
View File
@@ -28,43 +28,43 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'badge-error',
color: 'bg-red-100 text-red-800',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'badge-warning',
color: 'bg-yellow-100 text-yellow-800',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'badge-secondary',
color: 'bg-gray-100 text-gray-800',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'badge-info',
color: 'bg-blue-100 text-blue-800',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'badge-warning',
color: 'bg-yellow-100 text-yellow-800',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'badge-accent',
color: 'bg-purple-100 text-purple-800',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'badge-success',
color: 'bg-green-100 text-green-800',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
@@ -75,7 +75,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
return (
<div
className={`badge ${config.color} gap-1 cursor-help`}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color} gap-1 cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
@@ -85,7 +85,7 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
};
return (
<div className="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
<div className="bg-white dark:bg-gray-800 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<figure className="relative">
<img
src={imageError ? '/window.svg' : product.image_url}
@@ -102,25 +102,25 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
)}
</figure>
<div className="card-body">
<h3 className="card-title text-base-content line-clamp-2">{product.name}</h3>
<p className="text-base-content/70 text-sm line-clamp-2">{product.description}</p>
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white line-clamp-2">{product.name}</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">{product.description}</p>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-base-content/60">{product.brand.name}</span>
<span className="text-lg font-bold text-primary">${lowestPrice.toFixed(2)}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{product.brand.name}</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">
<span className="text-xs text-base-content/50">{product.category.name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{product.category.name}</span>
<Link href={`/products/${product.id}`} legacyBehavior>
<a className="btn btn-primary btn-sm">
View Details
</a>
<a className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors">
View Details
</a>
</Link>
{onAdd && (
<button
className="btn btn-neutral btn-sm ml-2 flex items-center gap-1"
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors ml-2 flex items-center gap-1"
onClick={onAdd}
disabled={added}
>
+571
View File
@@ -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
View File
@@ -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
View File
@@ -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: [],
}