account page
This commit is contained in:
parent
ee80f849bd
commit
cd128d7914
@ -23,7 +23,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form method="post" action="?/claim">
|
||||
<form method="post" action="?/claim&id={item?.id}">
|
||||
<Field.Group>
|
||||
<div class="flex gap-4">
|
||||
|
||||
|
||||
@ -7,11 +7,13 @@
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import StarIcon from '@lucide/svelte/icons/star';
|
||||
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { dateFormatOptions } from '$lib/shared';
|
||||
import { approveDenyItem } from '$lib/db/items.remote';
|
||||
import { approveDenyItem, restoreClaimedItem } from '$lib/db/items.remote';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||
|
||||
@ -98,11 +100,26 @@
|
||||
Deny
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="ghost" class="text-action"
|
||||
onclick={() => {editCallback(item)}}>
|
||||
<PencilIcon />
|
||||
Manage
|
||||
</Button>
|
||||
{#if item.claimedDate === null}
|
||||
<Button variant="ghost" class="text-action"
|
||||
onclick={() => {editCallback(item)}}>
|
||||
<PencilIcon />
|
||||
Manage
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" class="text-destructive"
|
||||
onclick={async () => {await approveDenyItem({id: item.id, approved: false});
|
||||
invalidateAll()}}>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="ghost" class="text-action"
|
||||
onclick={async () => {await restoreClaimedItem(item.id);
|
||||
invalidateAll()}}>
|
||||
<ArchiveRestoreIcon />
|
||||
Restore
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
29
src/lib/components/ui/switch/switch.svelte
Normal file
29
src/lib/components/ui/switch/switch.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Switch as SwitchPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
checked = $bindable(false),
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<SwitchPrimitive.Root
|
||||
bind:ref
|
||||
bind:checked
|
||||
data-slot="switch"
|
||||
class={cn(
|
||||
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
class={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
@ -25,4 +25,5 @@ const EMAIL_REGEX =
|
||||
/^(?!\.)(?!.*\.\.)([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
|
||||
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, ''');
|
||||
export const EMAIL_REGEX_STRING =
|
||||
"^(?!\\.)(?!.*\\.\\.)([a-zA-Z0-9_'+\\-\\.]*)[a-zA-Z0-9_'+\\-]@([a-zA-Z0-9][a-zA-Z0-9\\-]*\\.)+[a-zA-Z]{2,}$";
|
||||
|
||||
@ -58,3 +58,14 @@ export const approveDenyItem = command(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const restoreClaimedItem = command(v.number(), async (id) => {
|
||||
const { cookies } = getRequestEvent();
|
||||
verifyJWT(cookies);
|
||||
|
||||
const reponse = await sql`
|
||||
UPDATE items
|
||||
SET claimed_date = null
|
||||
WHERE id = ${id};
|
||||
`;
|
||||
});
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import sql from '$lib/db/db.server';
|
||||
import type { Item } from '$lib/types/item';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
|
||||
import { threadId } from 'node:worker_threads';
|
||||
import type { Item } from '$lib/types/item';
|
||||
|
||||
// Create a transporter object using SMTP transport
|
||||
export const transporter = nodemailer.createTransport({
|
||||
@ -47,7 +46,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
|
||||
//
|
||||
// `;
|
||||
|
||||
const item: Item = await sql`
|
||||
const [item]: Item[] = await sql`
|
||||
SELECT
|
||||
i.*,
|
||||
json_agg(
|
||||
@ -57,7 +56,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
|
||||
'created_at', t.created_at,
|
||||
'messages', m.messages
|
||||
)
|
||||
) FILTER (WHERE t.id = ${threadId}) AS threads
|
||||
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
|
||||
FROM items i
|
||||
LEFT JOIN inquiry_threads t
|
||||
ON t.item_id = i.id
|
||||
@ -77,49 +76,42 @@ export async function sendNewInquiryEmail(inquiryId: number) {
|
||||
const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!);
|
||||
|
||||
console.log(item);
|
||||
console.log(item.threads);
|
||||
console.log(replyToken);
|
||||
// Send mail with defined transport object
|
||||
// await transporter.sendMail({
|
||||
// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||
// to: item.emails,
|
||||
// // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||
// replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
||||
// subject: 'New Item Inquiry!',
|
||||
// text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
|
||||
// });
|
||||
await transporter.sendMail({
|
||||
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||
to: item.emails,
|
||||
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||
replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
||||
subject: 'New Item Inquiry!',
|
||||
text: `Someone has made an inquiry on the item with description: ${item.description}\nThey ask: ${item.threads![0].messages[0].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) {
|
||||
const item: Item = await sql`
|
||||
SELECT json_agg(item_data) AS result
|
||||
FROM (
|
||||
SELECT
|
||||
i.*,
|
||||
(
|
||||
SELECT json_agg(thread_data)
|
||||
FROM (
|
||||
SELECT
|
||||
it.id,
|
||||
it.item_id,
|
||||
(
|
||||
SELECT json_agg(im)
|
||||
FROM inquiry_messages im
|
||||
WHERE im.thread_id = it.id
|
||||
ORDER BY im.created_at
|
||||
) AS messages
|
||||
FROM inquiry_threads it
|
||||
WHERE it.id = ${inquiryId}
|
||||
) AS thread_data
|
||||
) AS threads
|
||||
FROM items i
|
||||
WHERE i.id = (
|
||||
SELECT item_id
|
||||
FROM inquiry_threads
|
||||
WHERE id = ${inquiryId}
|
||||
)
|
||||
) AS item_data;
|
||||
|
||||
`;
|
||||
const [item]: Item[] = await sql`
|
||||
SELECT
|
||||
i.*,
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'item_id', t.item_id,
|
||||
'created_at', t.created_at,
|
||||
'messages', m.messages
|
||||
)
|
||||
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
|
||||
FROM items i
|
||||
LEFT JOIN inquiry_threads t
|
||||
ON t.item_id = i.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
json_agg(im.* ORDER BY im.created_at) AS messages
|
||||
FROM inquiry_messages im
|
||||
WHERE im.thread_id = t.id
|
||||
) m ON TRUE
|
||||
WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId})
|
||||
GROUP BY i.id;`;
|
||||
|
||||
const tokenPayload: inquiryTokenPayload = {
|
||||
threadId: inquiryId,
|
||||
@ -137,3 +129,20 @@ export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender)
|
||||
text: `Someone has replied to the inquiry on the item with description: ${item.description}\nThey say: ${item.threads![0].messages[item.threads![0].messages.length - 1].body}\n\n\nRespond to this email directly, or click the below link to reply on Westuffinder\n${process.env.BASE_URL}/items/${item.id}/inquiries/${inquiryId}?token=${replyToken}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendClaimEmail(id: number, email: string) {
|
||||
const [item]: Item[] = await sql`
|
||||
SELECT * FROM items WHERE id = ${id};`;
|
||||
|
||||
if (!item.transferred) {
|
||||
// Send mail with defined transport object
|
||||
await transporter.sendMail({
|
||||
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||
to: item.emails,
|
||||
replyTo: email,
|
||||
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||
subject: 'Your Item was Claimed!',
|
||||
text: `Someone has claimed your item with description: ${item.description}\nReply to this email explaining how they can pick up the item from you. Replies to this email go directly to the claimer.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
17
src/routes/account/+page.server.ts
Normal file
17
src/routes/account/+page.server.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { PageServerLoad } from '$types';
|
||||
import sql from '$lib/db/db.server';
|
||||
import type { User } from '$lib/types/user';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals || !locals.user) {
|
||||
throw Error('Need to be logged in!');
|
||||
}
|
||||
|
||||
const [userData]: User[] = await sql`
|
||||
SELECT * FROM users WHERE id = ${locals.user.id}
|
||||
`;
|
||||
|
||||
console.log(userData);
|
||||
|
||||
return { userData };
|
||||
};
|
||||
@ -1,10 +1,130 @@
|
||||
<!-- src/routes/account/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { DefaultUserSettings } from '$lib/types/user';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function signOut() {
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
let form = $derived({
|
||||
name: data.userData.name,
|
||||
email: data.userData.email,
|
||||
staleItemDays:
|
||||
data.userData.settings?.staleItemDays ?? DefaultUserSettings.staleItemDays,
|
||||
notifyAllApprovedInquiries:
|
||||
data.userData.settings?.notifyAllApprovedInquiries ?? false,
|
||||
notifyAllTurnedInInquiries:
|
||||
data.userData.settings?.notifyAllTurnedInInquiries ?? false
|
||||
});
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Button onclick={signOut}>Sign out</Button>
|
||||
<svelte:head>
|
||||
<title>Account</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-3xl py-10">
|
||||
<Card class="rounded-2xl shadow-sm">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-2xl font-semibold">Account Overview</CardTitle>
|
||||
<Badge variant="secondary">ID #{data.userData.id}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Editable Profile Form -->
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Username</Label>
|
||||
<Input id="username" value={data.userData.username} disabled />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Full Name</Label>
|
||||
<Input id="name" name="name" bind:value={form.name} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" name="email" type="email" bind:value={form.email} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="staleItemDays">Stale Item Days</Label>
|
||||
<Input
|
||||
id="staleItemDays"
|
||||
name="staleItemDays"
|
||||
type="number"
|
||||
bind:value={form.staleItemDays}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">Notify All Approved</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Receive notifications when inquiries are approved.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
name="notifyAllApprovedInquiries"
|
||||
bind:checked={form.notifyAllApprovedInquiries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">Notify All Turned In</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Receive notifications when inquiries are turned in.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
name="notifyAllTurnedInInquiries"
|
||||
bind:checked={form.notifyAllTurnedInInquiries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Member since {formatDate(data.userData.createdAt)} · Last sign in {formatDate(
|
||||
data.userData.lastSignIn
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit">Save Changes</Button>
|
||||
<Button type="button" onclick={signOut} variant="destructive">
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@ -6,6 +6,7 @@ import sharp from 'sharp';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import type { Item } from '$lib/types/item';
|
||||
import { getFormString, getRequiredFormString } from '$lib/shared';
|
||||
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const searchQuery = url.searchParams.get('search');
|
||||
@ -344,7 +345,7 @@ export const actions: Actions = {
|
||||
id AS message_id;
|
||||
`;
|
||||
|
||||
// sendNewInquiryEmail(response[0].threadId);
|
||||
await sendNewInquiryEmail(response[0].threadId);
|
||||
} catch (e) {
|
||||
return fail(400, {
|
||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||
@ -359,11 +360,17 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
let inquiry: string;
|
||||
let email: string;
|
||||
|
||||
try {
|
||||
inquiry = getRequiredFormString(data, 'inquiry');
|
||||
email = getRequiredFormString(data, 'email');
|
||||
|
||||
const response = await sql`
|
||||
UPDATE items SET claimed_date = NOW()
|
||||
WHERE id = ${id};
|
||||
`;
|
||||
|
||||
await sendClaimEmail(id, email);
|
||||
} catch (e) {
|
||||
return fail(400, {
|
||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||
|
||||
@ -83,14 +83,14 @@
|
||||
<FieldSeparator class="col-span-full text-lg h-8 my-2">
|
||||
Pending items
|
||||
</FieldSeparator>
|
||||
{/if}
|
||||
|
||||
{#each data.items as item (item.id)}
|
||||
{#if item.approvedDate === null}
|
||||
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||
{/if}
|
||||
{/each}
|
||||
{#each data.items as item (item.id)}
|
||||
{#if item.approvedDate === null}
|
||||
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if data.user && data.items.some(
|
||||
(item) => item.approvedDate !== null && item.claimedDate === null
|
||||
@ -101,7 +101,7 @@
|
||||
{/if}
|
||||
|
||||
{#each data.items as item (item.id)}
|
||||
{#if item.approvedDate !== null}
|
||||
{#if item.approvedDate !== null && item.claimedDate === null}
|
||||
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||
{/if}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { Actions, PageServerLoad } from '$types';
|
||||
import { type inquiryTokenPayload, type Message, Sender } from '$lib/types/inquiries';
|
||||
import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import sql from '$lib/db/db.server';
|
||||
import { getRequiredFormString } from '$lib/shared';
|
||||
import { sendInquiryMessageEmail } from '$lib/email/sender.server';
|
||||
import type { Item } from '$lib/types/item';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, params }) => {
|
||||
const token: string | undefined = url.searchParams.get('token');
|
||||
@ -11,7 +13,11 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
|
||||
const inquiryId: string = params.inquiryId;
|
||||
|
||||
if (token) {
|
||||
jwt.verify(token, process.env.JWT_SECRET!);
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET!);
|
||||
} catch {
|
||||
throw error(403, 'Your response token does not match this inquiry!');
|
||||
}
|
||||
} else if (!locals || !locals.user) {
|
||||
throw error(
|
||||
403,
|
||||
@ -19,11 +25,32 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const messages: Message[] = await sql`
|
||||
SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId}
|
||||
`;
|
||||
const [item]: Item[] = await sql`
|
||||
SELECT
|
||||
i.*,
|
||||
json_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'item_id', t.item_id,
|
||||
'created_at', t.created_at,
|
||||
'messages', m.messages
|
||||
)
|
||||
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
|
||||
FROM items i
|
||||
LEFT JOIN inquiry_threads t
|
||||
ON t.item_id = i.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
json_agg(im.* ORDER BY im.created_at) AS messages
|
||||
FROM inquiry_messages im
|
||||
WHERE im.thread_id = t.id
|
||||
) m ON TRUE
|
||||
WHERE i.id = (SELECT item_id FROM inquiry_threads WHERE id = ${inquiryId})
|
||||
GROUP BY i.id;`;
|
||||
|
||||
return { messages };
|
||||
console.log(item);
|
||||
|
||||
return { item };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@ -37,13 +64,15 @@ export const actions: Actions = {
|
||||
if (locals && locals.user) {
|
||||
sender = Sender.ADMIN;
|
||||
} else if (token) {
|
||||
const decoded: inquiryTokenPayload = jwt.verify(
|
||||
token,
|
||||
process.env.JWT_SECRET!
|
||||
) as inquiryTokenPayload;
|
||||
sender = decoded.sender;
|
||||
let decoded: inquiryTokenPayload;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload;
|
||||
sender = decoded.sender;
|
||||
} catch {
|
||||
throw error(403, 'Your response token does not match this inquiry!');
|
||||
}
|
||||
|
||||
if (decoded.threadId !== inquiryId) {
|
||||
if (decoded.threadId.toString() !== inquiryId.toString()) {
|
||||
throw error(403, 'Your response token does not match this inquiry!');
|
||||
}
|
||||
} else {
|
||||
@ -64,6 +93,15 @@ export const actions: Actions = {
|
||||
RETURNING id;
|
||||
`;
|
||||
|
||||
// TODO: Double check logic
|
||||
let nextReplySender: Sender;
|
||||
if (sender === Sender.ADMIN || sender === Sender.FINDER) {
|
||||
nextReplySender = Sender.INQUIRER;
|
||||
} else {
|
||||
nextReplySender = Sender.FINDER;
|
||||
}
|
||||
await sendInquiryMessageEmail(response[0].threadId, nextReplySender);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@ -4,6 +4,11 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { type Message, Sender } from '$lib/types/inquiries';
|
||||
import SendIcon from '@lucide/svelte/icons/send';
|
||||
import LocationIcon from '@lucide/svelte/icons/map-pinned';
|
||||
import { page } from '$app/state';
|
||||
import * as item from 'valibot';
|
||||
import NoImagePlaceholder from '$lib/components/custom/no-image-placeholder.svelte';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@ -33,35 +38,59 @@
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto p-4">
|
||||
<div class="flex gap-4">
|
||||
|
||||
{#if data.item.image}
|
||||
<img src="https://fbla26.marinodev.com/uploads/{data.item.id}.jpg"
|
||||
class="object-cover max-w-48 max-h-48 rounded-2xl"
|
||||
alt="Lost item">
|
||||
{:else}
|
||||
<!-- <div class="min-h-48 w-full bg-accent flex flex-col justify-center">-->
|
||||
<!-- <div class="justify-center flex ">-->
|
||||
<!-- <NoImagePlaceholder className="" />-->
|
||||
<!-- </div>-->
|
||||
<!-- <p class="text-center mt-4">No image available</p>-->
|
||||
<!-- </div>-->
|
||||
{/if}
|
||||
<div class="">
|
||||
<div class="flex-1">{data.item.description}</div>
|
||||
{#if data.item.foundLocation}
|
||||
<div class="mt-2">
|
||||
<LocationIcon class="float-left mr-1" size={24} />
|
||||
<div>{data.item.foundLocation}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-6" />
|
||||
|
||||
<div class="relative">
|
||||
<!-- Conversation -->
|
||||
<div class="flex flex-col gap-6 relative">
|
||||
{#each data.messages as msg, index (msg.id)}
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Avatar -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}>
|
||||
{senderLabel(msg.sender)}
|
||||
{#if data.item.threads}
|
||||
{#each data.item.threads[0].messages as msg (msg.id)}
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Avatar -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}>
|
||||
{senderLabel(msg.sender)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Timeline line (except for last message) -->
|
||||
{#if index < data.messages.length - 1}
|
||||
<div class="w-px flex-1 bg-gray-300 mt-1"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Message body -->
|
||||
<div class="bg-card rounded-lg p-3 shadow-sm max-w-xl">
|
||||
<p class="text-sm">{msg.body}</p>
|
||||
<span class="text-xs text-muted-foreground mt-1 block">{msg.createdAt.toLocaleString()}</span>
|
||||
<!-- Message body -->
|
||||
<div class="bg-card rounded-lg p-3 shadow-sm max-w-xl">
|
||||
<p class="text-sm">{msg.body}</p>
|
||||
<span class="text-xs text-muted-foreground mt-1 block">{msg.createdAt.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<form class="mt-6 flex flex-col gap-2" method="post" action="?/reply">
|
||||
<form class="mt-6 flex flex-col gap-2" method="post" action="?/reply&token={page.url.searchParams.get('token')}">
|
||||
<Textarea
|
||||
placeholder="Write a message..."
|
||||
class="resize-none"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user