account page
This commit is contained in:
parent
ee80f849bd
commit
cd128d7914
@ -23,7 +23,7 @@
|
|||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<form method="post" action="?/claim">
|
<form method="post" action="?/claim&id={item?.id}">
|
||||||
<Field.Group>
|
<Field.Group>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
|
||||||
|
|||||||
@ -7,11 +7,13 @@
|
|||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
|
import NotebookPenIcon from '@lucide/svelte/icons/notebook-pen';
|
||||||
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||||
import StarIcon from '@lucide/svelte/icons/star';
|
import StarIcon from '@lucide/svelte/icons/star';
|
||||||
|
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { dateFormatOptions } from '$lib/shared';
|
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 { invalidateAll } from '$app/navigation';
|
||||||
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
import NoImagePlaceholder from './no-image-placeholder.svelte';
|
||||||
|
|
||||||
@ -98,11 +100,26 @@
|
|||||||
Deny
|
Deny
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.claimedDate === null}
|
||||||
<Button variant="ghost" class="text-action"
|
<Button variant="ghost" class="text-action"
|
||||||
onclick={() => {editCallback(item)}}>
|
onclick={() => {editCallback(item)}}>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{:else}
|
{: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;
|
/^(?!\.)(?!.*\.\.)([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, ''');
|
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 nodemailer from 'nodemailer';
|
||||||
import sql from '$lib/db/db.server';
|
import sql from '$lib/db/db.server';
|
||||||
import type { Item } from '$lib/types/item';
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { type inquiryTokenPayload, Sender } from '$lib/types/inquiries';
|
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
|
// Create a transporter object using SMTP transport
|
||||||
export const transporter = nodemailer.createTransport({
|
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
|
SELECT
|
||||||
i.*,
|
i.*,
|
||||||
json_agg(
|
json_agg(
|
||||||
@ -57,7 +56,7 @@ export async function sendNewInquiryEmail(inquiryId: number) {
|
|||||||
'created_at', t.created_at,
|
'created_at', t.created_at,
|
||||||
'messages', m.messages
|
'messages', m.messages
|
||||||
)
|
)
|
||||||
) FILTER (WHERE t.id = ${threadId}) AS threads
|
) FILTER ( WHERE t.id = ${inquiryId} ) AS threads
|
||||||
FROM items i
|
FROM items i
|
||||||
LEFT JOIN inquiry_threads t
|
LEFT JOIN inquiry_threads t
|
||||||
ON t.item_id = i.id
|
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!);
|
const replyToken = jwt.sign(tokenPayload, process.env.JWT_SECRET!);
|
||||||
|
|
||||||
console.log(item);
|
console.log(item);
|
||||||
|
console.log(item.threads);
|
||||||
console.log(replyToken);
|
console.log(replyToken);
|
||||||
// Send mail with defined transport object
|
// Send mail with defined transport object
|
||||||
// await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
from: `Westuffind Notifier <${process.env.EMAIL_USER}>`,
|
||||||
// to: item.emails,
|
to: item.emails,
|
||||||
// // to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
|
||||||
// replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
replyTo: `${process.env.EMAIL_USER!.split('@')[0]}+${replyToken}${process.env.EMAIL_USER!.split('@')[1]}`,
|
||||||
// subject: 'New Item Inquiry!',
|
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}`
|
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) {
|
export async function sendInquiryMessageEmail(inquiryId: number, sender: Sender) {
|
||||||
const item: Item = await sql`
|
const [item]: Item[] = await sql`
|
||||||
SELECT json_agg(item_data) AS result
|
|
||||||
FROM (
|
|
||||||
SELECT
|
SELECT
|
||||||
i.*,
|
i.*,
|
||||||
(
|
json_agg(
|
||||||
SELECT json_agg(thread_data)
|
jsonb_build_object(
|
||||||
FROM (
|
'id', t.id,
|
||||||
SELECT
|
'item_id', t.item_id,
|
||||||
it.id,
|
'created_at', t.created_at,
|
||||||
it.item_id,
|
'messages', m.messages
|
||||||
(
|
|
||||||
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;
|
) 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 = {
|
const tokenPayload: inquiryTokenPayload = {
|
||||||
threadId: inquiryId,
|
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}`
|
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">
|
<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 { Button } from '$lib/components/ui/button';
|
||||||
|
import { DefaultUserSettings } from '$lib/types/user';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
function signOut() {
|
function signOut() {
|
||||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
window.location.href = '/';
|
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>
|
</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 { writeFileSync } from 'node:fs';
|
||||||
import type { Item } from '$lib/types/item';
|
import type { Item } from '$lib/types/item';
|
||||||
import { getFormString, getRequiredFormString } from '$lib/shared';
|
import { getFormString, getRequiredFormString } from '$lib/shared';
|
||||||
|
import { sendClaimEmail, sendNewInquiryEmail } from '$lib/email/sender.server';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const searchQuery = url.searchParams.get('search');
|
const searchQuery = url.searchParams.get('search');
|
||||||
@ -344,7 +345,7 @@ export const actions: Actions = {
|
|||||||
id AS message_id;
|
id AS message_id;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// sendNewInquiryEmail(response[0].threadId);
|
await sendNewInquiryEmail(response[0].threadId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||||
@ -359,11 +360,17 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
let email: string;
|
||||||
let inquiry: string;
|
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
message: e instanceof Error ? e.message : 'Unknown error occurred',
|
||||||
|
|||||||
@ -83,7 +83,6 @@
|
|||||||
<FieldSeparator class="col-span-full text-lg h-8 my-2">
|
<FieldSeparator class="col-span-full text-lg h-8 my-2">
|
||||||
Pending items
|
Pending items
|
||||||
</FieldSeparator>
|
</FieldSeparator>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each data.items as item (item.id)}
|
{#each data.items as item (item.id)}
|
||||||
{#if item.approvedDate === null}
|
{#if item.approvedDate === null}
|
||||||
@ -91,6 +90,7 @@
|
|||||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.user && data.items.some(
|
{#if data.user && data.items.some(
|
||||||
(item) => item.approvedDate !== null && item.claimedDate === null
|
(item) => item.approvedDate !== null && item.claimedDate === null
|
||||||
@ -101,7 +101,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each data.items as item (item.id)}
|
{#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}
|
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
|
||||||
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import type { Actions, PageServerLoad } from '$types';
|
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 jwt from 'jsonwebtoken';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import sql from '$lib/db/db.server';
|
import sql from '$lib/db/db.server';
|
||||||
import { getRequiredFormString } from '$lib/shared';
|
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 }) => {
|
export const load: PageServerLoad = async ({ url, locals, params }) => {
|
||||||
const token: string | undefined = url.searchParams.get('token');
|
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;
|
const inquiryId: string = params.inquiryId;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
try {
|
||||||
jwt.verify(token, process.env.JWT_SECRET!);
|
jwt.verify(token, process.env.JWT_SECRET!);
|
||||||
|
} catch {
|
||||||
|
throw error(403, 'Your response token does not match this inquiry!');
|
||||||
|
}
|
||||||
} else if (!locals || !locals.user) {
|
} else if (!locals || !locals.user) {
|
||||||
throw error(
|
throw error(
|
||||||
403,
|
403,
|
||||||
@ -19,11 +25,32 @@ export const load: PageServerLoad = async ({ url, locals, params }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages: Message[] = await sql`
|
const [item]: Item[] = await sql`
|
||||||
SELECT * FROM inquiry_messages WHERE thread_id = ${inquiryId}
|
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 = {
|
export const actions: Actions = {
|
||||||
@ -37,13 +64,15 @@ export const actions: Actions = {
|
|||||||
if (locals && locals.user) {
|
if (locals && locals.user) {
|
||||||
sender = Sender.ADMIN;
|
sender = Sender.ADMIN;
|
||||||
} else if (token) {
|
} else if (token) {
|
||||||
const decoded: inquiryTokenPayload = jwt.verify(
|
let decoded: inquiryTokenPayload;
|
||||||
token,
|
try {
|
||||||
process.env.JWT_SECRET!
|
decoded = jwt.verify(token, process.env.JWT_SECRET!) as inquiryTokenPayload;
|
||||||
) as inquiryTokenPayload;
|
|
||||||
sender = decoded.sender;
|
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!');
|
throw error(403, 'Your response token does not match this inquiry!');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -64,6 +93,15 @@ export const actions: Actions = {
|
|||||||
RETURNING id;
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@ -4,6 +4,11 @@
|
|||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { type Message, Sender } from '$lib/types/inquiries';
|
import { type Message, Sender } from '$lib/types/inquiries';
|
||||||
import SendIcon from '@lucide/svelte/icons/send';
|
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();
|
let { data } = $props();
|
||||||
|
|
||||||
@ -33,10 +38,37 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto p-4">
|
<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">
|
<div class="relative">
|
||||||
<!-- Conversation -->
|
<!-- Conversation -->
|
||||||
<div class="flex flex-col gap-6 relative">
|
<div class="flex flex-col gap-6 relative">
|
||||||
{#each data.messages as msg, index (msg.id)}
|
{#if data.item.threads}
|
||||||
|
{#each data.item.threads[0].messages as msg (msg.id)}
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
@ -44,10 +76,6 @@
|
|||||||
class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}>
|
class={`flex items-center justify-center w-10 h-10 rounded-full text-white ${senderColor(msg.sender)}`}>
|
||||||
{senderLabel(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>
|
</div>
|
||||||
|
|
||||||
<!-- Message body -->
|
<!-- Message body -->
|
||||||
@ -57,11 +85,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input area -->
|
<!-- 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
|
<Textarea
|
||||||
placeholder="Write a message..."
|
placeholder="Write a message..."
|
||||||
class="resize-none"
|
class="resize-none"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user