inquiry ui
All checks were successful
ci / docker_image (push) Successful in 3m9s
ci / deploy (push) Successful in 28s

This commit is contained in:
Drake Marino 2026-02-05 23:27:03 -06:00
parent 505434a2d8
commit 6c4ce12b45
11 changed files with 159 additions and 86 deletions

View File

@ -40,7 +40,7 @@
--warning: oklch(0.84 0.16 84); --warning: oklch(0.84 0.16 84);
--error: oklch(0.577 0.245 27.325); --error: oklch(0.577 0.245 27.325);
--positive: oklch(0.5 0.2067 147.18); --positive: oklch(0.5 0.2067 147.18);
--edit: oklch(0.5852 0.2263 260.47); --action: oklch(0.5852 0.2263 260.47);
} }
.dark { .dark {
@ -78,7 +78,7 @@
--warning: oklch(0.84 0.16 84); --warning: oklch(0.84 0.16 84);
--error: oklch(0.704 0.191 22.216); --error: oklch(0.704 0.191 22.216);
--positive: oklch(0.7522 0.2067 147.18); --positive: oklch(0.7522 0.2067 147.18);
--edit: oklch(0.6098 0.1872 260.47); --action: oklch(0.6098 0.1872 260.47);
} }
@theme inline { @theme inline {
@ -120,7 +120,7 @@
--color-warning: var(--warning); --color-warning: var(--warning);
--color-error: var(--error); --color-error: var(--error);
--color-positive: var(--positive); --color-positive: var(--positive);
--color-edit: var(--edit); --color-action: var(--action);
} }
@layer base { @layer base {

View File

@ -23,7 +23,7 @@
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="post" action="?/inquire"> <form method="post" action="?/claim">
<Field.Group> <Field.Group>
<div class="flex gap-4"> <div class="flex gap-4">

View File

@ -6,7 +6,7 @@
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import * as Field from '$lib/components/ui/field'; import * as Field from '$lib/components/ui/field';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import TrashIcon from '@lucide/svelte/icons/trash';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte'; import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import { genDescription } from '$lib/db/items.remote'; import { genDescription } from '$lib/db/items.remote';
import { EMAIL_REGEX_STRING } from '$lib/consts'; import { EMAIL_REGEX_STRING } from '$lib/consts';
@ -14,6 +14,9 @@
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { dateFormatOptions } from '$lib/shared'; import { dateFormatOptions } from '$lib/shared';
import { Badge } from '$lib/components/ui/badge';
import { deleteInquiry } from '$lib/db/inquiries.remote';
import { invalidateAll } from '$app/navigation';
let { open = $bindable(), item = $bindable() }: { open: boolean, item: Item | undefined } = $props(); let { open = $bindable(), item = $bindable() }: { open: boolean, item: Item | undefined } = $props();
@ -32,7 +35,7 @@
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-w-[calc(100%-2rem)] md:max-w-3xl"> <Dialog.Content class={item?.threads ? 'max-w-[calc(100%-2rem)] md:max-w-3xl' : ''}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Manage Item</Dialog.Title> <Dialog.Title>Manage Item</Dialog.Title>
<Dialog.Description> <Dialog.Description>
@ -122,16 +125,30 @@
<h2 class="text-lg leading-none font-semibold">Item inquiries:</h2> <h2 class="text-lg leading-none font-semibold">Item inquiries:</h2>
{#each item.threads as thread (thread)} {#each item.threads as thread (thread)}
<a href="/items/{item.id}/inquiries/{thread.id}" class="mt-4">
<Card.Root> <Card.Root class="mt-4">
<Card.Header> <Card.Header>
<Card.Title>Date</Card.Title> <Card.Title>{thread.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
<Card.Content>Inquirer: {thread.messages[0].body} <!--{#if thread.messages[thread.messages.length - 1].sender === 'inquirer'}-->
</Card.Content> <!-- t-->
</Card.Header> <!--{/if}-->
</Card.Root> </Card.Title>
</a> <Card.Description
>{thread.messages[0].body}
</Card.Description
>
<Card.Action class="items-center flex gap-2">
<a href="/items/{item.id}/inquiries/{thread.id}"
class="{buttonVariants({variant: 'ghost'})} text-action">Reply?</a>
<Button variant="ghost" class="text-destructive"
onclick={() => {deleteInquiry(thread.id); invalidateAll();}}>
<TrashIcon />
</Button>
</Card.Action>
</Card.Header>
</Card.Root>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -12,6 +12,7 @@
let { open = $bindable(), item }: { open: boolean, item: Item | undefined } = $props(); let { open = $bindable(), item }: { open: boolean, item: Item | undefined } = $props();
</script> </script>
<Dialog.Root bind:open> <Dialog.Root bind:open>
@ -23,7 +24,7 @@
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="post" action="?/inquire"> <form method="post" action={'?/inquire&id=' + item?.id}>
<Field.Group> <Field.Group>
<div class="flex gap-4"> <div class="flex gap-4">
@ -53,7 +54,7 @@
</div> </div>
<Field.Field> <Field.Field>
<Field.Label for="description"> <Field.Label for="inquiry">
Please describe your inquiry <span class="text-error">*</span> Please describe your inquiry <span class="text-error">*</span>
</Field.Label> </Field.Label>
<Input <Input

View File

@ -98,7 +98,7 @@
Deny Deny
</Button> </Button>
{/if} {/if}
<Button variant="ghost" class="text-edit" <Button variant="ghost" class="text-action"
onclick={() => {editCallback(item)}}> onclick={() => {editCallback(item)}}>
<PencilIcon /> <PencilIcon />
Manage Manage
@ -107,7 +107,7 @@
</div> </div>
{:else} {:else}
<div class="mt-2 justify-between flex"> <div class="mt-2 justify-between flex">
<Button variant="ghost" class="text-edit" <Button variant="ghost" class="text-action"
onclick={() => {inquireCallback(item)}}> onclick={() => {inquireCallback(item)}}>
<NotebookPenIcon /> <NotebookPenIcon />
Inquire Inquire

View File

@ -34,7 +34,7 @@
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="post" action="?/create" enctype="multipart/form-data"> <form method="post" action="/items?/create" enctype="multipart/form-data">
<Field.Group> <Field.Group>
<ImageUpload onSelect={onSelect} required /> <ImageUpload onSelect={onSelect} required />

View File

@ -22,7 +22,7 @@ export const EXPIRE_REMINDER_DAYS = 30;
// /(?:[a-z0-9!#$%&'*+\/=?^`\{-\}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`\{-\}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; // /(?:[a-z0-9!#$%&'*+\/=?^`\{-\}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`\{-\}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
const EMAIL_REGEX = const EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_'+-]@([a-z0-9][a-z0-9-]*\.)+[a-z]{2,}$/i; /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/i;
// Replace single quote with HTML entity or remove it from the character class // Replace single quote with HTML entity or remove it from the character class
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '&#39;'); export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '&#39;');

View File

@ -0,0 +1,11 @@
import { getRequestEvent, query } from '$app/server';
import * as v from 'valibot';
import sql from '$lib/db/db.server';
import { verifyJWT } from '$lib/auth/index.server';
export const deleteInquiry = query(v.number(), async (id) => {
const { cookies } = getRequestEvent();
verifyJWT(cookies);
await sql`DELETE FROM inquiry_threads WHERE id = ${id};`;
});

View File

@ -1,4 +1,4 @@
import { getRequestEvent, query } from '$app/server'; import { command, getRequestEvent, query } from '$app/server';
import * as v from 'valibot'; import * as v from 'valibot';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import { verifyJWT } from '$lib/auth/index.server'; import { verifyJWT } from '$lib/auth/index.server';
@ -9,7 +9,7 @@ export const genDescription = query(async () => {
return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.'; return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.';
}); });
export const approveDenyItem = query( export const approveDenyItem = command(
v.object({ id: v.number(), approved: v.boolean() }), v.object({ id: v.number(), approved: v.boolean() }),
async ({ id, approved }) => { async ({ id, approved }) => {
const { cookies } = getRequestEvent(); const { cookies } = getRequestEvent();

View File

@ -5,8 +5,6 @@ import sql from '$lib/db/db.server';
import sharp from 'sharp'; import sharp from 'sharp';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item.server'; import type { Item } from '$lib/types/item.server';
import type { Message } from '$lib/types/inquiries.server';
import { Sender } from '$lib/types/inquiries.server';
import { getFormString, getRequiredFormString } from '$lib/shared'; import { getFormString, getRequiredFormString } from '$lib/shared';
export const load: PageServerLoad = async ({ url, locals }) => { export const load: PageServerLoad = async ({ url, locals }) => {
@ -15,59 +13,92 @@ export const load: PageServerLoad = async ({ url, locals }) => {
// If the user is logged in, fetch items together with their threads (each thread contains its first message) // If the user is logged in, fetch items together with their threads (each thread contains its first message)
if (locals && locals.user) { if (locals && locals.user) {
try { try {
type DBMessage = { const items: Item[] = await sql`
id: number; SELECT
sender: keyof typeof Sender | string; i.*,
body: string; json_agg(
createdAt: string; jsonb_build_object(
}; 'id', t.id,
type DBThread = { id: number; messages: DBMessage[] }; 'item_id', t.item_id,
type RowsItem = { threads: DBThread[]; [key: string]: unknown }; 'created_at', t.created_at,
'messages', m.messages
const rows = (await sql` )
SELECT i.*, COALESCE( ) FILTER (WHERE t.id IS NOT NULL) AS threads
( FROM items i
SELECT json_agg(json_build_object('id', t.id, 'messages', json_build_array(json_build_object('id', m.id, 'sender', m.sender, 'body', m.body, 'createdAt', m.created_at))) ORDER BY t.id) LEFT JOIN inquiry_threads t
FROM inquiry_threads t ON t.item_id = i.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT id, sender, body, created_at SELECT
FROM inquiry_messages json_agg(im.* ORDER BY im.created_at) AS messages
WHERE thread_id = t.id FROM inquiry_messages im
ORDER BY created_at ASC WHERE im.thread_id = t.id
LIMIT 1 ) m ON TRUE
) m ON true GROUP BY i.id;
WHERE t.item_id = i.id `;
), '[]'::json items.forEach((item) =>
) AS threads item.threads?.forEach((thread) => (thread.createdAt = new Date(thread.createdAt)))
FROM items i );
${
searchQuery
? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3
ORDER BY word_similarity(${searchQuery}, i.description)`
: sql``
};
`) as RowsItem[];
// `rows` contains items with a `threads` JSON column. Attach parsed threads to each item.
const items: Item[] = rows.map((r) => {
const item: Record<string, unknown> = { ...r };
const rawThreads = (r.threads || []) as DBThread[];
item.threads = rawThreads.map((t) => ({
id: t.id,
messages: (t.messages || []).map((m) => ({
id: m.id,
// coerce sender to our Sender enum type if possible
sender: (Object.values(Sender).includes(m.sender as Sender)
? (m.sender as Sender)
: (m.sender as unknown)) as Message['sender'],
body: m.body,
createdAt: new Date(m.createdAt)
}))
}));
return item as unknown as Item;
});
return { items }; return { items };
// type DBMessage = {
// id: number;
// sender: keyof typeof Sender | string;
// body: string;
// createdAt: string;
// };
// type DBThread = { id: number; messages: DBMessage[] };
// type RowsItem = { threads: DBThread[]; [key: string]: unknown };
//
// const rows = (await sql`
// SELECT i.*, COALESCE(
// (
// SELECT json_agg(json_build_object('id', t.id, 'messages', json_build_array(json_build_object('id', m.id, 'sender', m.sender, 'body', m.body, 'createdAt', m.created_at))), 'createdAt' , t.created_at ORDER BY t.id)
// FROM inquiry_threads t
// LEFT JOIN LATERAL (
// SELECT id, sender, body, created_at
// FROM inquiry_messages
// WHERE thread_id = t.id
// ORDER BY created_at ASC
// LIMIT 1
// ) m ON true
// WHERE t.item_id = i.id
// ), '[]'::json
// ) AS threads
// FROM items i
// ${
// searchQuery
// ? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3
// ORDER BY word_similarity(${searchQuery}, i.description)`
// : sql``
// };
// `) as RowsItem[];
//
// // `rows` contains items with a `threads` JSON column. Attach parsed threads to each item.
// const items: Item[] = rows.map((r) => {
// const item: Record<string, unknown> = { ...r };
// const rawThreads = (r.threads || []) as DBThread[];
// console.log(rawThreads);
// if (rawThreads.length === 0) {
// item.threads = null;
// } else {
// item.threads = rawThreads.map((t) => ({
// id: t.id,
// messages: (t.messages || []).map((m) => ({
// id: m.id,
// // coerce sender to our Sender enum type if possible
// sender: (Object.values(Sender).includes(m.sender as Sender)
// ? (m.sender as Sender)
// : (m.sender as unknown)) as Message['sender'],
// body: m.body,
// createdAt: new Date(m.createdAt)
// }))
// }));
// }
// return item as unknown as Item;
// });
//
// return { items };
} catch (err) { } catch (err) {
console.error('Failed to load items with threads:', err); console.error('Failed to load items with threads:', err);
// Fallback to non-joined fetch so the page still loads // Fallback to non-joined fetch so the page still loads
@ -128,7 +159,10 @@ export const actions: Actions = {
const inputBuffer = Buffer.from(await file.arrayBuffer()); const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally) // Detect format (Sharp does this internally)
const image = sharp(inputBuffer); const image = sharp(inputBuffer).rotate();
// image = image.rotate();
const metadata = await image.metadata(); const metadata = await image.metadata();
if (metadata.format === 'jpeg') { if (metadata.format === 'jpeg') {
@ -137,6 +171,7 @@ export const actions: Actions = {
} else { } else {
// Convert to JPG // Convert to JPG
outputBuffer = await image outputBuffer = await image
.jpeg({ quality: 90 }) // adjust if needed .jpeg({ quality: 90 }) // adjust if needed
.toBuffer(); .toBuffer();
} }
@ -238,7 +273,7 @@ export const actions: Actions = {
// Convert File → Buffer // Convert File → Buffer
const inputBuffer = Buffer.from(await file.arrayBuffer()); const inputBuffer = Buffer.from(await file.arrayBuffer());
const image = sharp(inputBuffer); const image = sharp(inputBuffer).rotate();
const metadata = await image.metadata(); const metadata = await image.metadata();
if (metadata.format === 'jpeg') { if (metadata.format === 'jpeg') {
@ -280,6 +315,7 @@ export const actions: Actions = {
inquire: async ({ request, url }) => { inquire: async ({ request, url }) => {
const id = url.searchParams.get('id'); const id = url.searchParams.get('id');
if (!id) { if (!id) {
console.log('No id provided!');
return fail(400, { message: 'Missing id!', success: false }); return fail(400, { message: 'Missing id!', success: false });
} }
@ -292,13 +328,21 @@ export const actions: Actions = {
inquiry = getRequiredFormString(data, 'inquiry'); inquiry = getRequiredFormString(data, 'inquiry');
email = getRequiredFormString(data, 'email'); email = getRequiredFormString(data, 'email');
const response = sql` const response = await sql`
WITH new_thread AS ( WITH new_thread AS (
INSERT INTO inquiry_threads (item_id) VALUES (${id}) INSERT INTO inquiry_threads (item_id, inquirer_email)
RETURNING id VALUES (${id}, ${email})
) INSERT INTO inquiry_messages (thread_id, sender, body) RETURNING id
VALUES (new_thread.id, 'inquirer', ${inquiry}) )
RETURNING new_thread.id, id; INSERT INTO inquiry_messages (thread_id, sender, body)
SELECT
id,
'inquirer',
${inquiry}
FROM new_thread
RETURNING
thread_id,
id AS message_id;
`; `;
} catch (e) { } catch (e) {
return fail(400, { return fail(400, {

View File

@ -15,6 +15,6 @@ export const actions = {
} catch (e) { } catch (e) {
return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred' }); return fail(400, { message: e instanceof Error ? e.message : 'Unknown error occurred' });
} }
throw redirect(303, '/'); throw redirect(303, '/items');
} }
} satisfies Actions; } satisfies Actions;