diff --git a/prisma/migrations/20260101072430_com_3/migration.sql b/prisma/migrations/20260101072430_com_3/migration.sql new file mode 100644 index 0000000..4eaa3e0 --- /dev/null +++ b/prisma/migrations/20260101072430_com_3/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "CommissionRequest" ADD COLUMN "customFields" JSONB, +ADD COLUMN "customerSocials" TEXT, +ADD COLUMN "ipAddress" TEXT, +ADD COLUMN "status" TEXT NOT NULL DEFAULT 'NEW', +ADD COLUMN "userAgent" TEXT; + +-- CreateTable +CREATE TABLE "CommissionRequestFile" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "sortIndex" INTEGER NOT NULL DEFAULT 0, + "objectKey" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "requestId" TEXT NOT NULL, + + CONSTRAINT "CommissionRequestFile_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "CommissionRequestFile" ADD CONSTRAINT "CommissionRequestFile_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "CommissionRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260101072657_com_4/migration.sql b/prisma/migrations/20260101072657_com_4/migration.sql new file mode 100644 index 0000000..81a1768 --- /dev/null +++ b/prisma/migrations/20260101072657_com_4/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `mimeType` on the `CommissionRequestFile` table. All the data in the column will be lost. + - You are about to drop the column `objectKey` on the `CommissionRequestFile` table. All the data in the column will be lost. + - You are about to drop the column `originalName` on the `CommissionRequestFile` table. All the data in the column will be lost. + - You are about to drop the column `sizeBytes` on the `CommissionRequestFile` table. All the data in the column will be lost. + - You are about to drop the column `sortIndex` on the `CommissionRequestFile` table. All the data in the column will be lost. + - A unique constraint covering the columns `[fileKey]` on the table `CommissionRequestFile` will be added. If there are existing duplicate values, this will fail. + - Added the required column `fileKey` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty. + - Added the required column `fileSize` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty. + - Added the required column `fileType` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty. + - Added the required column `originalFile` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty. + - Added the required column `uploadDate` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "CommissionRequestFile" DROP COLUMN "mimeType", +DROP COLUMN "objectKey", +DROP COLUMN "originalName", +DROP COLUMN "sizeBytes", +DROP COLUMN "sortIndex", +ADD COLUMN "fileKey" TEXT NOT NULL, +ADD COLUMN "fileSize" INTEGER NOT NULL, +ADD COLUMN "fileType" TEXT NOT NULL, +ADD COLUMN "originalFile" TEXT NOT NULL, +ADD COLUMN "uploadDate" TIMESTAMP(3) NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionRequestFile_fileKey_key" ON "CommissionRequestFile"("fileKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f299ce0..24fb1fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -360,6 +360,12 @@ model CommissionRequest { customerName String customerEmail String message String + status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM + + customerSocials String? + ipAddress String? + userAgent String? + customFields Json? optionId String? typeId String? @@ -367,6 +373,7 @@ model CommissionRequest { type CommissionType? @relation(fields: [typeId], references: [id]) extras CommissionExtra[] + files CommissionRequestFile[] } model CommissionGuidelines { @@ -380,6 +387,21 @@ model CommissionGuidelines { @@index([isActive]) } +model CommissionRequestFile { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fileKey String @unique + originalFile String + fileType String + fileSize Int + uploadDate DateTime + + requestId String + request CommissionRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) +} + model TermsOfService { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/src/actions/commissions/deleteCommissionRequest.ts b/src/actions/commissions/deleteCommissionRequest.ts new file mode 100644 index 0000000..1b605a6 --- /dev/null +++ b/src/actions/commissions/deleteCommissionRequest.ts @@ -0,0 +1,14 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +export async function deleteCommissionRequest(id: string) { + const parsed = z.string().min(1).parse(id); + + await prisma.commissionRequest.delete({ + where: { id: parsed }, + }); + + return { ok: true }; +} diff --git a/src/actions/commissions/getCommissionRequestsTablePage.ts b/src/actions/commissions/getCommissionRequestsTablePage.ts new file mode 100644 index 0000000..f72c912 --- /dev/null +++ b/src/actions/commissions/getCommissionRequestsTablePage.ts @@ -0,0 +1,110 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { + commissionRequestTableRowSchema, + commissionStatusSchema, +} from "@/schemas/commissions/tableSchema"; +import { z } from "zod"; + +export type CursorPagination = { pageIndex: number; pageSize: number }; +export type SortDir = "asc" | "desc"; + +const triStateSchema = z.enum(["any", "true", "false"]); +type TriState = z.infer; + +const sortingSchema = z.array( + z.object({ + id: z.string(), + desc: z.boolean(), + }) +); + +const filtersSchema = z.object({ + q: z.string().optional(), + email: z.string().optional(), + status: z.union([z.literal("any"), commissionStatusSchema]).default("any"), + hasFiles: triStateSchema.default("any"), +}); + +export async function getCommissionRequestsTablePage(input: { + pagination: CursorPagination; + sorting: z.infer; + filters: z.infer; +}) { + const { pagination, sorting, filters } = input; + + const where: any = {}; + + if (filters.q) { + const q = filters.q.trim(); + if (q) { + where.OR = [ + { customerName: { contains: q, mode: "insensitive" } }, + { customerEmail: { contains: q, mode: "insensitive" } }, + { message: { contains: q, mode: "insensitive" } }, + ]; + } + } + + if (filters.email) { + const e = filters.email.trim(); + if (e) where.customerEmail = { contains: e, mode: "insensitive" }; + } + + if (filters.status !== "any") { + where.status = filters.status; + } + + if (filters.hasFiles !== "any") { + where.files = filters.hasFiles === "true" ? { some: {} } : { none: {} }; + } + + // sorting + const sort = sorting?.[0] ?? { id: "createdAt", desc: true }; + const orderBy: any = + sort.id === "createdAt" + ? { createdAt: sort.desc ? "desc" : "asc" } + : sort.id === "status" + ? { status: sort.desc ? "desc" : "asc" } + : { createdAt: "desc" }; + + const [total, rows] = await prisma.$transaction([ + prisma.commissionRequest.count({ where }), + prisma.commissionRequest.findMany({ + where, + orderBy, + skip: pagination.pageIndex * pagination.pageSize, + take: pagination.pageSize, + select: { + id: true, + createdAt: true, + status: true, + customerName: true, + customerEmail: true, + customerSocials: true, + message: true, + _count: { select: { files: true } }, + }, + }), + ]); + + const mapped = rows.map((r) => ({ + id: r.id, + createdAt: r.createdAt.toISOString(), + status: r.status as any, + customerName: r.customerName, + customerEmail: r.customerEmail, + customerSocials: r.customerSocials ?? null, + messagePreview: r.message.slice(0, 140), + filesCount: r._count.files, + })); + + // Validate output once (helps catch schema drift) + const parsed = z.array(commissionRequestTableRowSchema).safeParse(mapped); + if (!parsed.success) { + throw new Error("Commission table row shape mismatch"); + } + + return { total, rows: parsed.data }; +} diff --git a/src/actions/commissions/setCommissionRequestStatus.ts b/src/actions/commissions/setCommissionRequestStatus.ts new file mode 100644 index 0000000..7fb07cc --- /dev/null +++ b/src/actions/commissions/setCommissionRequestStatus.ts @@ -0,0 +1,20 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { commissionStatusSchema } from "@/schemas/commissions/tableSchema"; +import { z } from "zod"; + +export async function setCommissionRequestStatus(input: { + id: string; + status: z.infer; +}) { + const id = z.string().min(1).parse(input.id); + const status = commissionStatusSchema.parse(input.status); + + await prisma.commissionRequest.update({ + where: { id }, + data: { status }, + }); + + return { ok: true }; +} diff --git a/src/app/(admin)/commissions/page.tsx b/src/app/(admin)/commissions/page.tsx index 767df1d..8e00609 100644 --- a/src/app/(admin)/commissions/page.tsx +++ b/src/app/(admin)/commissions/page.tsx @@ -1,5 +1,9 @@ +import { CommissionRequestsTable } from "@/components/commissions/CommissionRequestsTable"; + export default function CommissionPage() { return ( -
CommissionPage
+
+ +
); } \ No newline at end of file diff --git a/src/app/api/v1/commissions/route.ts b/src/app/api/v1/commissions/route.ts new file mode 100644 index 0000000..3088f12 --- /dev/null +++ b/src/app/api/v1/commissions/route.ts @@ -0,0 +1,139 @@ +import { prisma } from "@/lib/prisma"; +import { s3 } from "@/lib/s3"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { NextResponse } from "next/server"; +import { v4 as uuidv4 } from "uuid"; +import { z } from "zod/v4"; + +export const runtime = "nodejs"; // required for AWS SDK on many setups +export const dynamic = "force-dynamic"; // API endpoint should be dynamic + +const payloadSchema = z.object({ + typeId: z.string().min(1).optional().nullable(), + optionId: z.string().min(1).optional().nullable(), + extraIds: z.array(z.string().min(1)).default([]), + + customerName: z.string().min(1).max(200), + customerEmail: z.string().email().max(320), + customerSocials: z.string().max(2000).optional().nullable(), + message: z.string().min(1).max(20_000), + + // customFields: z.record(z.string(), z.unknown()).optional(), +}); + +function safeJsonParse(input: string) { + try { + return JSON.parse(input) as unknown; + } catch { + return null; + } +} + +export async function POST(request: Request) { + // Optional: basic origin allowlist check (recommended) + // const origin = request.headers.get("origin"); + // if (origin && !["https://domain.com", "https://www.domain.com"].includes(origin)) { + // return NextResponse.json({ error: "Invalid origin" }, { status: 403 }); + // } + + const form = await request.formData(); + + const payloadRaw = form.get("payload"); + if (typeof payloadRaw !== "string") { + return NextResponse.json({ error: "Missing payload" }, { status: 400 }); + } + + const parsedJson = safeJsonParse(payloadRaw); + if (!parsedJson) { + return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 }); + } + + const payload = payloadSchema.safeParse(parsedJson); + if (!payload.success) { + return NextResponse.json( + { error: "Validation error", issues: payload.error.issues }, + { status: 422 } + ); + } + + const files = form.getAll("files").filter((v): v is File => v instanceof File); + + // Optional: enforce limits + const MAX_FILES = 10; + const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB + if (files.length > MAX_FILES) { + return NextResponse.json({ error: "Too many files" }, { status: 413 }); + } + for (const f of files) { + if (f.size > MAX_BYTES_EACH) { + return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 }); + } + } + + // Capture basic metadata + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null; + const userAgent = request.headers.get("user-agent") ?? null; + + // Create request first to get requestId; then upload files and store file rows + // Use a transaction to keep DB consistent. + const result = await prisma.$transaction(async (tx) => { + const created = await tx.commissionRequest.create({ + data: { + status: "NEW", + customerName: payload.data.customerName, + customerEmail: payload.data.customerEmail, + customerSocials: payload.data.customerSocials ?? null, + message: payload.data.message, + + typeId: payload.data.typeId ?? null, + optionId: payload.data.optionId ?? null, + + ipAddress, + userAgent, + + // customFields: payload.data.customFields ?? undefined, + + // Extras are M:N; connect by ids + extras: payload.data.extraIds?.length + ? { connect: payload.data.extraIds.map((id) => ({ id })) } + : undefined, + }, + select: { id: true, createdAt: true }, + }); + + for (const f of files) { + const fileKey = uuidv4(); + const fileType = f.type; + const realFileType = fileType.split("/")[1]; + const originalKey = `commissions/${fileKey}.${realFileType}`; + + const arrayBuffer = await f.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + await s3.send( + new PutObjectCommand({ + Bucket: `${process.env.BUCKET_NAME}`, + Key: originalKey, + Body: buffer, + ContentType: f.type || "application/octet-stream", + }) + ); + + await tx.commissionRequestFile.create({ + data: { + requestId: created.id, + fileKey: originalKey, + originalFile: f.name, + fileType: fileType || "application/octet-stream", + fileSize: f.size, + uploadDate: created.createdAt, + }, + }); + } + + return created; + }); + + return NextResponse.json(result, { status: 201 }); +} diff --git a/src/components/commissions/CommissionRequestsTable.tsx b/src/components/commissions/CommissionRequestsTable.tsx new file mode 100644 index 0000000..b514f83 --- /dev/null +++ b/src/components/commissions/CommissionRequestsTable.tsx @@ -0,0 +1,590 @@ +"use client"; + +import { + ColumnDef, + SortingState, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronDown, + ChevronUp, + MoreHorizontal, + Pencil, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import * as React from "react"; + +import { deleteCommissionRequest } from "@/actions/commissions/deleteCommissionRequest"; +import { getCommissionRequestsTablePage } from "@/actions/commissions/getCommissionRequestsTablePage"; +import { setCommissionRequestStatus } from "@/actions/commissions/setCommissionRequestStatus"; +import type { + CommissionRequestTableRow, + CommissionStatus, +} from "@/schemas/commissions/tableSchema"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +type TriState = "any" | "true" | "false"; + +function useDebouncedValue(value: T, delayMs: number) { + const [debounced, setDebounced] = React.useState(value); + React.useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} + +function SortHeader(props: { title: string; column: any }) { + const sorted = props.column.getIsSorted() as false | "asc" | "desc"; + + return ( + + ); +} + +function StatusBadge({ status }: { status: CommissionStatus }) { + const variant = + status === "COMPLETED" + ? "default" + : status === "REJECTED" || status === "SPAM" + ? "destructive" + : "secondary"; + + return ( + + {status} + + ); +} + +function TriSelectInline(props: { value: TriState; onChange: (v: TriState) => void }) { + return ( + + ); +} + +const STATUS_OPTIONS: CommissionStatus[] = [ + "NEW", + "REVIEWING", + "ACCEPTED", + "REJECTED", + "COMPLETED", + "SPAM", +]; + +type Filters = { + q: string; + email: string; + status: "any" | CommissionStatus; + hasFiles: TriState; +}; + +export function CommissionRequestsTable() { + const [sorting, setSorting] = React.useState([ + { id: "createdAt", desc: true }, + ]); + const [pageIndex, setPageIndex] = React.useState(0); + const [pageSize, setPageSize] = React.useState(25); + + const [filters, setFilters] = React.useState({ + q: "", + email: "", + status: "any", + hasFiles: "any", + }); + + const debouncedQ = useDebouncedValue(filters.q, 300); + const debouncedEmail = useDebouncedValue(filters.email, 300); + + const [rows, setRows] = React.useState([]); + const [total, setTotal] = React.useState(0); + + const [isPending, startTransition] = React.useTransition(); + + // Delete dialog + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState<{ + id: string; + label: string; + } | null>(null); + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const refresh = React.useCallback(() => { + startTransition(async () => { + const res = await getCommissionRequestsTablePage({ + pagination: { pageIndex, pageSize }, + sorting, + filters: { + q: debouncedQ || undefined, + email: debouncedEmail || undefined, + status: filters.status, + hasFiles: filters.hasFiles, + }, + }); + + setRows(res.rows); + setTotal(res.total); + }); + }, [ + pageIndex, + pageSize, + sorting, + debouncedQ, + debouncedEmail, + filters.status, + filters.hasFiles, + ]); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + const columns = React.useMemo[]>( + () => [ + { + accessorKey: "createdAt", + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleString()} + + ), + }, + { + accessorKey: "filesCount", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.filesCount} + ), + }, + { + id: "requestor", + header: "Requestor", + enableSorting: false, + cell: ({ row }) => { + const r = row.original; + return ( +
+
{r.customerName}
+
+ {r.customerEmail} + {r.customerSocials ? ` · ${r.customerSocials}` : ""} +
+
+ ); + }, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + id: "complete", + header: "", + enableSorting: false, + cell: ({ row }) => { + const r = row.original; + const isCompleted = r.status === "COMPLETED"; + + return ( +
+ +
+ ); + }, + }, + { + id: "actions", + header: "", + enableSorting: false, + cell: ({ row }) => { + const r = row.original; + + return ( +
+ + + + + + + {/* status changes */} +
+ Set status +
+ {STATUS_OPTIONS.filter((s) => s !== "COMPLETED").map((s) => ( + { + e.preventDefault(); + startTransition(async () => { + await setCommissionRequestStatus({ id: r.id, status: s }); + refresh(); + }); + }} + > + {s} + + ))} + + + + + + + Edit + + + + + + { + e.preventDefault(); + setDeleteTarget({ + id: r.id, + label: `${r.customerName} (${r.customerEmail})`, + }); + setDeleteOpen(true); + }} + > + + Delete + +
+
+
+ ); + }, + }, + ], + [isPending, refresh], + ); + + const table = useReactTable({ + data: rows, + columns, + state: { sorting }, + manualPagination: true, + manualSorting: true, + pageCount, + onSortingChange: (updater) => { + setSorting((prev) => (typeof updater === "function" ? updater(prev) : updater)); + setPageIndex(0); + }, + getCoreRowModel: getCoreRowModel(), + }); + + const headerGroup = table.getHeaderGroups()[0]; + + return ( +
+
+
+
+ + + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + + {/* filter row */} + + {headerGroup.headers.map((header) => { + const colId = header.column.id; + + return ( + + {colId === "createdAt" ? ( +
+ ) : colId === "filesCount" ? ( + { + setFilters((f) => ({ ...f, hasFiles: v })); + setPageIndex(0); + }} + /> + ) : colId === "requestor" ? ( +
+ { + setFilters((f) => ({ ...f, q: e.target.value })); + setPageIndex(0); + }} + /> + { + setFilters((f) => ({ ...f, email: e.target.value })); + setPageIndex(0); + }} + /> +
+ ) : colId === "status" ? ( + + ) : ( +
+ )} + + ); + })} + + + + + {rows.length === 0 ? ( + + +
+ {isPending ? "Loading…" : "No results."} +
+
+ Adjust filters or change page size. +
+
+
+ ) : ( + table.getRowModel().rows.map((r, idx) => ( + + {r.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} +
+
+
+
+ + {/* pagination */} +
+
+ {isPending ? "Updating…" : null} Total: {total} +
+ +
+ + + + + +
+ Page {pageIndex + 1} / {pageCount} +
+ + + +
+
+ + {/* delete confirmation */} + + + + Delete request? + + This will delete{" "} + {deleteTarget?.label}. This action cannot be undone. + + + + Cancel + { + const target = deleteTarget; + if (!target) return; + + startTransition(async () => { + await deleteCommissionRequest(target.id); + setDeleteOpen(false); + setDeleteTarget(null); + + // If current page becomes empty after delete, clamp page index. + setPageIndex((p) => (p > 0 && rows.length === 1 ? p - 1 : p)); + refresh(); + }); + }} + > + Delete + + + + +
+ ); +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 175b243..c426f61 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -27,7 +27,7 @@ const artworkItems = [ const commissionItems = [ { - title: "Commissions", + title: "Requests", href: "/commissions", }, { diff --git a/src/proxy.ts b/src/proxy.ts index 62b8a4c..cfab274 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -26,5 +26,5 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!api/auth|api/image|login|_next/static|_next/image|favicon.ico).*)"], + matcher: ["/((?!api/auth|api/image|api/v1|login|_next/static|_next/image|favicon.ico).*)"], }; \ No newline at end of file diff --git a/src/schemas/commissions/tableSchema.ts b/src/schemas/commissions/tableSchema.ts new file mode 100644 index 0000000..7901a60 --- /dev/null +++ b/src/schemas/commissions/tableSchema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const commissionStatusSchema = z.enum([ + "NEW", + "REVIEWING", + "ACCEPTED", + "REJECTED", + "COMPLETED", + "SPAM", +]); + +export const commissionRequestTableRowSchema = z.object({ + id: z.string(), + createdAt: z.string(), // ISO + status: commissionStatusSchema, + + customerName: z.string(), + customerEmail: z.string(), + customerSocials: z.string().nullable().optional(), + + messagePreview: z.string().optional(), // optional, useful for hover later + filesCount: z.number().int().nonnegative(), +}); + +export type CommissionRequestTableRow = z.infer; +export type CommissionStatus = z.infer;