feat: messages

This commit is contained in:
Nevo David 2024-05-06 00:02:50 +07:00
parent e90b8e8389
commit b21cf8995e
23 changed files with 1090 additions and 48 deletions

View File

@ -21,6 +21,7 @@ import { CommentsController } from '@gitroom/backend/api/routes/comments.control
import { BillingController } from '@gitroom/backend/api/routes/billing.controller';
import { NotificationsController } from '@gitroom/backend/api/routes/notifications.controller';
import { MarketplaceController } from '@gitroom/backend/api/routes/marketplace.controller';
import { MessagesController } from '@gitroom/backend/api/routes/messages.controller';
const authenticatedController = [
UsersController,
@ -32,7 +33,8 @@ const authenticatedController = [
CommentsController,
BillingController,
NotificationsController,
MarketplaceController
MarketplaceController,
MessagesController
];
@Module({
imports: [

View File

@ -9,6 +9,9 @@ import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/us
import { ChangeActiveDto } from '@gitroom/nestjs-libraries/dtos/marketplace/change.active.dto';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { AudienceDto } from '@gitroom/nestjs-libraries/dtos/marketplace/audience.dto';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
@ApiTags('Marketplace')
@Controller('/marketplace')
@ -16,7 +19,8 @@ export class MarketplaceController {
constructor(
private _itemUserService: ItemUserService,
private _stripeService: StripeService,
private _userService: UsersService
private _userService: UsersService,
private _messagesService: MessagesService
) {}
@Post('/')
@ -25,7 +29,19 @@ export class MarketplaceController {
@GetUserFromRequest() user: User,
@Body() body: ItemsDto
) {
return this._userService.getMarketplacePeople(organization.id, user.id, body);
return this._userService.getMarketplacePeople(
organization.id,
user.id,
body
);
}
@Post('/conversation')
createConversation(
@GetUserFromRequest() user: User,
@Body() body: NewConversationDto
) {
return this._messagesService.createConversation(user.id, body);
}
@Get('/bank')
@ -49,6 +65,14 @@ export class MarketplaceController {
await this._userService.changeMarketplaceActive(user.id, body.active);
}
@Post('/audience')
async changeAudience(
@GetUserFromRequest() user: User,
@Body() body: AudienceDto
) {
await this._userService.changeAudienceSize(user.id, body.audience);
}
@Get('/item')
async getItems(@GetUserFromRequest() user: User) {
return this._itemUserService.getItems(user.id);
@ -56,12 +80,15 @@ export class MarketplaceController {
@Get('/account')
async getAccount(@GetUserFromRequest() user: User) {
const { account, marketplace, connectedAccount } =
const { account, marketplace, connectedAccount, name, picture, audience } =
await this._userService.getUserByEmail(user.email);
return {
account,
marketplace,
connectedAccount,
fullname: name,
audience,
picture,
};
}
}

View File

@ -0,0 +1,34 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { User } from '@prisma/client';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
@ApiTags('Messages')
@Controller('/messages')
export class MessagesController {
constructor(private _messagesService: MessagesService) {}
@Get('/')
getMessagesGroup(@GetUserFromRequest() user: User) {
return this._messagesService.getMessagesGroup(user.id);
}
@Get('/:groupId/:page')
getMessages(
@GetUserFromRequest() user: User,
@Param('groupId') groupId: string,
@Param('page') page: string
) {
return this._messagesService.getMessages(user.id, groupId, +page);
}
@Post('/:groupId')
createMessage(
@GetUserFromRequest() user: User,
@Param('groupId') groupId: string,
@Body() message: AddMessageDto
) {
return this._messagesService.createMessage(user.id, groupId, message);
}
}

View File

@ -24,6 +24,7 @@ import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { ApiTags } from '@nestjs/swagger';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
@ApiTags('User')
@Controller('/user')
@ -57,13 +58,18 @@ export class UsersController {
}
@Get('/personal')
async getPersonal(
@GetUserFromRequest() user: User,
) {
async getPersonal(@GetUserFromRequest() user: User) {
return this._userService.getPersonal(user.id);
}
@Post('/personal')
async changePersonal(
@GetUserFromRequest() user: User,
@Body() body: UserDetailDto
) {
return this._userService.changePersonal(user.id, body);
}
@Get('/subscription')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getSubscription(@GetOrgFromRequest() organization: Organization) {

View File

@ -0,0 +1,14 @@
import { Messages } from '@gitroom/frontend/components/messages/messages';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Messages',
description: '',
};
export default async function Index() {
return <Messages />;
}

View File

@ -0,0 +1,9 @@
import { Layout } from '@gitroom/frontend/components/messages/layout';
export const dynamic = 'force-dynamic';
import { ReactNode } from 'react';
export default async function LayoutWrapper({children}: {children: ReactNode}) {
return (
<Layout renderChildren={children} />
);
}

View File

@ -0,0 +1,14 @@
export const dynamic = 'force-dynamic';
import {Metadata} from "next";
export const metadata: Metadata = {
title: 'Gitroom Messages',
description: '',
}
export default async function Index() {
return (
<div>asd</div>
);
}

View File

@ -1,21 +1,32 @@
import { useModals } from '@mantine/modals';
import React, { FC, useCallback, useEffect } from 'react';
import React, { FC, useCallback, useEffect, useMemo } from 'react';
import { Input } from '@gitroom/react/form/input';
import { Button } from '@gitroom/react/form/button';
import { Textarea } from '@gitroom/react/form/textarea';
import { FormProvider, useForm } from 'react-hook-form';
import { showMediaBox } from '@gitroom/frontend/components/media/media.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useSWRConfig } from 'swr';
const SettingsPopup: FC = () => {
const fetch = useFetch();
const form = useForm({});
const toast = useToaster();
const swr = useSWRConfig();
const resolver = useMemo(() => {
return classValidatorResolver(UserDetailDto);
}, []);
const form = useForm({ resolver });
const picture = form.watch('picture');
const modal = useModals();
const close = useCallback(() => {
return modal.closeAll();
}, []);
const loadProfile = useCallback( async() => {
const loadProfile = useCallback(async () => {
const personal = await (await fetch('/user/personal')).json();
form.setValue('fullname', personal.name || '');
form.setValue('bio', personal.bio || '');
@ -24,12 +35,22 @@ const SettingsPopup: FC = () => {
const openMedia = useCallback(() => {
showMediaBox((values) => {
console.log(values);
})
form.setValue('picture', values);
});
}, []);
const submit = useCallback((val: any) => {
console.log(val);
const remove = useCallback(() => {
form.setValue('picture', null);
}, []);
const submit = useCallback(async (val: any) => {
await fetch('/user/personal', {
method: 'POST',
body: JSON.stringify(val),
});
toast.show('Profile updated');
swr.mutate('/marketplace/account');
close();
}, []);
useEffect(() => {
@ -73,11 +94,19 @@ const SettingsPopup: FC = () => {
<Input label="Full Name" name="fullname" />
</div>
<div className="flex gap-[8px] mb-[10px]">
<div className="w-[48px] h-[48px] rounded-full bg-[#D9D9D9]"></div>
<div className="w-[48px] h-[48px] rounded-full bg-[#D9D9D9]">
{!!picture?.path && (
<img
src={picture?.path}
alt="profile"
className="w-full h-full rounded-full"
/>
)}
</div>
<div className="flex flex-col gap-[2px]">
<div className="text-[14px]">Profile Picture</div>
<div className="flex gap-[8px]">
<button className="h-[24px] w-[120px] bg-[#612AD5] rounded-[4px] flex justify-center gap-[4px] items-center cursor-pointer">
<button className="h-[24px] w-[120px] bg-[#612AD5] rounded-[4px] flex justify-center gap-[4px] items-center cursor-pointer" type="button">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -92,9 +121,11 @@ const SettingsPopup: FC = () => {
/>
</svg>
</div>
<div className="text-[12px]" onClick={openMedia}>Upload image</div>
<div className="text-[12px]" onClick={openMedia}>
Upload image
</div>
</button>
<button className="h-[24px] w-[88px] rounded-[4px] border-2 border-[#506490] flex justify-center items-center gap-[4px]">
<button className="h-[24px] w-[88px] rounded-[4px] border-2 border-[#506490] flex justify-center items-center gap-[4px]" type="button">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -109,7 +140,9 @@ const SettingsPopup: FC = () => {
/>
</svg>
</div>
<div className="text-[12px]">Remove</div>
<div className="text-[12px]" onClick={remove}>
Remove
</div>
</button>
</div>
</div>
@ -148,7 +181,7 @@ export const SettingsComponent = () => {
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="cursor-pointer"
className="cursor-pointer relative z-[200]"
onClick={openModal}
>
<path

View File

@ -34,6 +34,11 @@ export const menuItems = [
icon: 'marketplace',
path: '/marketplace',
},
{
name: 'Messages',
icon: 'messages',
path: '/messages',
},
];
export const TopMenu: FC = () => {
@ -64,7 +69,7 @@ export const TopMenu: FC = () => {
}
return true;
})
.map((p) => path.indexOf(p.path) > -1 ? index : -1)
.map((p) => (path.indexOf(p.path) > -1 ? index : -1))
.indexOf(index) === index
? 'text-primary showbox'
: 'text-gray'

View File

@ -1,6 +1,6 @@
'use client';
import {
import React, {
FC,
Fragment,
useCallback,
@ -21,6 +21,12 @@ import {
import { chunk, fill } from 'lodash';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useModals } from '@mantine/modals';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Textarea } from '@gitroom/react/form/textarea';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
export interface Root {
list: List[];
@ -28,7 +34,14 @@ export interface Root {
}
export interface List {
id: string;
name: any;
bio: string;
audience: number;
picture: {
id: string;
path: string;
};
organizations: Organization[];
items: Item[];
}
@ -86,6 +99,100 @@ export const LabelCheckbox: FC<{
);
};
const Pagination: FC<{ results: number }> = (props) => {
const { results } = props;
const router = useRouter();
const search = useSearchParams();
const page = +(parseInt(search.get('page')!) || 1) - 1;
const from = page * 8;
const to = (page + 1) * 8;
const pagesArray = useMemo(() => {
return Array.from({ length: Math.ceil(results / 8) }, (_, i) => i + 1);
}, [results]);
const changePage = useCallback(
(newPage: number) => () => {
const params = new URLSearchParams(window.location.search);
params.set('page', String(newPage));
router.replace('?' + params.toString(), {
scroll: true,
});
},
[page]
);
if (results < 8) {
return null;
}
return (
<div className="flex items-center relative">
<div className="absolute left-0">
Showing {from + 1} to {to > results ? results : to} from {results}{' '}
Results
</div>
<div className="flex mx-auto">
{page > 0 && (
<div>
<svg
width="41"
height="40"
viewBox="0 0 41 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
onClick={changePage(page)}
>
<g clipPath="url(#clip0_703_22324)">
<path
d="M22.5 25L17.5 20L22.5 15"
stroke="#64748B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_703_22324">
<rect x="0.5" width="40" height="40" rx="8" fill="white" />
</clipPath>
</defs>
</svg>
</div>
)}
{pagesArray.map((p) => (
<div
key={p}
onClick={changePage(p)}
className={clsx(
'w-[40px] h-[40px] flex justify-center items-center rounded-[8px] cursor-pointer',
p === page + 1 ? 'bg-[#8155DD]' : 'text-[#64748B]'
)}
>
{p}
</div>
))}
{page + 1 < pagesArray[pagesArray.length - 1] && (
<svg
onClick={changePage(page + 2)}
width="41"
height="40"
viewBox="0 0 41 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.5 15L23.5 20L18.5 25"
stroke="#64748B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
);
};
export const Options: FC<{
title: string;
options: Array<{ key: string; value: string }>;
@ -174,10 +281,82 @@ export const Options: FC<{
);
};
export const RequestService: FC<{ toId: string; name: string }> = (props) => {
const { toId, name } = props;
const router = useRouter();
const fetch = useFetch();
const modal = useModals();
const resolver = useMemo(() => {
return classValidatorResolver(NewConversationDto);
}, []);
const form = useForm({ resolver, values: { to: toId, message: '' } });
const close = useCallback(() => {
return modal.closeAll();
}, []);
const createConversation: SubmitHandler<NewConversationDto> = useCallback(async (data) => {
const {id} = await (await fetch('/marketplace/conversation', {
method: 'POST',
body: JSON.stringify(data),
})).json();
close();
router.push(`/messages/${id}`);
}, []);
return (
<form onSubmit={form.handleSubmit(createConversation)}>
<FormProvider {...form}>
<div className="w-full max-w-[920px] mx-auto bg-[#0B101B] px-[16px] rounded-[4px] border border-[#172034] gap-[24px] flex flex-col relative">
<button
onClick={close}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="text-[18px] font-[500] flex flex-col">
<TopTitle title={`Send a message to ${name}`} />
<Textarea
placeholder="Add a message like: I'm intrested in 3 posts for Linkedin... (min 50 chars)"
className="mt-[14px] resize-none h-[400px]"
name="message"
label=""
/>
<div className="flex justify-end">
<Button
disabled={!form.formState.isValid}
type="submit"
className="w-[144px] mb-[16px] rounded-[4px] text-[14px]"
>
Send Message
</Button>
</div>
</div>
</div>
</FormProvider>
</form>
);
};
export const Card: FC<{
data: List;
}> = (props) => {
const { data } = props;
const modal = useModals();
const tags = useMemo(() => {
return data.items
@ -187,6 +366,17 @@ export const Card: FC<{
});
}, [data]);
const requestService = useCallback(() => {
modal.openModal({
children: <RequestService toId={data.id} name={data.name || 'Noname'} />,
classNames: {
modal: 'bg-transparent text-white',
},
withCloseButton: false,
size: '100%',
});
}, []);
const identifier = useMemo(() => {
return [
...new Set(
@ -202,10 +392,12 @@ export const Card: FC<{
<div className="flex gap-[16px] flex-1">
<div>
<div className="h-[103px] w-[103px] bg-red-500/10 rounded-full relative">
<img
src="https://via.placeholder.com/103"
className="rounded-full w-full h-full"
/>
{data?.picture?.path && (
<img
src={data?.picture?.path}
className="rounded-full w-full h-full"
/>
)}
<div className="w-[80px] h-[28px] bg-[#8155DD] absolute bottom-0 left-[50%] -translate-x-[50%] rounded-[30px] flex gap-[4px] justify-center items-center">
<div>
<svg
@ -221,7 +413,7 @@ export const Card: FC<{
/>
</svg>
</div>
<div className="text-[14px]">22,6K</div>
<div className="text-[14px]">{data?.audience}</div>
</div>
</div>
</div>
@ -261,8 +453,7 @@ export const Card: FC<{
</div>
</div>
<div className="text-[18px] text-[#AAA] font-[400]">
Maecenas dignissim justo eget nulla rutrum molestie. Maecenas
lobortis sem dui,
{data.bio || 'No bio'}
</div>
<div
className={clsx(
@ -284,7 +475,7 @@ export const Card: FC<{
</div>
</div>
<div className="ml-[100px] items-center flex">
<Button>Request Service</Button>
<Button onClick={requestService}>Request Service</Button>
</div>
</div>
);
@ -304,13 +495,16 @@ export const Buyer = () => {
method: 'POST',
body: JSON.stringify({
items: services?.split(',').filter((f) => f) || [],
page,
page: page === 0 ? 1 : page,
}),
})
).json();
}, [services, page]);
useEffect(() => {
if (!services) {
return;
}
const params = new URLSearchParams(window.location.search);
params.set('page', '1');
router.replace('?' + params.toString());
@ -339,6 +533,7 @@ export const Buyer = () => {
{list?.list?.map((item, index) => (
<Card key={String(index)} data={item} />
))}
<Pagination results={list?.count || 0} />
</div>
</div>
);

View File

@ -4,9 +4,11 @@ import { Slider } from '@gitroom/react/form/slider';
import { Button } from '@gitroom/react/form/button';
import { tagsList } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
import { Options } from '@gitroom/frontend/components/marketplace/buyer';
import { useCallback, useEffect, useState } from 'react';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Input } from '@gitroom/react/form/input';
import { useDebouncedCallback } from 'use-debounce';
export const Seller = () => {
const fetch = useFetch();
@ -16,6 +18,7 @@ export const Seller = () => {
>([]);
const [connectedLoading, setConnectedLoading] = useState(false);
const [state, setState] = useState(true);
const [audience, setAudience] = useState<number>(0);
const accountInformation = useCallback(async () => {
const account = await (
@ -25,6 +28,7 @@ export const Seller = () => {
).json();
setState(account.marketplace);
setAudience(account.audience);
return account;
}, []);
@ -60,15 +64,36 @@ export const Seller = () => {
setLoading(false);
}, []);
const changeMarketplace = useCallback(async (value: string) => {
await fetch('/marketplace/active', {
method: 'POST',
body: JSON.stringify({
active: value === 'on',
}),
});
setState(!state);
}, [state]);
const changeAudienceBackend = useDebouncedCallback(
useCallback(async (aud: number) => {
fetch('/marketplace/audience', {
method: 'POST',
body: JSON.stringify({
audience: aud,
}),
});
}, []),
500
);
const changeAudience = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const num = String(+e.target.value.replace(/\D/g, '') || 0).slice(0, 8);
setAudience(+num);
changeAudienceBackend(+num);
}, []);
const changeMarketplace = useCallback(
async (value: string) => {
await fetch('/marketplace/active', {
method: 'POST',
body: JSON.stringify({
active: value === 'on',
}),
});
setState(!state);
},
[state]
);
const { data } = useSWR('/marketplace/account', accountInformation);
@ -85,11 +110,23 @@ export const Seller = () => {
<div className="w-[328px] flex flex-col gap-[16px]">
<h2 className="text-[20px]">Seller Mode</h2>
<div className="flex p-[24px] bg-sixth rounded-[4px] border border-[#172034] flex-col items-center gap-[16px]">
<div className="w-[64px] h-[64px] bg-[#D9D9D9] rounded-full" />
<div className="text-[24px]">John Smith</div>
<div className="w-[64px] h-[64px] bg-[#D9D9D9] rounded-full">
{!!data?.picture?.path && (
<img
className="w-full h-full rounded-full"
src={data?.picture?.path || ''}
alt="avatar"
/>
)}
</div>
<div className="text-[24px]">{data?.fullname || ''}</div>
{data?.connectedAccount && (
<div className="flex gap-[16px] items-center pb-[8px]">
<Slider fill={true} value={state ? 'on' : 'off'} onChange={changeMarketplace} />
<Slider
fill={true}
value={state ? 'on' : 'off'}
onChange={changeMarketplace}
/>
<div className="text-[18px]">Active</div>
</div>
)}
@ -119,6 +156,23 @@ export const Seller = () => {
title={tag.name}
/>
))}
<div className="h-[56px] text-[20px] font-[600] flex items-center px-[24px] bg-[#0F1524]">
Audience Size
</div>
<div className="bg-[#0b0f1c] flex px-[32px] py-[24px]">
<div className="flex-1">
<Input
label="Audience size on all platforms"
name="audience"
type="text"
pattern="\d*"
max={8}
disableForm={true}
value={audience}
onChange={changeAudience}
/>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,96 @@
'use client';
import { FC, ReactNode, useCallback } from 'react';
import clsx from 'clsx';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useParams, useRouter } from 'next/navigation';
export interface Root2 {
id: string;
buyerId: string;
sellerId: string;
createdAt: string;
updatedAt: string;
seller: Seller;
messages: Message[];
}
export interface Seller {
name: any;
picture: Picture;
}
export interface Picture {
id: string;
path: string;
}
export interface Message {
id: string;
from: string;
content: string;
groupId: string;
createdAt: string;
updatedAt: string;
deletedAt: any;
}
const Card: FC<{ message: Root2 }> = (props) => {
const { message } = props;
const path = useParams();
const router = useRouter();
const changeConversation = useCallback(() => {
router.push(`/messages/${message.id}`);
}, []);
return (
<div
onClick={changeConversation}
className={clsx(
'h-[89px] p-[24px] flex gap-[16px] rounded-[4px] cursor-pointer',
path?.id === message.id && 'bg-sixth border border-[#172034]'
)}
>
<div className="w-[40px] h-[40px] rounded-full bg-amber-200">
{message?.seller?.picture?.path && (
<img src={message.seller.picture.path} alt={message.seller.name || 'Noname'} className="w-full h-full rounded-full" />
)}
</div>
<div className="flex-1 relative">
<div className="absolute left-0 top-0 w-full h-full flex flex-col whitespace-nowrap">
<div>{message.seller.name || 'Noname'}</div>
<div className="text-[12px] w-full overflow-ellipsis overflow-hidden">
{message.messages[0]?.content}
</div>
</div>
</div>
<div className="text-[12px]">Mar 28</div>
</div>
);
};
export const Layout: FC<{ renderChildren: ReactNode }> = (props) => {
const { renderChildren } = props;
const fetch = useFetch();
const loadMessagesGroup = useCallback(async () => {
return await (await fetch('/messages')).json();
}, []);
const messagesGroup = useSWR<Root2[]>('messagesGroup', loadMessagesGroup);
return (
<div className="flex gap-[20px]">
<div className="pt-[7px] w-[330px] flex flex-col">
<div className="text-[20px] mb-[18px]">All Messages</div>
<div className="flex flex-col">
{messagesGroup.data?.map((message) => (
<Card key={message.id} message={message} />
))}
</div>
</div>
<div className="flex-1 flex flex-col">{renderChildren}</div>
</div>
);
};

View File

@ -0,0 +1,229 @@
'use client';
import dayjs from 'dayjs';
export interface Root {
id: string;
buyerId: string;
sellerId: string;
createdAt: string;
updatedAt: string;
seller: SellerBuyer;
buyer: SellerBuyer;
messages: Message[];
}
export interface SellerBuyer {
name?: string;
picture: Picture;
}
export interface Picture {
id: string;
path: string;
}
export interface Message {
id: string;
from: string;
content: string;
groupId: string;
createdAt: string;
updatedAt: string;
deletedAt: any;
}
import { Textarea } from '@gitroom/react/form/textarea';
import interClass from '@gitroom/react/helpers/inter.font';
import clsx from 'clsx';
import useSWR from 'swr';
import { FC, UIEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { reverse } from 'lodash';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
export const Message: FC<{
message: Message;
seller: SellerBuyer;
buyer: SellerBuyer;
scrollDown: () => void;
}> = (props) => {
const { message, seller, buyer, scrollDown } = props;
useEffect(() => {
scrollDown();
}, []);
const person = useMemo(() => {
return message.from === 'BUYER' ? buyer : seller;
}, [message]);
const isMe = useMemo(() => {
return message.from === 'BUYER';
}, []);
const time = useMemo(() => {
return dayjs(message.createdAt).format('h:mm A');
}, [message]);
return (
<div className="flex gap-[10px]">
<div>
<div className="w-[24px] h-[24px] rounded-full bg-amber-200">
{!!person.picture?.path && (
<img
src={person.picture.path}
alt="person"
className="w-[24px] h-[24px] rounded-full"
/>
)}
</div>
</div>
<div className="flex-1 flex flex-col max-w-[534px] gap-[10px]">
<div className="flex gap-[10px] items-center">
<div>{isMe ? 'Me' : person.name}</div>
<div className="w-[6px] h-[6px] bg-[#334155] rounded-full" />
<div className="text-[14px] text-inputText">{time}</div>
</div>
<pre
className={clsx(
'whitespace-pre-line font-[400] text-[12px]',
interClass
)}
>
{message.content}
</pre>
</div>
</div>
);
};
const Page: FC<{ page: number; group: string; refChange: any }> = (props) => {
const { page, group, refChange } = props;
const fetch = useFetch();
const loadMessages = useCallback(async () => {
return await (await fetch(`/messages/${group}/${page}`)).json();
}, []);
const { data, mutate } = useSWR<Root>(`load-${page}-${group}`, loadMessages);
const scrollDown = useCallback(() => {
if (page > 1) {
return ;
}
// @ts-ignore
refChange.current?.scrollTo(0, refChange.current.scrollHeight);
}, [refChange]);
const messages = useMemo(() => {
return reverse([...(data?.messages || [])]);
}, [data]);
return (
<>
{messages.map((message) => (
<Message
key={message.id}
message={message}
seller={data?.seller!}
buyer={data?.buyer!}
scrollDown={scrollDown}
/>
))}
</>
);
};
export const Messages = () => {
const [pages, setPages] = useState([makeId(3)]);
const params = useParams();
const fetch = useFetch();
const ref = useRef(null);
const resolver = useMemo(() => {
return classValidatorResolver(AddMessageDto);
}, []);
const form = useForm({ resolver, values: { message: '' } });
useEffect(() => {
setPages([makeId(3)]);
}, [params.id]);
const loadMessages = useCallback(async () => {
return await (await fetch(`/messages/${params.id}/1`)).json();
}, []);
const { data, mutate, isLoading } = useSWR<Root>(`load-1-${params.id}`, loadMessages);
const submit: SubmitHandler<AddMessageDto> = useCallback(async (values) => {
await fetch(`/messages/${params.id}`, {
method: 'POST',
body: JSON.stringify(values),
});
mutate();
form.reset();
}, []);
const changeScroll: UIEventHandler<HTMLDivElement> = useCallback((e) => {
// @ts-ignore
if (e.target.scrollTop === 0) {
// @ts-ignore
e.target.scrollTop = 1;
setPages((prev) => [...prev, makeId(3)]);
}
}, [pages, setPages]);
return (
<form onSubmit={form.handleSubmit(submit)}>
<FormProvider {...form}>
<div className="flex-1 flex flex-col rounded-[4px] border border-[#172034] bg-[#0b0f1c] pb-[16px]">
<div className="bg-[#0F1524] h-[64px] px-[24px] py-[16px] flex gap-[10px] items-center">
<div className="w-[32px] h-[32px] rounded-full bg-amber-200">
{!!data?.seller?.picture?.path && (
<img
src={data?.seller?.picture?.path}
alt="seller"
className="w-[32px] h-[32px] rounded-full"
/>
)}
</div>
<div className="text-[20px]">{data?.seller?.name || 'Noname'}</div>
</div>
<div className="flex-1 min-h-[658px] max-h-[658px] relative">
<div
className="pt-[18px] pb-[18px] absolute top-0 left-0 w-full h-full px-[24px] flex flex-col gap-[24px] overflow-x-hidden overflow-y-auto"
onScroll={changeScroll}
ref={ref}
>
{pages.map((p, index) => (
<Page key={'page_' + (pages.length - index)} refChange={ref} page={pages.length - index} group={params.id as string} />
))}
</div>
</div>
<div className="border-t border-t-[#658dac] p-[16px] flex flex-col">
<div>
<Textarea
className="!min-h-[100px] resize-none"
label=""
name="message"
/>
</div>
<div className="flex justify-end">
<button
className={clsx(
'rounded-[4px] border border-[#506490] h-[48px] px-[24px]',
!form.formState.isValid && 'opacity-40'
)}
disabled={!form.formState.isValid}
>
Send Message
</button>
</div>
</div>
</div>
</FormProvider>
</form>
);
};

View File

@ -22,6 +22,8 @@ import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/pris
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { ItemUserRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.repository';
import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.service';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository';
@Global()
@Module({
@ -44,11 +46,13 @@ import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marke
IntegrationRepository,
PostsService,
PostsRepository,
MessagesRepository,
MediaService,
MediaRepository,
CommentsRepository,
ItemUserRepository,
ItemUserService,
MessagesService,
CommentsService,
IntegrationManager,
EmailService,

View File

@ -0,0 +1,155 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { From } from '@prisma/client';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
@Injectable()
export class MessagesRepository {
constructor(
private _messagesGroup: PrismaRepository<'messagesGroup'>,
private _messages: PrismaRepository<'messages'>
) {}
async createConversation(userId: string, body: NewConversationDto) {
const { id } =
(await this._messagesGroup.model.messagesGroup.findFirst({
where: {
buyerId: userId,
sellerId: body.to,
},
})) ||
(await this._messagesGroup.model.messagesGroup.create({
data: {
buyerId: userId,
sellerId: body.to,
},
}));
await this._messagesGroup.model.messagesGroup.update({
where: {
id,
},
data: {
updatedAt: new Date(),
},
});
await this._messages.model.messages.create({
data: {
groupId: id,
from: From.BUYER,
content: body.message,
},
});
return { id };
}
async getMessagesGroup(userId: string) {
return this._messagesGroup.model.messagesGroup.findMany({
where: {
buyerId: userId,
},
orderBy: {
updatedAt: 'desc',
},
include: {
seller: {
select: {
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
messages: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
}
async createMessage(userId: string, groupId: string, body: AddMessageDto) {
const group = await this._messagesGroup.model.messagesGroup.findFirst({
where: {
id: groupId,
OR: [
{
buyerId: userId,
},
{
sellerId: userId,
},
],
},
});
if (!group) {
throw new Error('Group not found');
}
await this._messages.model.messages.create({
data: {
groupId,
from: group.buyerId === userId ? From.BUYER : From.SELLER,
content: body.message,
},
});
}
async getMessages(userId: string, groupId: string, page: number) {
return this._messagesGroup.model.messagesGroup.findFirst({
where: {
id: groupId,
OR: [
{
buyerId: userId,
},
{
sellerId: userId,
},
],
},
include: {
seller: {
select: {
id: true,
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
buyer: {
select: {
id: true,
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
messages: {
orderBy: {
createdAt: 'desc',
},
take: 10,
skip: (page - 1) * 10,
},
},
});
}
}

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
@Injectable()
export class MessagesService {
constructor(private _messagesRepository: MessagesRepository) {}
createConversation(userId: string, body: NewConversationDto) {
return this._messagesRepository.createConversation(userId, body);
}
getMessagesGroup(userId: string) {
return this._messagesRepository.getMessagesGroup(userId);
}
getMessages(userId: string, groupId: string, page: number) {
return this._messagesRepository.getMessages(userId, groupId, page);
}
createMessage(userId: string, groupId: string, body: AddMessageDto) {
return this._messagesRepository.createMessage(userId, groupId, body);
}
}

View File

@ -35,6 +35,7 @@ model User {
name String?
lastName String?
bio String?
audience Int @default(0)
pictureId String?
picture Media? @relation(fields: [pictureId], references: [id])
providerId String?
@ -48,7 +49,9 @@ model User {
items ItemUser[]
marketplace Boolean @default(true)
account String?
connectedAccount Boolean @default(false)
connectedAccount Boolean @default(false)
groupBuyer MessagesGroup[] @relation("groupBuyer")
groupSeller MessagesGroup[] @relation("groupSeller")
@@unique([email, providerName])
@@index([lastReadNotifications])
@ -250,6 +253,41 @@ model Notifications {
@@index([deletedAt])
}
model MessagesGroup {
id String @id @default(uuid())
buyerId String
buyer User @relation("groupBuyer", fields: [buyerId], references: [id])
sellerId String
seller User @relation("groupSeller", fields: [sellerId], references: [id])
messages Messages[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([buyerId, sellerId])
@@index([createdAt])
@@index([updatedAt])
}
model Messages {
id String @id @default(uuid())
from From
content String?
groupId String
group MessagesGroup @relation(fields: [groupId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([groupId])
@@index([createdAt])
@@index([deletedAt])
}
enum From {
BUYER
SELLER
}
enum State {
QUEUE
PUBLISHED

View File

@ -4,6 +4,8 @@ import { Provider } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { allTagsOptions } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
@Injectable()
export class UsersRepository {
@ -14,6 +16,14 @@ export class UsersRepository {
where: {
email,
},
include: {
picture: {
select: {
id: true,
path: true,
},
},
},
});
}
@ -38,6 +48,17 @@ export class UsersRepository {
});
}
changeAudienceSize(userId: string, audience: number) {
return this._user.model.user.update({
where: {
id: userId,
},
data: {
audience,
},
});
}
changeMarketplaceActive(userId: string, active: boolean) {
return this._user.model.user.update({
where: {
@ -70,6 +91,27 @@ export class UsersRepository {
return user;
}
async changePersonal(userId: string, body: UserDetailDto) {
await this._user.model.user.update({
where: {
id: userId,
},
data: {
name: body.fullname,
bio: body.bio,
picture: body.picture
? {
connect: {
id: body.picture.id,
},
}
: {
disconnect: true,
},
},
});
}
async getMarketplacePeople(orgId: string, userId: string, items: ItemsDto) {
const info = {
id: {
@ -100,7 +142,16 @@ export class UsersRepository {
...info,
},
select: {
id: true,
name: true,
bio: true,
audience: true,
picture: {
select: {
id: true,
path: true,
},
},
organizations: {
select: {
organization: {
@ -124,8 +175,8 @@ export class UsersRepository {
},
},
},
skip: (items.page - 1) * 10,
take: 10,
skip: (items.page - 1) * 8,
take: 8,
});
const count = await this._user.model.user.count({

View File

@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
import { UsersRepository } from '@gitroom/nestjs-libraries/database/prisma/users/users.repository';
import { Provider } from '@prisma/client';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
@Injectable()
export class UsersService {
@ -19,6 +21,10 @@ export class UsersService {
return this._usersRepository.updatePassword(id, password);
}
changeAudienceSize(userId: string, audience: number) {
return this._usersRepository.changeAudienceSize(userId, audience);
}
changeMarketplaceActive(userId: string, active: boolean) {
return this._usersRepository.changeMarketplaceActive(userId, active);
}
@ -30,4 +36,8 @@ export class UsersService {
getPersonal(userId: string) {
return this._usersRepository.getPersonal(userId);
}
changePersonal(userId: string, body: UserDetailDto) {
return this._usersRepository.changePersonal(userId, body);
}
}

View File

@ -0,0 +1,8 @@
import { IsNumber, Max, Min } from 'class-validator';
export class AudienceDto {
@IsNumber()
@Max(99999999)
@Min(1)
audience: number;
}

View File

@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class NewConversationDto {
@IsString()
to: string;
@IsString()
@MinLength(50)
message: string;
}

View File

@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class AddMessageDto {
@IsString()
@MinLength(3)
message: string;
}

View File

@ -0,0 +1,16 @@
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { IsOptional, IsString, MinLength, ValidateNested } from 'class-validator';
export class UserDetailDto {
@IsString()
@MinLength(3)
fullname: string;
@IsString()
@IsOptional()
bio: string;
@IsOptional()
@ValidateNested()
picture: MediaDto;
}