10 Commits

66 changed files with 5928 additions and 2558 deletions
+1
View File
@@ -0,0 +1 @@
{}
+10
View File
@@ -0,0 +1,10 @@
const bcrypt = require('bcryptjs');
// Replace with your plaintext password and hash
const plaintextPassword = 'newpassword'; // <-- put your real password here
const hash = '$2b$10$n78/VuxwnDoOemWoqjVKnunz5PZy7SisG3VUhsPtQXKEEnMej6TWK';
bcrypt.compare(plaintextPassword, hash, (err, result) => {
if (err) throw err;
console.log('Password matches hash?', result); // true if matches, false if not
});
+15
View File
@@ -0,0 +1,15 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle/migrations",
dialect: "postgresql",
dbCredentials: {
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"
}
+12
View File
@@ -0,0 +1,12 @@
const bcrypt = require('bcryptjs');
const password = process.argv[2];
if (!password) {
console.error('Usage: node hash-password.js <password>');
process.exit(1);
}
bcrypt.hash(password, 10, (err, hash) => {
if (err) throw err;
console.log('Hashed password:', hash);
});
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
if (!token || !token.isAdmin) {
return NextResponse.redirect(new URL('/account/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["www.brownells.com"],
},
};
module.exports = nextConfig;
-9
View File
@@ -1,9 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [],
},
};
export default nextConfig;
+1584 -1331
View File
File diff suppressed because it is too large Load Diff
+15 -8
View File
@@ -10,25 +10,32 @@
},
"dependencies": {
"@auth/core": "^0.34.2",
"@auth/drizzle-adapter": "^1.10.0",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"daisyui": "^4.7.3",
"next": "15.3.4",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.2",
"lucide-react": "^0.525.0",
"next": "^14.2.30",
"next-auth": "^4.24.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"pg": "^8.16.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "^3.4.3",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"@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",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.4",
"typescript": "^5"
}
}
+1 -1
View File
@@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
};
}
@@ -13,7 +13,7 @@ export default function ForgotPasswordPage() {
return (
<div className="flex flex-1 items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/>
@@ -24,21 +24,21 @@ export default function ForgotPasswordPage() {
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={submitted}
/>
<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>
@@ -8,7 +8,7 @@ export default function AccountLayout({
return (
<div className="min-h-screen flex flex-col">
{/* Simple navbar with back button */}
<nav className="bg-white dark:bg-neutral-900 shadow-sm">
<nav className="bg-white dark:bg-zinc-900 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-start h-16">
<div className="flex items-center">
@@ -49,13 +49,13 @@ export default function LoginPage() {
/>
</div>
{/* Right side form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-neutral-900 min-h-screen">
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-zinc-900 min-h-screen">
<div className="mx-auto w-full max-w-md space-y-8">
<div>
<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-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-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-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-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'}
@@ -139,10 +139,10 @@ export default function LoginPage() {
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-neutral-700" />
<div className="w-full border-t border-gray-300 dark:border-zinc-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-neutral-900 text-gray-500 dark:text-gray-400">
<span className="px-2 bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400">
Or continue with
</span>
</div>
@@ -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-neutral-700 rounded-md shadow-sm bg-white dark:bg-neutral-800 text-sm font-medium text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-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>
@@ -23,7 +23,7 @@ export default function ProfilePage() {
}
return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-neutral-900 rounded shadow">
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-zinc-900 rounded shadow">
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<div className="space-y-2">
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>
@@ -44,15 +44,15 @@ export default function RegisterPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-neutral-900">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-zinc-900">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={loading}
@@ -62,7 +62,7 @@ export default function RegisterPage() {
type={showPassword ? 'text' : 'password'}
required
placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10"
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-md bg-white dark:bg-zinc-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
@@ -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>
@@ -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>
)}
@@ -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">
+20
View File
@@ -0,0 +1,20 @@
import "../globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "@/components/Providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Pew Builder - Firearm Parts Catalog & Build Management",
description: "Professional firearm parts catalog and AR-15 build management system",
};
export default function MainAppLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
{children}
</Providers>
);
}
@@ -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">
+7 -6
View File
@@ -1,4 +1,4 @@
import BetaTester from "../components/BetaTester";
import BetaTester from "@/components/BetaTester";
import Link from 'next/link';
export default function LandingPage() {
@@ -29,29 +29,30 @@ export default function LandingPage() {
A better way to plan your next build
</h1>
<p className="mt-8 text-pretty text-lg font-medium text-gray-500 sm:text-xl/8">
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
fugiat veniam occaecat fugiat aliqua. Anim aute id magna aliqua ad ad non deserunt sunt.
Welcome to Pew Builder the modern way to discover, compare, and assemble firearm parts. Start your build, explore top brands, and find the perfect components for your next project.
</p>
<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>
</div>
</div>
{/* Right: Product Image */}
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center">
<div className="mt-16 sm:mt-24 lg:mt-0 lg:shrink-0 lg:grow items-top flex justify-center group">
<img
alt="AR-15 Lower Receiver"
src="https://i.imgur.com/IK8FbaI.png"
className="max-w-md w-full h-auto object-contain rounded-xl"
className="max-w-md w-full h-auto object-contain rounded-xl transition-transform duration-500 ease-in-out animate-float group-hover:-translate-y-2"
style={{ willChange: 'transform' }}
/>
</div>
</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>
);
}
+124
View File
@@ -0,0 +1,124 @@
// Minimal, future-proof category mapping for builder logic
export const categoryToComponentType: Record<string, string> = {
"Muzzle Devices": "Muzzle Device",
"Receiver Parts": "Lower Receiver",
"Barrel Parts": "Barrel",
"Stock Parts": "Stock",
"Bolt Parts": "Bolt Carrier Group",
"Triggers Parts": "Trigger",
"Sights": "Accessories"
};
// Map category to builder component type, with fallback heuristics
export function mapToBuilderType(category: string): string {
if (categoryToComponentType[category]) {
return categoryToComponentType[category];
}
// Fallback: guess based on keywords
if (category?.toLowerCase().includes('barrel')) return 'Barrel';
if (category?.toLowerCase().includes('stock')) return 'Stock';
if (category?.toLowerCase().includes('bolt')) return 'Bolt Carrier Group';
if (category?.toLowerCase().includes('trigger')) return 'Trigger';
if (category?.toLowerCase().includes('sight') || category?.toLowerCase().includes('optic')) return 'Accessories';
// Log for future mapping
console.warn('Unmapped category:', category);
return 'Accessories';
}
// List of standardized builder component types (from component_type.csv)
export const standardizedComponentTypes = [
"Upper Receiver",
"Barrel",
"Muzzle Device",
"Lower Receiver",
"Safety",
"Trigger",
"Gas Tube",
"Gas Block",
"Grips",
"Handguards",
"Charging Handle",
"Bolt Carrier Group",
"Magazine",
"Buffer Assembly",
"Buffer Tube",
"Foregrips",
"Lower Parts Kit",
"Accessories"
];
// Builder category hierarchy for filters
export const builderCategories = [
{
id: "upper-parts",
name: "Upper Parts",
subcategories: [
{ id: "complete-upper", name: "Complete Upper Receiver" },
{ id: "stripped-upper", name: "Stripped Upper Receiver" },
{ id: "barrel", name: "Barrel" },
{ id: "gas-block", name: "Gas Block" },
{ id: "gas-tube", name: "Gas Tube" },
{ id: "handguard", name: "Handguard / Rail" },
{ id: "bcg", name: "Bolt Carrier Group (BCG)" },
{ id: "charging-handle", name: "Charging Handle" },
{ id: "muzzle-device", name: "Muzzle Device" },
{ id: "forward-assist", name: "Forward Assist" },
{ id: "dust-cover", name: "Dust Cover" }
]
},
{
id: "lower-parts",
name: "Lower Parts",
subcategories: [
{ id: "complete-lower", name: "Complete Lower Receiver" },
{ id: "stripped-lower", name: "Stripped Lower Receiver" },
{ id: "lower-parts-kit", name: "Lower Parts Kit" },
{ id: "trigger", name: "Trigger / Fire Control Group" },
{ id: "buffer-tube", name: "Buffer Tube Assembly" },
{ id: "buffer-spring", name: "Buffer & Spring" },
{ id: "stock", name: "Stock / Brace" },
{ id: "pistol-grip", name: "Pistol Grip" },
{ id: "trigger-guard", name: "Trigger Guard" },
{ id: "ambi-controls", name: "Ambidextrous Controls" }
]
},
{
id: "accessories",
name: "Accessories",
subcategories: [
{ id: "optics", name: "Optics & Sights" },
{ id: "sling-mounts", name: "Sling Mounts / QD Points" },
{ id: "slings", name: "Slings" },
{ id: "grips-bipods", name: "Vertical Grips / Bipods" },
{ id: "lights", name: "Weapon Lights" },
{ id: "magazines", name: "Magazines" },
{ id: "optic-mounts", name: "Optic Mounts / Rings" },
{ id: "suppressors", name: "Suppressors / Adapters" }
]
},
{
id: "kits-bundles",
name: "Kits / Bundles",
subcategories: [
{ id: "rifle-kit", name: "Rifle Kit" },
{ id: "pistol-kit", name: "Pistol Kit" },
{ id: "upper-kit", name: "Upper Build Kit" },
{ id: "lower-kit", name: "Lower Build Kit" },
{ id: "kit-80", name: "80% Build Kit" },
{ id: "receiver-set", name: "Matched Receiver Set" }
]
}
];
// Example subcategory mapping (expand as needed)
export const subcategoryMapping: Record<string, string> = {
"Rifle Barrels": "barrel",
"Bolt Carrier Groups": "bcg",
"Handguards & Rails": "handguard",
"Suppressors": "suppressors",
"Receivers": "complete-upper", // or "stripped-upper" if you want to split
"Triggers": "trigger",
"Rifle Stocks": "stock",
"Buttstocks": "stock",
// ...add more mappings as needed
};
+558
View File
@@ -0,0 +1,558 @@
'use client';
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';
import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip';
import Link from 'next/link';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
// Product type (copied from mock/product for type safety)
type Product = {
id: string;
name: string;
description: string;
longDescription?: string;
image_url: string;
images?: string[];
brand: { id: string; name: string; logo?: string };
category: { id: string; name: string };
subcategory?: string;
offers: Array<{
price: number;
url: string;
vendor: { name: string; logo?: string };
inStock?: boolean;
shipping?: string;
}>;
restrictions?: {
nfa?: boolean;
sbr?: boolean;
suppressor?: boolean;
stateRestrictions?: string[];
};
slug: string;
};
// Restrictions for filter dropdown
const restrictionOptions = [
{ value: '', label: 'All Restrictions' },
{ value: 'NFA', label: 'NFA' },
{ value: 'SBR', label: 'SBR' },
{ value: 'Suppressor', label: 'Suppressor' },
{ value: 'State Restrictions', label: 'State Restrictions' },
];
type SortField = 'name' | 'category' | 'price';
type SortDirection = 'asc' | 'desc';
// Restriction indicator component
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'bg-red-600 text-white',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'bg-orange-600 text-white',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'bg-purple-600 text-white',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'bg-blue-600 text-white',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'bg-yellow-600 text-black',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'bg-pink-600 text-white',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'bg-green-600 text-white',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
};
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
if (!config) return null;
return (
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
);
};
// Tailwind UI Dropdown Component
const Dropdown = ({
label,
value,
onChange,
options,
placeholder = "Select option"
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-zinc-700 mb-1">
{label}
</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">
{value || placeholder}
</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>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-blue-100 text-blue-900' : 'text-zinc-900'
}`
}
value={option.value}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
// Map product categories to checklist component categories
const getComponentCategory = (productCategory: string): string => {
const categoryMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper',
'Barrel': 'Upper',
'BCG': 'Upper',
'Bolt Carrier Group': 'Upper',
'Charging Handle': 'Upper',
'Gas Block': 'Upper',
'Gas Tube': 'Upper',
'Handguard': 'Upper',
'Muzzle Device': 'Upper',
'Suppressor': 'Upper',
// Lower components
'Lower Receiver': 'Lower',
'Trigger': 'Lower',
'Trigger Guard': 'Lower',
'Pistol Grip': 'Lower',
'Buffer Tube': 'Lower',
'Buffer': 'Lower',
'Buffer Spring': 'Lower',
'Stock': 'Lower',
// Accessories
'Magazine': 'Accessory',
'Sights': 'Accessory',
'Optic': 'Accessory',
'Scope': 'Accessory',
'Red Dot': 'Accessory',
};
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
};
// 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>
);
};
// --- Canonical Category Fetch ---
const useCanonicalCategories = () => {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/categories')
.then(res => res.json())
.then(data => {
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(err.message || 'Error fetching products');
setLoading(false);
});
}, []);
// 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]);
// 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]);
// 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, category, subcategory, limit]);
return (
<>
<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>
</div>
</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>
{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>
</>
);
}
@@ -1,23 +1,77 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { mockProducts } from '@/mock/product';
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';
// Product type (copied from /parts/page.tsx for type safety)
type Product = {
id: string;
name: string;
description: string;
longDescription?: string;
image_url: string;
images?: string[];
brand: { id: string; name: string; logo?: string };
category: { id: string; name: string };
subcategory?: string;
offers: Array<{
price: number;
url: string;
vendor: { name: string; logo?: string };
inStock?: boolean;
shipping?: string;
}>;
restrictions?: {
nfa?: boolean;
sbr?: boolean;
suppressor?: boolean;
stateRestrictions?: string[];
};
};
export default function ProductDetailsPage() {
const params = useParams();
const productId = params.id as string;
const product = mockProducts.find(p => p.id === productId);
const slug = params.slug as string;
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [selectedOffer, setSelectedOffer] = useState(0);
const [addSuccess, setAddSuccess] = useState(false);
const selectPartForComponent = useBuildStore((state) => state.selectPartForComponent);
useEffect(() => {
setLoading(true);
fetch(`/api/products/${slug}`)
.then(res => res.json())
.then((data: any) => {
if (data.success && data.product) {
setProduct(data.product);
} else {
setError('No data returned from API');
}
setLoading(false);
})
.catch((err: any) => {
setError(String(err));
setLoading(false);
});
}, [slug]);
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="alert alert-info">
<span>Loading product...</span>
</div>
</div>
);
}
if (!product) {
return (
<div className="container mx-auto px-4 py-8">
@@ -28,19 +82,22 @@ export default function ProductDetailsPage() {
);
}
// Use images array if present, otherwise fallback to image_url
// 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];
const lowestPrice = Math.min(...product.offers.map(o => o.price));
const highestPrice = Math.max(...product.offers.map(o => o.price));
const averageRating = product.reviews
? product.reviews.reduce((acc, review) => acc + review.rating, 0) / product.reviews.length
: 0;
const handleAddToBuild = () => {
// Map category to component ID
const categoryToComponentMap: Record<string, string> = {
// Map category to component ID (can be improved to match /parts logic)
const categoryToComponentMap = {
'Barrel': 'barrel',
'Upper Receiver': 'upper',
'Suppressor': 'suppressor',
@@ -60,9 +117,7 @@ export default function ProductDetailsPage() {
'Magazine': 'magazine',
'Sights': 'sights'
};
const componentId = categoryToComponentMap[product.category.name] || product.category.id;
const componentId = (categoryToComponentMap as Record<string, string>)[product.category.name] || product.category.id;
selectPartForComponent(componentId, {
id: product.id,
name: product.name,
@@ -78,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) && (
@@ -100,7 +189,7 @@ export default function ProductDetailsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Product Images */}
<div className="space-y-4">
<div className="aspect-square bg-neutral-100 dark:bg-neutral-800 rounded-lg overflow-hidden">
<div className="aspect-square bg-zinc-100 dark:bg-zinc-800 rounded-lg overflow-hidden">
<Image
src={allImages[selectedImageIndex]}
alt={product.name}
@@ -119,7 +208,7 @@ export default function ProductDetailsPage() {
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 ${
selectedImageIndex === index
? 'border-primary-500'
: 'border-neutral-200 dark:border-neutral-700'
: 'border-zinc-200 dark:border-zinc-700'
}`}
>
<Image
@@ -149,72 +238,54 @@ export default function ProductDetailsPage() {
/>
)}
<div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.brand.name}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{product.category.name}
</div>
</div>
</div>
{/* Product Name */}
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">
{product.name}
</h1>
{/* 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 && (
<div className="text-lg text-neutral-600 dark:text-neutral-400">
<div className="text-lg text-zinc-600 dark:text-zinc-400">
- ${highestPrice.toFixed(2)}
</div>
)}
<div className="text-sm text-neutral-500">
<div className="text-sm text-zinc-500">
from {product.offers.length} vendor{product.offers.length > 1 ? 's' : ''}
</div>
</div>
{/* Reviews */}
{product.reviews && product.reviews.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={`h-5 w-5 ${
star <= averageRating
? 'text-yellow-400'
: 'text-neutral-300 dark:text-neutral-600'
}`}
/>
))}
</div>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{averageRating.toFixed(1)} ({product.reviews.length} reviews)
</span>
</div>
)}
{/* Description */}
<div>
<h3 className="text-lg font-semibold mb-2">Description</h3>
<p className="text-neutral-700 dark:text-neutral-300">
<p className="text-zinc-700 dark:text-zinc-300">
{product.longDescription || product.description}
</p>
</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>
@@ -222,54 +293,6 @@ export default function ProductDetailsPage() {
</div>
</div>
{/* Specifications */}
{product.specifications && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Specifications</h2>
<div className="card">
<div className="card-body">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{product.specifications.weight && (
<div>
<span className="font-semibold">Weight:</span> {product.specifications.weight}
</div>
)}
{product.specifications.length && (
<div>
<span className="font-semibold">Length:</span> {product.specifications.length}
</div>
)}
{product.specifications.material && (
<div>
<span className="font-semibold">Material:</span> {product.specifications.material}
</div>
)}
{product.specifications.finish && (
<div>
<span className="font-semibold">Finish:</span> {product.specifications.finish}
</div>
)}
{product.specifications.caliber && (
<div>
<span className="font-semibold">Caliber:</span> {product.specifications.caliber}
</div>
)}
{product.specifications.compatibility && (
<div className="md:col-span-2">
<span className="font-semibold">Compatibility:</span>
<div className="flex flex-wrap gap-2 mt-1">
{product.specifications.compatibility.map((comp, index) => (
<span key={index} className="badge badge-outline">{comp}</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Vendor Offers */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Where to Buy</h2>
@@ -291,7 +314,7 @@ export default function ProductDetailsPage() {
<div>
<div className="font-semibold">{offer.vendor.name}</div>
{offer.shipping && (
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{offer.shipping}
</div>
)}
@@ -299,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 && (
@@ -312,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>
@@ -323,52 +346,6 @@ export default function ProductDetailsPage() {
))}
</div>
</div>
{/* Reviews */}
{product.reviews && product.reviews.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
<div className="space-y-4">
{product.reviews.map((review) => (
<div key={review.id} className="card">
<div className="card-body">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={`h-4 w-4 ${
star <= review.rating
? 'text-yellow-400'
: 'text-neutral-300 dark:text-neutral-600'
}`}
/>
))}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{new Date(review.date).toLocaleDateString()}
</div>
</div>
<div className="font-semibold mb-1">{review.user}</div>
<p className="text-neutral-700 dark:text-neutral-300">{review.comment}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Compatibility */}
{product.compatibility && product.compatibility.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Compatible Parts</h2>
<div className="flex flex-wrap gap-2">
{product.compatibility.map((part, index) => (
<span key={index} className="badge badge-primary">{part}</span>
))}
</div>
</div>
)}
</div>
);
}
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
export default function TestProductsPage() {
const [products, setProducts] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/test-products')
.then(res => res.json())
.then(data => {
if (data.success) setProducts(data.data);
else setError(data.error || "Unknown error");
})
.catch(err => setError(String(err)));
}, []);
return (
<div className="max-w-2xl mx-auto py-10">
<h1 className="text-2xl font-bold mb-4">Test Products API</h1>
{error && <div className="text-red-500 mb-4">Error: {error}</div>}
<pre className="bg-gray-100 p-4 rounded overflow-x-auto text-xs">
{JSON.stringify(products, null, 2)}
</pre>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { useState, ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { useSession } from 'next-auth/react';
import {
Dialog,
DialogBackdrop,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
} from '@headlessui/react';
import {
Bars3Icon,
BellIcon,
CalendarIcon,
ChartPieIcon,
Cog6ToothIcon,
DocumentDuplicateIcon,
FolderIcon,
HomeIcon,
UsersIcon,
XMarkIcon,
CubeIcon,
} from '@heroicons/react/24/outline';
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid';
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: '/account/profile' },
{ name: 'Sign out', href: '/api/auth/signout' },
];
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
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 (
<>
<div>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
{/* Sidebar component */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
pathname === item.href
? 'bg-gray-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
pathname === item.href ? 'text-indigo-600' : 'text-gray-400 group-hover:text-indigo-600',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
{/* Sidebar component */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
className="h-8 w-auto"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
pathname === item.href
? 'bg-gray-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-indigo-600',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
pathname === item.href ? 'text-indigo-600' : 'text-gray-400 group-hover:text-indigo-600',
'size-6 shrink-0',
)}
/>
{item.name}
</a>
</li>
))}
</ul>
</li>
<li className="mt-auto">
<a
href="#"
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold text-gray-700 hover:bg-gray-50 hover:text-indigo-600"
>
<Cog6ToothIcon
aria-hidden="true"
className="size-6 shrink-0 text-gray-400 group-hover:text-indigo-600"
/>
Settings
</a>
</li>
</ul>
</nav>
</div>
</div>
<div className="lg:pl-72">
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button type="button" onClick={() => setSidebarOpen(true)} className="-m-2.5 p-2.5 text-gray-700 lg:hidden">
<span className="sr-only">Open sidebar</span>
<Bars3Icon aria-hidden="true" className="size-6" />
</button>
{/* Separator */}
<div aria-hidden="true" className="h-6 w-px bg-gray-200 lg:hidden" />
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
<input
name="search"
type="search"
placeholder="Search"
aria-label="Search"
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-none placeholder:text-gray-400 sm:text-sm/6"
/>
<MagnifyingGlassIcon
aria-hidden="true"
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>
<BellIcon aria-hidden="true" className="size-6" />
</button>
{/* Separator */}
<div aria-hidden="true" className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200" />
{/* Profile dropdown */}
<Menu as="div" className="relative">
<MenuButton className="relative flex items-center">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<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">
{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-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-2 text-sm text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none hover:bg-gray-50"
>
{item.name}
</a>
</MenuItem>
))}
</MenuItems>
</Menu>
</div>
</div>
</div>
<main className="py-10">
{children}
</main>
</div>
</div>
</>
);
}
+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>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useState, ChangeEvent, FormEvent } from "react";
import CategoryTreeTest from '@/components/CategoryTreeTest';
type Mapping = {
id: number;
feedname: string;
affiliatecategory: string;
buildercategoryid: number;
notes?: string;
};
type MappingForm = {
feedname: string;
affiliatecategory: string;
buildercategoryid: string;
notes?: string;
};
export default function CategoryMappingAdmin() {
const [mappings, setMappings] = useState<Mapping[]>([]);
const [form, setForm] = useState<MappingForm>({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState<MappingForm>({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
const [productCategories, setProductCategories] = useState<any[]>([]);
// Fetch all mappings
const fetchMappings = async () => {
const res = await fetch("/api/category-mapping");
const data = await res.json();
setMappings(data.data || []);
};
const fetchProductCategories = async () => {
const res = await fetch("/api/product-categories");
const data = await res.json();
setProductCategories(data.data || []);
};
useEffect(() => {
fetchMappings();
fetchProductCategories();
}, []);
// Add new mapping
const handleAdd = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
await fetch("/api/category-mapping", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
buildercategoryid: parseInt(form.buildercategoryid, 10)
}),
});
setForm({ feedname: "", affiliatecategory: "", buildercategoryid: "", notes: "" });
fetchMappings();
};
// Edit mapping
const handleEdit = (mapping: Mapping) => {
setEditingId(mapping.id);
setEditForm({ ...mapping, buildercategoryid: mapping.buildercategoryid.toString() });
};
const handleUpdate = async (e: FormEvent<HTMLButtonElement>) => {
e.preventDefault();
await fetch("/api/category-mapping", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...editForm, buildercategoryid: parseInt(editForm.buildercategoryid, 10), id: editingId }),
});
setEditingId(null);
fetchMappings();
};
// Delete mapping
const handleDelete = async (id: number) => {
await fetch("/api/category-mapping", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
fetchMappings();
};
return (
<div className="max-w-3xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-4">Affiliate Category Mapping Admin</h1>
<div className="mb-8">
<h2 className="text-xl font-semibold mb-2">AR15 Category Hierarchy</h2>
<table className="min-w-full border text-sm">
<thead>
<tr className="bg-zinc-100">
<th className="border px-2 py-1">ID</th>
<th className="border px-2 py-1">Category</th>
<th className="border px-2 py-1">Parent</th>
<th className="border px-2 py-1">Type</th>
</tr>
</thead>
<tbody>
{productCategories
.filter(cat => cat.type === 'AR15')
.map(cat => {
const parent = productCategories.find(p => p.id === cat.parent_category_id);
return (
<tr key={cat.id}>
<td className="border px-2 py-1">{cat.id}</td>
<td className="border px-2 py-1">{cat.name}</td>
<td className="border px-2 py-1">{parent ? parent.name : 'Top Level'}</td>
<td className="border px-2 py-1">{cat.type}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<form onSubmit={handleAdd} className="flex gap-2 mb-6">
<input className="border px-2 py-1 rounded w-32" placeholder="Feed Name" value={form.feedname} onChange={e => setForm(f => ({ ...f, feedname: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-48" placeholder="Affiliate Category" value={form.affiliatecategory} onChange={e => setForm(f => ({ ...f, affiliatecategory: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-40" placeholder="Builder Category ID" value={form.buildercategoryid} onChange={e => setForm(f => ({ ...f, buildercategoryid: e.target.value }))} required />
<input className="border px-2 py-1 rounded w-32" placeholder="Notes" value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
<button className="bg-blue-600 text-white px-3 py-1 rounded" type="submit">Add</button>
</form>
<table className="min-w-full border text-sm">
<thead>
<tr className="bg-zinc-100">
<th className="border px-2 py-1">Feed Name</th>
<th className="border px-2 py-1">Affiliate Category</th>
<th className="border px-2 py-1">Builder Category ID</th>
<th className="border px-2 py-1">Notes</th>
<th className="border px-2 py-1">Actions</th>
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr key={m.id}>
{editingId === m.id ? (
<>
<td className="border px-2 py-1"><input className="border px-1 rounded w-28" value={editForm.feedname} onChange={e => setEditForm(f => ({ ...f, feedname: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-40" value={editForm.affiliatecategory} onChange={e => setEditForm(f => ({ ...f, affiliatecategory: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-32" value={editForm.buildercategoryid} onChange={e => setEditForm(f => ({ ...f, buildercategoryid: e.target.value }))} /></td>
<td className="border px-2 py-1"><input className="border px-1 rounded w-24" value={editForm.notes} onChange={e => setEditForm(f => ({ ...f, notes: e.target.value }))} /></td>
<td className="border px-2 py-1">
<button className="bg-green-600 text-white px-2 py-1 rounded mr-1" onClick={handleUpdate}>Save</button>
<button className="bg-gray-400 text-white px-2 py-1 rounded" onClick={() => setEditingId(null)}>Cancel</button>
</td>
</>
) : (
<>
<td className="border px-2 py-1">{m.feedname}</td>
<td className="border px-2 py-1">{m.affiliatecategory}</td>
<td className="border px-2 py-1">{m.buildercategoryid}</td>
<td className="border px-2 py-1">{m.notes}</td>
<td className="border px-2 py-1">
<button className="bg-yellow-500 text-white px-2 py-1 rounded mr-1" onClick={() => handleEdit(m)}>Edit</button>
<button className="bg-red-600 text-white px-2 py-1 rounded" onClick={() => handleDelete(m.id)}>Delete</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
<CategoryTreeTest />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import AdminNavbar from './AdminNavbar';
import AdminProviders from './AdminProviders';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminProviders>
<AdminNavbar>
{children}
</AdminNavbar>
</AdminProviders>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState } from 'react';
import {
Users,
ShoppingCart,
TrendingUp,
AlertTriangle,
CheckCircle,
Clock,
BarChart3,
Activity,
Calendar,
Settings
} from 'lucide-react';
export default function AdminDashboard() {
const [selectedPeriod, setSelectedPeriod] = useState('7d');
// Sample data - in a real app, this would come from your database
const stats = {
totalUsers: 1247,
activeUsers: 892,
totalProducts: 15420,
totalBuilds: 3421,
revenue: 45678,
growth: 12.5,
pendingApprovals: 23,
systemAlerts: 2
};
const recentActivity = [
{ id: 1, user: 'John Doe', action: 'Created new build', time: '2 minutes ago', type: 'build' },
{ id: 2, user: 'Jane Smith', action: 'Updated profile', time: '5 minutes ago', type: 'profile' },
{ id: 3, user: 'Mike Johnson', action: 'Added product to cart', time: '8 minutes ago', type: 'cart' },
{ id: 4, user: 'Sarah Wilson', action: 'Completed purchase', time: '12 minutes ago', type: 'purchase' },
{ id: 5, user: 'Admin User', action: 'Updated category mapping', time: '15 minutes ago', type: 'admin' }
];
const topProducts = [
{ name: 'AR-15 Lower Receiver', sales: 156, revenue: 12480 },
{ name: 'Tactical Scope', sales: 89, revenue: 17800 },
{ name: 'Gun Case', sales: 234, revenue: 11700 },
{ name: 'Ammo Storage', sales: 67, revenue: 3350 },
{ name: 'Cleaning Kit', sales: 189, revenue: 5670 }
];
const systemStatus = [
{ service: 'Database', status: 'healthy', uptime: '99.9%' },
{ service: 'API', status: 'healthy', uptime: '99.8%' },
{ service: 'File Storage', status: 'warning', uptime: '98.5%' },
{ service: 'Email Service', status: 'healthy', uptime: '99.7%' }
];
return (
<div className="p-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
<p className="text-gray-600 mt-1">Welcome back! Here's what's happening with your platform.</p>
</div>
<div className="flex items-center space-x-4">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors">
<Settings className="w-4 h-4 inline mr-2" />
Settings
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<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-primary" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Users</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalUsers.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">+{stats.growth}%</span>
<span className="text-gray-500 ml-1">from last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<ShoppingCart className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Products</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalProducts.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">Active</span>
<span className="text-gray-500 ml-1">in catalog</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-purple-100 rounded-lg">
<BarChart3 className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Builds</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalBuilds.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<Activity className="w-4 h-4 text-blue-500 mr-1" />
<span className="text-primary">{stats.activeUsers}</span>
<span className="text-gray-500 ml-1">active users</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-orange-100 rounded-lg">
<TrendingUp className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Revenue</p>
<p className="text-2xl font-bold text-gray-900">${stats.revenue.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">+8.2%</span>
<span className="text-gray-500 ml-1">from last month</span>
</div>
</div>
</div>
{/* Alerts and Notifications */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
</div>
<div className="p-6">
<div className="space-y-4">
{recentActivity.map((activity) => (
<div key={activity.id} className="flex items-center space-x-3">
<div className={`w-2 h-2 rounded-full ${
activity.type === 'build' ? 'bg-blue-500' :
activity.type === 'profile' ? 'bg-green-500' :
activity.type === 'cart' ? 'bg-orange-500' :
activity.type === 'purchase' ? 'bg-purple-500' :
'bg-gray-500'
}`} />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{activity.user}</p>
<p className="text-sm text-gray-500">{activity.action}</p>
</div>
<div className="text-sm text-gray-400">{activity.time}</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* Pending Actions */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Pending Actions</h3>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-3" />
<span className="text-sm font-medium">Approvals Needed</span>
</div>
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
{stats.pendingApprovals}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 text-red-500 mr-3" />
<span className="text-sm font-medium">System Alerts</span>
</div>
<span className="bg-red-100 text-red-800 text-xs font-medium px-2 py-1 rounded-full">
{stats.systemAlerts}
</span>
</div>
</div>
</div>
</div>
{/* System Status */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">System Status</h3>
</div>
<div className="p-6">
<div className="space-y-3">
{systemStatus.map((service, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{service.service}</span>
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
service.status === 'healthy' ? 'bg-green-500' : 'bg-yellow-500'
}`} />
<span className="text-xs text-gray-500">{service.uptime}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Top Products */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Top Products</h3>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sales
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Revenue
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{topProducts.map((product, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{product.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.sales}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${product.revenue.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</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>
);
}
+183
View File
@@ -0,0 +1,183 @@
import React from 'react';
import { db } from '@/db';
import { users } from '@/db/schema';
import { format } from 'date-fns';
async function getUsers() {
try {
const allUsers = await db.query.users.findMany({
columns: {
id: true,
email: true,
name: true,
first_name: true,
last_name: true,
isAdmin: true,
emailVerified: true,
createdAt: true,
lastLogin: true,
buildPrivacySetting: true
},
orderBy: (users, { desc }) => [desc(users.createdAt)]
});
return allUsers;
} catch (error) {
console.error('Error fetching users:', error);
return [];
}
}
export default async function AdminUsersPage() {
const usersList = await getUsers();
const adminCount = usersList.filter(user => user.isAdmin).length;
const verifiedCount = usersList.filter(user => user.emailVerified).length;
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Users</h1>
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-500">
Total: {usersList.length} | Admins: {adminCount} | Verified: {verifiedCount}
</div>
</div>
</div>
{/* 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-primary">{usersList.length}</div>
<div className="text-sm text-gray-500">Total Users</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-purple-600">{adminCount}</div>
<div className="text-sm text-gray-500">Admins</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{verifiedCount}</div>
<div className="text-sm text-gray-500">Verified</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-orange-600">{usersList.length - verifiedCount}</div>
<div className="text-sm text-gray-500">Unverified</div>
</div>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Privacy
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{usersList.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-sm font-medium text-gray-700">
{user.first_name?.[0] || user.last_name?.[0] || user.email[0].toUpperCase()}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.first_name && user.last_name
? `${user.first_name} ${user.last_name}`
: user.name || 'No name'
}
</div>
<div className="text-sm text-gray-500">
ID: {user.id}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.emailVerified
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{user.emailVerified ? 'Verified' : 'Unverified'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.isAdmin
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{user.isAdmin ? 'Admin' : 'User'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.createdAt ? format(new Date(user.createdAt), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.lastLogin ? format(new Date(user.lastLogin), 'MMM d, yyyy') : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.buildPrivacySetting === 'public'
? 'bg-blue-100 text-blue-800'
: 'bg-orange-100 text-orange-800'
}`}>
{user.buildPrivacySetting || 'public'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{usersList.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500">No users found</div>
</div>
)}
</div>
</div>
);
}
+29 -32
View File
@@ -1,16 +1,14 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';
import { users } from '@/db/schema';
import bcrypt from 'bcryptjs';
const handler = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: 'jwt' },
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
@@ -23,25 +21,16 @@ const handler = NextAuth({
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
console.log('Credentials received:', credentials);
if (!credentials?.email || !credentials?.password) return null;
// Check in-memory user store
const user = users.find(
(u) => u.email === credentials.email && u.password === credentials.password
);
if (user) {
return {
id: user.email,
email: user.email,
name: user.email.split('@')[0],
};
}
// For demo, still allow the test user
if (credentials.email === "test@example.com" && credentials.password === "password") {
return {
id: "1",
email: credentials.email,
name: "Test User",
};
// Query the real users table using Drizzle
const foundUser = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, credentials.email),
});
console.log('User found:', foundUser);
if (foundUser && foundUser.hashedPassword && await bcrypt.compare(credentials.password, foundUser.hashedPassword)) {
console.log('Returning user:', foundUser);
return foundUser;
}
return null;
}
@@ -53,17 +42,25 @@ const handler = NextAuth({
// error: '/account/error', // Uncomment when error page is ready
},
callbacks: {
async session({ session, token }) {
// Add any additional user data to the session here
async session({ session, user, token }) {
console.log('Session callback - user:', user);
console.log('Session callback - token:', token);
if (session.user) {
(session.user as any).isAdmin = (user as any)?.isAdmin ?? token?.isAdmin ?? false;
console.log('Session callback - final isAdmin:', (session.user as any).isAdmin);
}
return session;
},
async jwt({ token, user }) {
// Add any additional user data to the JWT here
if (user) {
token.id = user.id;
console.log('JWT callback - user:', user);
console.log('JWT callback - token before:', token);
if (user && typeof user === 'object' && 'isAdmin' in user) {
(token as any).isAdmin = (user as any).isAdmin;
console.log('JWT callback - setting isAdmin to:', (user as any).isAdmin);
}
console.log('JWT callback - token after:', token);
return token;
},
}
},
})
+17
View File
@@ -0,0 +1,17 @@
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
console.log('Check Admin API - Token:', token);
console.log('Check Admin API - isAdmin:', token?.isAdmin);
return NextResponse.json({
isAdmin: token?.isAdmin || false,
token: token
});
}
+7
View File
@@ -0,0 +1,7 @@
import { db } from "@/db";
import { brands } from "@/db/schema";
export async function GET() {
const allBrands = await db.select().from(brands);
return Response.json({ success: true, data: allBrands });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { categories } from '@/db/schema';
export async function GET() {
const allCategories = await db.select().from(categories);
return NextResponse.json({ success: true, data: allCategories });
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { affiliateCategoryMap } from '@/db/schema';
import { eq } from 'drizzle-orm';
// GET: List all mappings
export async function GET() {
try {
const mappings = await db.select().from(affiliateCategoryMap);
return NextResponse.json({ success: true, data: mappings });
} catch (error: any) {
console.error('GET /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error', data: [] }, { status: 500 });
}
}
// POST: Create a new mapping
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { feedname, affiliatecategory, buildercategoryid, notes } = body;
if (!feedname || !affiliatecategory || !buildercategoryid) {
return NextResponse.json({ success: false, error: 'Missing required fields' }, { status: 400 });
}
const [inserted] = await db.insert(affiliateCategoryMap).values({ feedname, affiliatecategory, buildercategoryid, notes }).returning();
return NextResponse.json({ success: true, data: inserted });
} catch (error: any) {
console.error('POST /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
// PUT: Update a mapping
export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const { id, feedname, affiliatecategory, buildercategoryid, notes } = body;
if (!id) {
return NextResponse.json({ success: false, error: 'Missing id' }, { status: 400 });
}
const [updated] = await db.update(affiliateCategoryMap)
.set({ feedname, affiliatecategory, buildercategoryid, notes })
.where(eq(affiliateCategoryMap.id, id))
.returning();
return NextResponse.json({ success: true, data: updated });
} catch (error: any) {
console.error('PUT /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
// DELETE: Remove a mapping
export async function DELETE(req: NextRequest) {
try {
const body = await req.json();
const { id } = body;
if (!id) {
return NextResponse.json({ success: false, error: 'Missing id' }, { status: 400 });
}
await db.delete(affiliateCategoryMap).where(eq(affiliateCategoryMap.id, id));
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('DELETE /api/category-mapping error:', error);
return NextResponse.json({ success: false, error: error.message || 'Unknown error' }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { product_categories } from '@/db/schema';
export async function GET() {
const allCategories = await db.select().from(product_categories);
// 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 });
}
+58
View File
@@ -0,0 +1,58 @@
import { db } from '@/db';
import { products } from '@/db/schema';
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
export async function GET(
req: Request,
{ params }: { params: { slug: string } }
) {
try {
const allProducts = await db.select().from(products);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(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',
logo: '',
},
inStock: true,
shipping: '',
},
],
restrictions: {},
}));
const found = mapped.find((p: any) => p.slug === params.slug);
if (found) {
return Response.json({ success: true, product: found });
} else {
return Response.json({ success: false, error: 'Not found' }, { status: 404 });
}
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
+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 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { db } from "@/db";
import { products } from "@/db/schema";
export async function GET() {
try {
const allProducts = await db.select().from(products).limit(50);
const mapped = allProducts.map((item: any) => ({
id: item.uuid,
name: item.productName,
slug: slugify(item.productName),
...item
}));
return Response.json({ success: true, data: mapped });
} catch (error) {
return Response.json({ success: false, error: String(error) }, { status: 500 });
}
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
+58
View File
@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply transition-colors duration-200;
}
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-100 dark:bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-300 dark:bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-400 dark:bg-zinc-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-zinc-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
+48 -40
View File
@@ -3,48 +3,56 @@
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
html {
scroll-behavior: smooth;
}
body {
@apply transition-colors duration-200;
}
}
body {
@apply transition-colors duration-200;
}
}
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
@layer components {
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-100 dark:bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-300 dark:bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-400 dark:bg-zinc-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-zinc-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
::-webkit-scrollbar-track {
@apply bg-neutral-100 dark:bg-neutral-800;
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-neutral-400 dark:bg-neutral-500;
}
/* Focus styles for better accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-neutral-900;
}
/* Card styles */
.card {
@apply bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */
/* Input styles */
.input-field {
@apply w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-200;
}
}
.animate-float {
animation: float 4s ease-in-out infinite;
}
+2 -5
View File
@@ -1,7 +1,6 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "@/components/Providers";
const inter = Inter({ subsets: ["latin"] });
@@ -16,11 +15,9 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning data-theme="pew">
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} antialiased`}>
<Providers>
{children}
</Providers>
{children}
</body>
</html>
);
-766
View File
@@ -1,766 +0,0 @@
'use client';
import { useState, useEffect } 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';
import SearchInput from '@/components/SearchInput';
import ProductCard from '@/components/ProductCard';
import RestrictionAlert from '@/components/RestrictionAlert';
import Tooltip from '@/components/Tooltip';
import Link from 'next/link';
import { mockProducts } from '@/mock/product';
import type { Product } from '@/mock/product';
import Image from 'next/image';
import { useBuildStore } from '@/store/useBuildStore';
import { buildGroups } from '../build/page';
// Extract unique values for dropdowns
const categories = ['All', ...Array.from(new Set(mockProducts.map(part => part.category.name)))];
const brands = ['All', ...Array.from(new Set(mockProducts.map(part => part.brand.name)))];
const vendors = ['All', ...Array.from(new Set(mockProducts.flatMap(part => part.offers.map(offer => offer.vendor.name))))];
// Restrictions for filter dropdown
const restrictionOptions = [
'All',
'NFA',
'SBR',
'Suppressor',
'State Restrictions',
];
type SortField = 'name' | 'category' | 'price';
type SortDirection = 'asc' | 'desc';
// Restriction indicator component
const RestrictionBadge = ({ restriction }: { restriction: string }) => {
const restrictionConfig = {
NFA: {
label: 'NFA',
color: 'bg-red-600 text-white',
icon: '🔒',
tooltip: 'National Firearms Act - Requires special registration'
},
SBR: {
label: 'SBR',
color: 'bg-orange-600 text-white',
icon: '📏',
tooltip: 'Short Barrel Rifle - Requires NFA registration'
},
SUPPRESSOR: {
label: 'Suppressor',
color: 'bg-purple-600 text-white',
icon: '🔇',
tooltip: 'Sound Suppressor - Requires NFA registration'
},
FFL_REQUIRED: {
label: 'FFL',
color: 'bg-blue-600 text-white',
icon: '🏪',
tooltip: 'Federal Firearms License required for purchase'
},
STATE_RESTRICTIONS: {
label: 'State',
color: 'bg-yellow-600 text-black',
icon: '🗺️',
tooltip: 'State-specific restrictions may apply'
},
HIGH_CAPACITY: {
label: 'High Cap',
color: 'bg-pink-600 text-white',
icon: '🥁',
tooltip: 'High capacity magazine - check local laws'
},
SILENCERSHOP_PARTNER: {
label: 'SilencerShop',
color: 'bg-green-600 text-white',
icon: '🤝',
tooltip: 'Available through SilencerShop partnership'
}
};
const config = restrictionConfig[restriction as keyof typeof restrictionConfig];
if (!config) return null;
return (
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color} cursor-help`}
title={config.tooltip}
>
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
);
};
// Tailwind UI Dropdown Component
const Dropdown = ({
label,
value,
onChange,
options,
placeholder = "Select option"
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
}) => {
return (
<div className="relative">
<Listbox value={value} onChange={onChange}>
<div className="relative">
<Listbox.Label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{value || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-4 w-4 text-neutral-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 dark:bg-neutral-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<Listbox.Options>
{options.map((option, optionIdx) => (
<Listbox.Option
key={optionIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
value={option}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-600 dark:text-primary-400">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
};
// Map product categories to checklist component categories
const getComponentCategory = (productCategory: string): string => {
const categoryMap: Record<string, string> = {
// Upper components
'Upper Receiver': 'Upper',
'Barrel': 'Upper',
'BCG': 'Upper',
'Bolt Carrier Group': 'Upper',
'Charging Handle': 'Upper',
'Gas Block': 'Upper',
'Gas Tube': 'Upper',
'Handguard': 'Upper',
'Muzzle Device': 'Upper',
'Suppressor': 'Upper',
// Lower components
'Lower Receiver': 'Lower',
'Trigger': 'Lower',
'Trigger Guard': 'Lower',
'Pistol Grip': 'Lower',
'Buffer Tube': 'Lower',
'Buffer': 'Lower',
'Buffer Spring': 'Lower',
'Stock': 'Lower',
// Accessories
'Magazine': 'Accessory',
'Sights': 'Accessory',
'Optic': 'Accessory',
'Scope': 'Accessory',
'Red Dot': 'Accessory',
};
return categoryMap[productCategory] || 'Accessory'; // Default to Accessory if no match
};
// Map product categories to specific checklist component names
const getMatchingComponentName = (productCategory: string): string => {
const componentMap: Record<string, string> = {
'Upper Receiver': 'Upper Receiver',
'Barrel': 'Barrel',
'BCG': 'Bolt Carrier Group (BCG)',
'Bolt Carrier Group': 'Bolt Carrier Group (BCG)',
'Charging Handle': 'Charging Handle',
'Gas Block': 'Gas Block',
'Gas Tube': 'Gas Tube',
'Handguard': 'Handguard',
'Muzzle Device': 'Muzzle Device',
'Suppressor': 'Muzzle Device', // Suppressors go to Muzzle Device component
'Lower Receiver': 'Lower Receiver',
'Trigger': 'Trigger',
'Trigger Guard': 'Trigger Guard',
'Pistol Grip': 'Pistol Grip',
'Buffer Tube': 'Buffer Tube',
'Buffer': 'Buffer',
'Buffer Spring': 'Buffer Spring',
'Stock': 'Stock',
'Magazine': 'Magazine',
'Sights': 'Sights',
'Optic': 'Sights',
'Scope': 'Sights',
'Red Dot': 'Sights',
};
return componentMap[productCategory] || '';
};
export default function Home() {
const searchParams = useSearchParams();
const router = useRouter();
const [selectedCategory, setSelectedCategory] = 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);
// Read category from URL parameter on page load
useEffect(() => {
const categoryParam = searchParams.get('category');
if (categoryParam && categories.includes(categoryParam)) {
setSelectedCategory(categoryParam);
}
}, [searchParams]);
// Filter parts based on selected criteria
const filteredParts = mockProducts.filter(part => {
const matchesCategory = selectedCategory === 'All' || part.category.name === selectedCategory;
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
let matchesRestriction = true;
if (selectedRestriction) {
if (selectedRestriction === 'NFA') matchesRestriction = !!part.restrictions?.nfa;
else if (selectedRestriction === 'SBR') matchesRestriction = !!part.restrictions?.sbr;
else if (selectedRestriction === 'Suppressor') matchesRestriction = !!part.restrictions?.suppressor;
else if (selectedRestriction === 'State Restrictions') matchesRestriction = !!(part.restrictions?.stateRestrictions && part.restrictions.stateRestrictions.length > 0);
else 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 && 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 = () => {
setSelectedCategory('All');
setSelectedBrand('All');
setSelectedVendor('All');
setSearchTerm('');
setPriceRange('');
setSelectedRestriction('');
};
const hasActiveFilters = selectedCategory !== '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);
};
return (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-900">
{/* Page Title */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">
Parts Catalog
{selectedCategory !== 'All' && (
<span className="text-primary-600 dark:text-primary-400 ml-2 text-2xl">
- {selectedCategory}
</span>
)}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-2">
{selectedCategory !== 'All'
? `Showing ${selectedCategory} parts for your build`
: 'Browse and filter firearm parts for your build'
}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<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-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 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-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 transition-colors rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700"
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={selectedCategory}
onChange={setSelectedCategory}
options={categories}
placeholder="All categories"
/>
</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-neutral-700 dark:text-neutral-300 mb-1">
Price Range
</Listbox.Label>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white dark:bg-neutral-800 py-1.5 pl-3 pr-10 text-left shadow-sm ring-1 ring-inset ring-neutral-300 dark:ring-neutral-600 focus:outline-none focus:ring-2 focus:ring-primary-500 sm:text-sm">
<span className="block truncate text-neutral-900 dark:text-white">
{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-neutral-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 dark:bg-neutral-800 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 dark:bg-primary-900 text-primary-900 dark:text-primary-100' : 'text-neutral-900 dark:text-white'
}`
}
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 dark:text-primary-400">
<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 dark:bg-accent-500 dark:hover:bg-accent-600 text-white'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-400 dark:text-neutral-500 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-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">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 dark:bg-neutral-800 shadow-sm rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<div className="overflow-x-auto max-h-screen overflow-y-auto">
<table className="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead className="bg-neutral-50 dark:bg-neutral-700 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Category
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-300 uppercase tracking-wider cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-600"
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-neutral-500 dark:text-neutral-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{sortedParts.map((part) => (
<tr key={part.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors">
<td className="px-6 py-4 whitespace-nowrap flex items-center gap-3 min-w-[180px]">
<div className="w-12 h-12 flex-shrink-0 rounded bg-neutral-100 dark:bg-neutral-700 overflow-hidden flex items-center justify-center border border-neutral-200 dark:border-neutral-700">
<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>
<Link href={`/products/${part.id}`} className="text-sm font-semibold text-primary hover:underline dark:text-primary-400">
{part.name}
</Link>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{part.brand.name}</div>
</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 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{part.category.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-neutral-900 dark:text-white">
${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="text-xs text-gray-400">Part Selected</span>
);
}
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Table Footer */}
<div className="bg-neutral-50 dark:bg-neutral-700 px-6 py-3 border-t border-neutral-200 dark:border-neutral-600">
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Showing {sortedParts.length} of {mockProducts.length} parts
{hasActiveFilters && (
<span className="ml-2 text-primary-600 dark:text-primary-400">
(filtered)
</span>
)}
</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
Total Value: ${sortedParts.reduce((sum, part) => sum + Math.min(...part.offers.map(offer => offer.price)), 0).toFixed(2)}
</div>
</div>
</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-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-center gap-4 text-xs text-neutral-500 dark:text-neutral-400">
<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>
</div>
</div>
</main>
);
}
+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}
>
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<SessionProvider>
<AuthProvider>
<ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<div className="min-h-screen bg-zinc-50 transition-colors duration-200">
<NavigationWrapper />
{children}
</div>
+3 -5
View File
@@ -15,14 +15,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// Check for saved theme preference or default to light mode
// Only use saved theme preference, otherwise default to light
const savedTheme = localStorage.getItem('theme') as Theme;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else if (prefersDark) {
setTheme('dark');
} else {
setTheme('light');
}
}, []);
+2 -2
View File
@@ -9,8 +9,8 @@ export default function ThemeSwitcher() {
return (
<button
onClick={toggleTheme}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode. Default is light unless changed.`}
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
+6
View File
@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
+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(),
});
+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"),
]);
+40
View File
@@ -0,0 +1,40 @@
const constants = {
APP_NAME: 'Ballistic Builder',
SITE_NAME: 'Ballistic Builder',
COMPANY_NAME: 'Forward Group, LLC',
COMPANY_URL: 'https://goforward.group',
AUTHOR: 'Forward Group, LLC',
META_KEYWORDS: 'Pew Pew',
META_DESCRIPTION: 'Pow Pow',
DESCRIPTION: 'Developed by Forward Group, LLC',
PJAM_RAINIER: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8713',
PJAM_BARRETTA: 'https://api.pepperjamnetwork.com/20120402/publisher/creative/product?apiKey=17c11367569cc10dce51e6a5900d0c7c8b390c9cb2d2cecc25b3ed53a3b8649b&format=json&programIds=8342'
};
export default constants;
export enum SITE_CONT_TYPE {
CONTACTUS = "CONTACTUS",
PRIVACYPOLICY = "PP",
PERSONALINFOPOLICY = "PIP",
FAQ = "FAQ",
TERMSOFSERVICE = "TOS",
ABOUTUS="ABOUTUS",
DISCLOSURE="DISCLOSURE"
}
export const APP_TITLE = "Ballistics Builder";
export const DATABASE_PREFIX = "";
export const TEST_DB_PREFIX = "test_acme";
export const EMAIL_SENDER = '"Ballistics Builder" <don@goforward.group>';
export enum Paths {
Home = "/",
Login = "/login",
Signup = "/signup",
Dashboard = "/dashboard",
VerifyEmail = "/verify-email",
ResetPassword = "/reset-password",
}
+23 -115
View File
@@ -1,120 +1,28 @@
console.log('DaisyUI plugin loaded');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Secondary accent colors
accent: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
// Neutral grays with warm undertones
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a',
},
// Success colors
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// Warning colors
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
950: '#451a03',
},
darkMode: 'class',
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
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)",
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
display: ['Inter', 'system-ui', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
neutral: "oklch(14% 0.005 285.823)",
base: {
100: "oklch(100% 0 0)",
200: "oklch(98% 0 0)",
300: "oklch(95% 0 0)",
},
},
},
plugins: [require('daisyui')],
daisyui: {
themes: [
{
pew: {
primary: '#4B6516', // Olive/army green
'primary-content': '#fff',
accent: '#181C20', // Dark navy for CTA/footer
'accent-content': '#fff',
neutral: '#222',
'base-100': '#fff',
'base-200': '#f5f6fa',
'base-300': '#e5e7eb',
info: '#3ABFF8',
success: '#36D399',
warning: '#FBBD23',
error: '#F87272',
},
},
'dark',
],
darkTheme: "dark",
base: true,
styled: true,
utils: true,
logs: false,
rtl: false,
prefix: '',
// 'pew' is the default theme
},
};
},
plugins: [],
}