Merge pull request #8 from semihyesilyurt/dev
feat(FN-188): add Changelog tab to dashboard settings
This commit is contained in:
@@ -11,6 +11,7 @@ import { AuthModule } from "./auth/auth.module";
|
||||
import { BrandsModule } from "./brands/brands.module";
|
||||
import { CatalogModule } from "./catalog/catalog.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { ChangelogModule } from "./changelog/changelog.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { AuthGuard } from "./common/guards/auth.guard";
|
||||
import { RolesGuard } from "./common/guards/roles.guard";
|
||||
@@ -79,6 +80,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
38
apps/api/src/changelog/changelog.controller.ts
Normal file
38
apps/api/src/changelog/changelog.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import type { CreateChangelogEntry, UpdateChangelogEntry } from "./changelog.dto";
|
||||
import { ChangelogService } from "./changelog.service";
|
||||
|
||||
@Controller("changelog")
|
||||
export class ChangelogController {
|
||||
constructor(private readonly changelogService: ChangelogService) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
async findAll() {
|
||||
return this.changelogService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async create(@Body() body: CreateChangelogEntry) {
|
||||
return this.changelogService.create(body);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async update(@Param("id") id: string, @Body() body: UpdateChangelogEntry) {
|
||||
return this.changelogService.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles("admin")
|
||||
async delete(@Param("id") id: string) {
|
||||
return this.changelogService.delete(id);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/changelog/changelog.dto.ts
Normal file
1
apps/api/src/changelog/changelog.dto.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { CreateChangelogEntry, UpdateChangelogEntry, ChangelogStage } from "@sase/shared";
|
||||
9
apps/api/src/changelog/changelog.module.ts
Normal file
9
apps/api/src/changelog/changelog.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ChangelogController } from "./changelog.controller";
|
||||
import { ChangelogService } from "./changelog.service";
|
||||
|
||||
@Module({
|
||||
controllers: [ChangelogController],
|
||||
providers: [ChangelogService],
|
||||
})
|
||||
export class ChangelogModule {}
|
||||
185
apps/api/src/changelog/changelog.service.spec.ts
Normal file
185
apps/api/src/changelog/changelog.service.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChangelogService } from "./changelog.service";
|
||||
|
||||
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||
function chainable(terminalValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"offset",
|
||||
"insert",
|
||||
"values",
|
||||
"update",
|
||||
"set",
|
||||
"delete",
|
||||
"returning",
|
||||
];
|
||||
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||
return chain;
|
||||
}
|
||||
|
||||
return {
|
||||
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRedis(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getJson: vi.fn().mockResolvedValue(null),
|
||||
setJson: vi.fn().mockResolvedValue(undefined),
|
||||
del: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const isoDate = "2026-05-11T12:00:00.000Z";
|
||||
const dateObj = new Date(isoDate);
|
||||
|
||||
const sampleEntry = {
|
||||
id: "entry-1",
|
||||
stage: "prod" as const,
|
||||
title: "New Feature",
|
||||
description: "A new feature was added.",
|
||||
publishedAt: dateObj,
|
||||
createdAt: dateObj,
|
||||
updatedAt: dateObj,
|
||||
};
|
||||
|
||||
describe("ChangelogService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return cached entries when cache hit", async () => {
|
||||
const cached = [
|
||||
{ ...sampleEntry, publishedAt: isoDate, createdAt: isoDate, updatedAt: isoDate },
|
||||
];
|
||||
const redis = createMockRedis({ getJson: vi.fn().mockResolvedValue(cached) });
|
||||
const db = createMockDb();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.findAll();
|
||||
expect(result).toEqual(cached);
|
||||
expect(redis.getJson).toHaveBeenCalledWith("changelog:entries");
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should query DB and cache when cache miss", async () => {
|
||||
const rows = [sampleEntry];
|
||||
const db = createMockDb({ _selectRows: rows });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.findAll();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].publishedAt).toBe(isoDate);
|
||||
expect(redis.setJson).toHaveBeenCalledWith("changelog:entries", expect.any(Array), 1800);
|
||||
});
|
||||
|
||||
it("should order by publishedAt DESC", async () => {
|
||||
const rows = [
|
||||
{ ...sampleEntry, id: "2", publishedAt: new Date("2026-02-01") },
|
||||
{ ...sampleEntry, id: "1", publishedAt: new Date("2026-01-01") },
|
||||
];
|
||||
const db = createMockDb({ _selectRows: rows });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.findAll();
|
||||
// The mock doesn't actually sort; this test just verifies the call pattern
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("should return entry when found", async () => {
|
||||
const db = createMockDb({ _selectRows: [sampleEntry] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.findById("entry-1");
|
||||
expect(result.publishedAt).toBe(isoDate);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when not found", async () => {
|
||||
const db = createMockDb({ _selectRows: [] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
await expect(service.findById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create entry and invalidate cache", async () => {
|
||||
const db = createMockDb({ _insertRows: [sampleEntry] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.create({
|
||||
stage: "prod",
|
||||
title: "New Feature",
|
||||
description: "A new feature was added.",
|
||||
publishedAt: isoDate,
|
||||
});
|
||||
|
||||
expect(result.publishedAt).toBe(isoDate);
|
||||
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update entry and invalidate cache", async () => {
|
||||
const updated = { ...sampleEntry, title: "Updated Title", updatedAt: dateObj };
|
||||
const db = createMockDb({ _updateRows: [updated] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
const result = await service.update("entry-1", { title: "Updated Title" });
|
||||
expect(result.title).toBe("Updated Title");
|
||||
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when entry not found", async () => {
|
||||
const db = createMockDb({ _updateRows: [] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
await expect(service.update("nonexistent", { title: "X" })).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete entry and invalidate cache", async () => {
|
||||
const db = createMockDb({ _deleteRows: [sampleEntry] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
await service.delete("entry-1");
|
||||
expect(redis.del).toHaveBeenCalledWith("changelog:entries");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when entry not found", async () => {
|
||||
const db = createMockDb({ _deleteRows: [] });
|
||||
const redis = createMockRedis();
|
||||
const service = new ChangelogService(db as any, redis as any);
|
||||
|
||||
await expect(service.delete("nonexistent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
112
apps/api/src/changelog/changelog.service.ts
Normal file
112
apps/api/src/changelog/changelog.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { ChangelogEntry, CreateChangelogEntry, UpdateChangelogEntry } from "@sase/shared";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { changelogEntries } from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
const CACHE_KEY = "changelog:entries";
|
||||
const CACHE_TTL = 1800; // 30 minutes
|
||||
|
||||
@Injectable()
|
||||
export class ChangelogService {
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ChangelogEntry[]> {
|
||||
const cached = await this.redis.getJson<ChangelogEntry[]>(CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(changelogEntries)
|
||||
.orderBy(desc(changelogEntries.publishedAt));
|
||||
|
||||
const entries = rows.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
}));
|
||||
|
||||
await this.redis.setJson(CACHE_KEY, entries, CACHE_TTL);
|
||||
return entries as ChangelogEntry[];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ChangelogEntry> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(changelogEntries)
|
||||
.where(eq(changelogEntries.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) throw new NotFoundException("Changelog entry not found");
|
||||
|
||||
const row = result[0];
|
||||
return {
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
} as ChangelogEntry;
|
||||
}
|
||||
|
||||
async create(dto: CreateChangelogEntry): Promise<ChangelogEntry> {
|
||||
const [row] = await this.db
|
||||
.insert(changelogEntries)
|
||||
.values({
|
||||
stage: dto.stage,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
publishedAt: new Date(dto.publishedAt),
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.redis.del(CACHE_KEY);
|
||||
|
||||
return {
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
} as ChangelogEntry;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateChangelogEntry): Promise<ChangelogEntry> {
|
||||
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (dto.stage !== undefined) values.stage = dto.stage;
|
||||
if (dto.title !== undefined) values.title = dto.title;
|
||||
if (dto.description !== undefined) values.description = dto.description;
|
||||
if (dto.publishedAt !== undefined) values.publishedAt = new Date(dto.publishedAt);
|
||||
|
||||
const [row] = await this.db
|
||||
.update(changelogEntries)
|
||||
.set(values)
|
||||
.where(eq(changelogEntries.id, id))
|
||||
.returning();
|
||||
|
||||
if (!row) throw new NotFoundException("Changelog entry not found");
|
||||
|
||||
await this.redis.del(CACHE_KEY);
|
||||
|
||||
return {
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
publishedAt: row.publishedAt.toISOString(),
|
||||
} as ChangelogEntry;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [row] = await this.db
|
||||
.delete(changelogEntries)
|
||||
.where(eq(changelogEntries.id, id))
|
||||
.returning();
|
||||
|
||||
if (!row) throw new NotFoundException("Changelog entry not found");
|
||||
|
||||
await this.redis.del(CACHE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,21 @@ export const referrals = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Changelog Entries ────────────────────────────
|
||||
export const changelogEntries = pgTable(
|
||||
"changelog_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
stage: varchar("stage", { length: 10 }).default("prod").notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
description: text("description").notNull(),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("changelog_entries_published_at_idx").on(table.publishedAt)],
|
||||
);
|
||||
|
||||
// ─── EMEX Category Translations ─────────────────────
|
||||
export const emexCategoryTranslations = pgTable(
|
||||
"emex_category_translations",
|
||||
|
||||
113
apps/web/src/components/settings/changelog-tab.tsx
Normal file
113
apps/web/src/components/settings/changelog-tab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useChangelog } from "@/hooks/use-changelog";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { ChangelogEntry } from "@sase/shared";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from "@sase/ui";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
function formatDate(iso: string, locale: "tr" | "en"): string {
|
||||
const date = new Date(iso);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
|
||||
}
|
||||
|
||||
function stageLabel(stage: ChangelogEntry["stage"], t: (key: string) => string): string {
|
||||
return t(`settings.changelog.stage.${stage}`);
|
||||
}
|
||||
|
||||
function ChangelogSkeleton() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="relative pb-6 pl-8">
|
||||
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="text-center">
|
||||
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
|
||||
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogTab() {
|
||||
const { data: entries, isLoading } = useChangelog();
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <ChangelogSkeleton />;
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return <ChangelogEmpty t={t} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.changelog.title")}</CardTitle>
|
||||
<CardDescription>{t("settings.changelog.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="relative border-l-2 border-muted-foreground/20"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
|
||||
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="flex flex-col items-start gap-1.5 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge stage={entry.stage}>{stageLabel(entry.stage, t)}</Badge>
|
||||
<span className="font-medium">{entry.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.publishedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{entry.description}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,19 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
Copy,
|
||||
Gift,
|
||||
Link2,
|
||||
Share2,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChangelogTab } from "./changelog-tab";
|
||||
|
||||
export function SettingsContent() {
|
||||
const { t } = useTranslation();
|
||||
@@ -172,6 +183,10 @@ export function SettingsContent() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("settings.tabs.account")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="changelog" className="gap-2">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("settings.tabs.changelog")}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Profile Tab */}
|
||||
@@ -454,6 +469,11 @@ export function SettingsContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Changelog Tab */}
|
||||
<TabsContent value="changelog">
|
||||
<ChangelogTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
|
||||
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -149,6 +152,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scroll-left {
|
||||
animation: scroll-left 30s linear infinite;
|
||||
}
|
||||
|
||||
11
apps/web/src/hooks/use-changelog.ts
Normal file
11
apps/web/src/hooks/use-changelog.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { ChangelogEntry } from "@sase/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useChangelog() {
|
||||
return useQuery({
|
||||
queryKey: ["changelog"],
|
||||
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
|
||||
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
|
||||
});
|
||||
}
|
||||
@@ -261,7 +261,8 @@
|
||||
"security": "Security",
|
||||
"connections": "Connections",
|
||||
"referral": "Referral",
|
||||
"account": "Account"
|
||||
"account": "Account",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Information",
|
||||
@@ -318,6 +319,17 @@
|
||||
"deleteFailed": "Account deletion failed.",
|
||||
"typeConfirm": "Type 'DELETE' to confirm",
|
||||
"confirmWord": "DELETE"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Changelog",
|
||||
"description": "Latest platform updates, new features, and bug fixes.",
|
||||
"emptyTitle": "No updates yet",
|
||||
"emptyDescription": "Platform updates will appear here soon.",
|
||||
"stage": {
|
||||
"alpha": "Alpha",
|
||||
"beta": "Beta",
|
||||
"prod": "Production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"security": "Güvenlik",
|
||||
"connections": "Bağlantılar",
|
||||
"referral": "Referans",
|
||||
"account": "Hesap"
|
||||
"account": "Hesap",
|
||||
"changelog": "Değişiklik Günlüğü"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil Bilgileri",
|
||||
@@ -318,6 +319,17 @@
|
||||
"deleteFailed": "Hesap silme başarısız.",
|
||||
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
||||
"confirmWord": "SİL"
|
||||
},
|
||||
"changelog": {
|
||||
"title": "Değişiklik Günlüğü",
|
||||
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
|
||||
"emptyTitle": "Henüz güncelleme yok",
|
||||
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
|
||||
"stage": {
|
||||
"alpha": "Alpha",
|
||||
"beta": "Beta",
|
||||
"prod": "Canlı"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock the PostHog capture function
|
||||
|
||||
@@ -504,6 +504,14 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
|
||||
|--------|------|------|-------------|
|
||||
| `POST` | `/api/analytics/oem-copy` | User | Track OEM code copy event (oemCode, partId?, vehicleId?, categoryId?) |
|
||||
|
||||
#### Changelog
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/changelog` | Public | List changelog entries (Redis-cached, 30min) |
|
||||
| `POST` | `/api/changelog` | Admin | Create changelog entry |
|
||||
| `PATCH` | `/api/changelog/:id` | Admin | Update changelog entry |
|
||||
| `DELETE` | `/api/changelog/:id` | Admin | Delete changelog entry |
|
||||
|
||||
### Authentication
|
||||
|
||||
**Provider:** Better Auth 1.2
|
||||
@@ -559,7 +567,7 @@ Flow:
|
||||
| `/dashboard/subscription` | `routes/dashboard/subscription/index.tsx` | Plan selection & brand picker |
|
||||
| `/dashboard/subscription/pay` | `routes/dashboard/subscription/pay.tsx` | Card (Iyzico) or EFT payment |
|
||||
| `/dashboard/billing` | `routes/dashboard/billing.tsx` | Payment history & receipts |
|
||||
| `/dashboard/settings` | `routes/dashboard/settings.tsx` | Profile, Security, Connections, Referral, Account tabs |
|
||||
| `/dashboard/settings` | `routes/dashboard/settings.tsx` | Profile, Security, Connections, Referral, Account, Changelog tabs |
|
||||
| `/dashboard/vehicles/$id` | `routes/dashboard/vehicles_/$id/index.tsx` | Vehicle details |
|
||||
| `/dashboard/vehicles/$id/categories/$categoryId` | `routes/dashboard/vehicles_/$id/categories_/$categoryId.tsx` | Interactive schema + parts table |
|
||||
|
||||
@@ -598,10 +606,11 @@ Flow:
|
||||
| **DailyChart** | `components/admin/daily-chart.tsx` | Daily VIN decode stats chart |
|
||||
| **PaymentContent** | `components/payment/payment-content.tsx` | Payment form and flow |
|
||||
| **SettingsContent** | `components/settings/settings-content.tsx` | User settings panel content |
|
||||
| **ChangelogTab** | `components/settings/changelog-tab.tsx` | Timeline + accordion changelog viewer |
|
||||
| **PsaVariantSelector** | `components/catalog/psa-variant-selector.tsx` | PSA (Citroën/Peugeot) body/engine/gearbox picker for VIN-less catalog |
|
||||
| **FordVariantSelector** | `components/catalog/ford-variant-selector.tsx` | Ford model-year/engine/gearbox picker for VIN-less catalog |
|
||||
|
||||
**UI primitives** from `@sase/ui`: Button, Card, Input, Label, Badge, Dialog, Tabs, Separator, Skeleton
|
||||
**UI primitives** from `@sase/ui`: Button, Card, Input, Label, Badge (with stage variant), Dialog, Tabs, Separator, Skeleton, Accordion
|
||||
|
||||
### Hooks & Stores
|
||||
|
||||
@@ -612,6 +621,7 @@ Flow:
|
||||
| `useCategoryParts(vehicleId, categoryId)` | TanStack Query for schema + parts + hotspots |
|
||||
| `useSchemaInteraction()` | Pan/zoom/pinch event handlers for schema viewer |
|
||||
| `usePageMeta(options)` | Sets `<title>`, meta description, canonical, OG/Twitter tags; restores defaults on unmount |
|
||||
| `useChangelog()` | TanStack Query for changelog entries (staleTime: 30min) |
|
||||
|
||||
**Stores (Zustand):**
|
||||
| Store | State |
|
||||
@@ -659,8 +669,8 @@ Flow:
|
||||
|
||||
| Directory | Exports |
|
||||
|-----------|---------|
|
||||
| `types/` | User, UserProfile, UserSubscriptionSummary, Vehicle, VinDecodeResult, CategoryNode, VehicleSource, Brand, Plan, Subscription, UserBrand, CreateSubscriptionInput, SubscriptionStatus, Payment, PaymentMethod, PaymentStatus, IyzicoInitializeInput, EftPaymentInput, Part, PartSource, PartSearchResult, Category, CategoryWithSchema, SchemaPic, Hotspot, ApiResponse, ApiError, PaginationMeta, PaginationInput, PaginatedResult |
|
||||
| `schemas/` | loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, vinSchema, paginationSchema (Zod) |
|
||||
| `types/` | User, UserProfile, UserSubscriptionSummary, Vehicle, VinDecodeResult, CategoryNode, VehicleSource, Brand, Plan, Subscription, UserBrand, CreateSubscriptionInput, SubscriptionStatus, Payment, PaymentMethod, PaymentStatus, IyzicoInitializeInput, EftPaymentInput, Part, PartSource, PartSearchResult, Category, CategoryWithSchema, SchemaPic, Hotspot, ApiResponse, ApiError, PaginationMeta, PaginationInput, PaginatedResult, ChangelogEntry, CreateChangelogEntry, UpdateChangelogEntry, ChangelogStage |
|
||||
| `schemas/` | loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, vinSchema, paginationSchema, changelogEntrySchema, createChangelogEntrySchema, updateChangelogEntrySchema, changelogStageEnum (Zod) |
|
||||
| `constants/` | ERROR_CODES (30+, prefixed AUTH/VIN/SUB/PAY), PLANS (Single/Double/Triple/Full), REFERRAL_REWARDS (Tier 1: 3→7d, Tier 2: 5→30d), VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX, CURRENCY |
|
||||
| `utils/` | VIN validator (check digit, WMI extraction, model year decode), currency (formatTRY, kurus↔lira), formatters (VIN, date, datetime, Turkish slug, referral code) |
|
||||
|
||||
@@ -673,9 +683,9 @@ Groups: DATABASE_URL, REDIS_*, BETTER_AUTH_*, GOOGLE_*, MINIO_*, CORS_ORIGIN, IY
|
||||
|
||||
### @sase/ui (`packages/ui/src/`)
|
||||
|
||||
Components: Button (CVA variants), Input, Card (6 compound parts), Badge (CVA), Label, Skeleton, Separator, Dialog (10 compound parts), Tabs (4 compound parts), `cn()` utility
|
||||
Components: Button (CVA variants), Input, Card (6 compound parts), Badge (CVA, includes stage variant: alpha/beta/prod), Label, Skeleton, Separator, Dialog (10 compound parts), Tabs (4 compound parts), Accordion (4 compound parts), `cn()` utility
|
||||
|
||||
Dependencies: Radix UI (dialog, dropdown-menu, label, popover, select, separator, slot, tabs, tooltip), class-variance-authority, clsx, tailwind-merge, lucide-react
|
||||
Dependencies: Radix UI (accordion, dialog, dropdown-menu, label, popover, select, separator, slot, tabs, tooltip), class-variance-authority, clsx, tailwind-merge, lucide-react
|
||||
|
||||
---
|
||||
|
||||
@@ -685,12 +695,12 @@ Dependencies: Radix UI (dialog, dropdown-menu, label, popover, select, separator
|
||||
|
||||
| Category | Files | Total Lines |
|
||||
|----------|-------|-------------|
|
||||
| **Service specs** | 11 (admin, brands, categories, parts, payments, plans, referrals, subscriptions, translations, users, vehicles) | 2,521 |
|
||||
| **Service specs** | 12 (admin, brands, categories, changelog, parts, payments, plans, referrals, subscriptions, translations, users, vehicles) | 2,521 |
|
||||
| **Guard specs** | 3 (auth, roles, brand-access) | 380 |
|
||||
| **Pipe specs** | 1 (vin-validation) | 77 |
|
||||
| **Integration specs** | 1 (corgi) | 121 |
|
||||
| **Telemetry specs** | 1 | 97 |
|
||||
| **Total API** | **17 test files** | **3,196 lines** |
|
||||
| **Total API** | **18 test files** | **3,196 lines** |
|
||||
|
||||
**Test Config:**
|
||||
- API: `apps/api/vitest.config.ts` — `src/**/*.spec.ts`, v8 coverage (text + lcov)
|
||||
|
||||
@@ -29,6 +29,18 @@ export {
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
} from "./schemas/auth.js";
|
||||
export {
|
||||
changelogStageEnum,
|
||||
changelogEntrySchema,
|
||||
createChangelogEntrySchema,
|
||||
updateChangelogEntrySchema,
|
||||
} from "./schemas/changelog.js";
|
||||
export type {
|
||||
ChangelogEntry,
|
||||
CreateChangelogEntry,
|
||||
UpdateChangelogEntry,
|
||||
ChangelogStage,
|
||||
} from "./schemas/changelog.js";
|
||||
export { paginationSchema } from "./schemas/pagination.js";
|
||||
|
||||
// Constants
|
||||
|
||||
27
packages/shared/src/schemas/changelog.ts
Normal file
27
packages/shared/src/schemas/changelog.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const changelogStageEnum = z.enum(["alpha", "beta", "prod"]);
|
||||
|
||||
export const changelogEntrySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
stage: changelogStageEnum,
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().min(1),
|
||||
publishedAt: z.string().datetime(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const createChangelogEntrySchema = z.object({
|
||||
stage: changelogStageEnum,
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().min(1),
|
||||
publishedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const updateChangelogEntrySchema = createChangelogEntrySchema.partial();
|
||||
|
||||
export type ChangelogEntry = z.infer<typeof changelogEntrySchema>;
|
||||
export type CreateChangelogEntry = z.infer<typeof createChangelogEntrySchema>;
|
||||
export type UpdateChangelogEntry = z.infer<typeof updateChangelogEntrySchema>;
|
||||
export type ChangelogStage = z.infer<typeof changelogStageEnum>;
|
||||
6
packages/shared/src/types/changelog.ts
Normal file
6
packages/shared/src/types/changelog.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
ChangelogEntry,
|
||||
CreateChangelogEntry,
|
||||
UpdateChangelogEntry,
|
||||
ChangelogStage,
|
||||
} from "../schemas/changelog.js";
|
||||
@@ -15,6 +15,7 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
|
||||
50
packages/ui/src/accordion.tsx
Normal file
50
packages/ui/src/accordion.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "./utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -12,6 +12,11 @@ const badgeVariants = cva(
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
stage: {
|
||||
alpha: "border-transparent bg-amber-500/15 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400",
|
||||
beta: "border-transparent bg-blue-500/15 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400",
|
||||
prod: "border-transparent bg-emerald-500/15 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
@@ -23,8 +28,8 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
function Badge({ className, variant, stage, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant, stage }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -14,6 +14,7 @@ export { Skeleton } from "./skeleton";
|
||||
export { Label } from "./label";
|
||||
export { Separator } from "./separator";
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
|
||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@@ -331,6 +331,9 @@ importers:
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2260,6 +2263,19 @@ packages:
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12':
|
||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
@@ -2273,6 +2289,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -8112,6 +8141,23 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -8121,6 +8167,22 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
Reference in New Issue
Block a user