feat: add Pcat catalog browsing — import pc dump, API endpoints, frontend routes
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Import parts-catalogs.com dump into pc schema (72 catalogs, 2788 models, 134K groups). Add pcat-catalog service/controller with Redis caching. Create frontend routes for catalog → model → group navigation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,16 @@ import { CatalogController } from "./catalog.controller";
|
||||
import { CatalogService } from "./catalog.service";
|
||||
import { EmexCatalogController } from "./emex-catalog.controller";
|
||||
import { EmexCatalogService } from "./emex-catalog.service";
|
||||
import { PcatCatalogController } from "./pcat-catalog.controller";
|
||||
import { PcatCatalogService } from "./pcat-catalog.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, SubscriptionsModule, StorageModule],
|
||||
controllers: [CatalogController, EmexCatalogController],
|
||||
providers: [CatalogService, EmexCatalogService],
|
||||
exports: [CatalogService, EmexCatalogService],
|
||||
controllers: [CatalogController, EmexCatalogController, PcatCatalogController],
|
||||
providers: [CatalogService, EmexCatalogService, PcatCatalogService],
|
||||
exports: [CatalogService, EmexCatalogService, PcatCatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
|
||||
25
apps/api/src/catalog/pcat-catalog.controller.ts
Normal file
25
apps/api/src/catalog/pcat-catalog.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { PcatCatalogService } from "./pcat-catalog.service";
|
||||
|
||||
@Controller("catalog/pcat")
|
||||
export class PcatCatalogController {
|
||||
constructor(private pcatCatalogService: PcatCatalogService) {}
|
||||
|
||||
@Get("catalogs")
|
||||
getCatalogs() {
|
||||
return this.pcatCatalogService.getCatalogs();
|
||||
}
|
||||
|
||||
@Get("catalogs/:catalogId/models")
|
||||
getModels(@Param("catalogId") catalogId: string) {
|
||||
return this.pcatCatalogService.getModels(catalogId);
|
||||
}
|
||||
|
||||
@Get("catalogs/:catalogId/groups")
|
||||
getGroups(
|
||||
@Param("catalogId") catalogId: string,
|
||||
@Query("parentId") parentId?: string,
|
||||
) {
|
||||
return this.pcatCatalogService.getGroups(catalogId, parentId);
|
||||
}
|
||||
}
|
||||
132
apps/api/src/catalog/pcat-catalog.service.ts
Normal file
132
apps/api/src/catalog/pcat-catalog.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
export interface PcatCatalogDto {
|
||||
id: string;
|
||||
name: string;
|
||||
modelsCount: number;
|
||||
}
|
||||
|
||||
export interface PcatModelDto {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
}
|
||||
|
||||
export interface PcatGroupDto {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
hasSubgroups: boolean;
|
||||
hasParts: boolean;
|
||||
}
|
||||
|
||||
const CACHE_TTL = {
|
||||
catalogs: 86400, // 24h
|
||||
models: 43200, // 12h
|
||||
groups: 21600, // 6h
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PcatCatalogService {
|
||||
private readonly logger = new Logger(PcatCatalogService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private redis: RedisService,
|
||||
) {}
|
||||
|
||||
async getCatalogs(): Promise<PcatCatalogDto[]> {
|
||||
const cacheKey = "pcat:catalogs";
|
||||
const cached = await this.redis.getJson<PcatCatalogDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.db.execute<{
|
||||
id: string;
|
||||
name: string;
|
||||
models_count: number;
|
||||
}>(sql`SELECT id, name, models_count FROM pc.catalogs WHERE is_active = true ORDER BY name`);
|
||||
|
||||
const result: PcatCatalogDto[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
modelsCount: r.models_count ?? 0,
|
||||
}));
|
||||
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL.catalogs);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getModels(catalogId: string): Promise<PcatModelDto[]> {
|
||||
const cacheKey = `pcat:models:${catalogId}`;
|
||||
const cached = await this.redis.getJson<PcatModelDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.db.execute<{
|
||||
id: string;
|
||||
catalog_id: string;
|
||||
name: string;
|
||||
img_url: string | null;
|
||||
}>(
|
||||
sql`SELECT id, catalog_id, name, img_url FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
|
||||
);
|
||||
|
||||
const result: PcatModelDto[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
catalogId: r.catalog_id,
|
||||
name: r.name,
|
||||
imgUrl: r.img_url,
|
||||
}));
|
||||
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL.models);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getGroups(catalogId: string, parentId?: string): Promise<PcatGroupDto[]> {
|
||||
const cacheKey = `pcat:groups:${catalogId}:${parentId || "root"}`;
|
||||
const cached = await this.redis.getJson<PcatGroupDto[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = parentId
|
||||
? await this.db.execute<{
|
||||
id: string;
|
||||
catalog_id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
img_url: string | null;
|
||||
has_subgroups: boolean;
|
||||
has_parts: boolean;
|
||||
}>(
|
||||
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id = ${parentId} ORDER BY sort_order, name`,
|
||||
)
|
||||
: await this.db.execute<{
|
||||
id: string;
|
||||
catalog_id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
img_url: string | null;
|
||||
has_subgroups: boolean;
|
||||
has_parts: boolean;
|
||||
}>(
|
||||
sql`SELECT id, catalog_id, parent_id, name, img_url, has_subgroups, has_parts FROM pc.groups WHERE catalog_id = ${catalogId} AND parent_id IS NULL ORDER BY sort_order, name`,
|
||||
);
|
||||
|
||||
const result: PcatGroupDto[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
catalogId: r.catalog_id,
|
||||
parentId: r.parent_id,
|
||||
name: r.name,
|
||||
imgUrl: r.img_url,
|
||||
hasSubgroups: r.has_subgroups ?? false,
|
||||
hasParts: r.has_parts ?? false,
|
||||
}));
|
||||
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL.groups);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,12 @@ interface EmexBrand {
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface PcatCatalog {
|
||||
id: string;
|
||||
name: string;
|
||||
modelsCount: number;
|
||||
}
|
||||
|
||||
function CatalogBrandsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState("pl24");
|
||||
@@ -35,6 +41,12 @@ function CatalogBrandsPage() {
|
||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||
});
|
||||
|
||||
const { data: pcatCatalogs, isLoading: pcatLoading } = useQuery({
|
||||
queryKey: ["pcat-catalogs"],
|
||||
queryFn: () => api.get<PcatCatalog[]>("/catalog/pcat/catalogs"),
|
||||
enabled: tab === "pcat",
|
||||
});
|
||||
|
||||
const { data: emexBrands, isLoading: emexLoading } = useQuery({
|
||||
queryKey: ["emex-brands"],
|
||||
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
|
||||
@@ -76,7 +88,17 @@ function CatalogBrandsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pcat" className="mt-4">
|
||||
<ComingSoonPlaceholder />
|
||||
{pcatLoading ? (
|
||||
<BrandGridSkeleton />
|
||||
) : !pcatCatalogs || pcatCatalogs.length === 0 ? (
|
||||
<EmptyBrands message={t("catalog.noBrands")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{pcatCatalogs.map((cat) => (
|
||||
<PcatCatalogCard key={cat.id} catalog={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emex" className="mt-4">
|
||||
@@ -171,6 +193,24 @@ function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PcatCatalogCard({ catalog }: { catalog: PcatCatalog }) {
|
||||
return (
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId: catalog.id }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<Library className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{catalog.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{catalog.modelsCount} model
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, Library } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId/",
|
||||
)({
|
||||
component: PcatModelsPage,
|
||||
});
|
||||
|
||||
interface PcatModel {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
}
|
||||
|
||||
function PcatModelsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId } = Route.useParams();
|
||||
|
||||
const { data: models, isLoading } = useQuery({
|
||||
queryKey: ["pcat-models", catalogId],
|
||||
queryFn: () =>
|
||||
api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/dashboard/catalog" search={{}}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToBrands")}
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !models || models.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noModels")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{models.map((model) => (
|
||||
<Link
|
||||
key={model.id}
|
||||
to="/dashboard/catalog/pcat/$catalogId/$modelId"
|
||||
params={{ catalogId, modelId: model.id }}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
{model.imgUrl ? (
|
||||
<img
|
||||
src={
|
||||
model.imgUrl.startsWith("//")
|
||||
? `https:${model.imgUrl}`
|
||||
: model.imgUrl
|
||||
}
|
||||
alt={model.name}
|
||||
className="mb-2 h-16 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<Library className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-semibold">{model.name}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/dashboard/catalog_/pcat/$catalogId_/$modelId/",
|
||||
)({
|
||||
component: PcatGroupsPage,
|
||||
});
|
||||
|
||||
interface PcatGroup {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
imgUrl: string | null;
|
||||
hasSubgroups: boolean;
|
||||
hasParts: boolean;
|
||||
}
|
||||
|
||||
function PcatGroupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { catalogId, modelId } = Route.useParams();
|
||||
|
||||
// Navigation stack for drilling into subgroups
|
||||
const [parentStack, setParentStack] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const currentParentId =
|
||||
parentStack.length > 0
|
||||
? parentStack[parentStack.length - 1].id
|
||||
: undefined;
|
||||
|
||||
const { data: groups, isLoading } = useQuery({
|
||||
queryKey: ["pcat-groups", catalogId, currentParentId || "root"],
|
||||
queryFn: () => {
|
||||
const params = currentParentId
|
||||
? `?parentId=${encodeURIComponent(currentParentId)}`
|
||||
: "";
|
||||
return api.get<PcatGroup[]>(
|
||||
`/catalog/pcat/catalogs/${catalogId}/groups${params}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleGroupClick = (group: PcatGroup) => {
|
||||
if (group.hasSubgroups) {
|
||||
setParentStack((prev) => [...prev, { id: group.id, name: group.name }]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setParentStack((prev) => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{parentStack.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToCategories")}
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
to="/dashboard/catalog/pcat/$catalogId"
|
||||
params={{ catalogId }}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("catalog.backToModels")}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-xl font-bold">
|
||||
{catalogId.toUpperCase()}
|
||||
{parentStack.length > 0 && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" / "}
|
||||
{parentStack.map((p) => p.name).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : !groups || groups.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
{t("catalog.noCategories")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => handleGroupClick(group)}
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
{group.imgUrl ? (
|
||||
<img
|
||||
src={
|
||||
group.imgUrl.startsWith("//")
|
||||
? `https:${group.imgUrl}`
|
||||
: group.imgUrl
|
||||
}
|
||||
alt={group.name}
|
||||
className="mb-2 h-12 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen className="mb-2 size-8 text-muted-foreground/50" />
|
||||
)}
|
||||
<p className="text-xs font-medium">{group.name}</p>
|
||||
{group.hasSubgroups && (
|
||||
<ChevronRight className="mt-1 size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user