Skip to content

Commit 0271ce0

Browse files
author
juan
committed
feat: added notifications and proposal integrations
1 parent 14cbad7 commit 0271ce0

File tree

13 files changed

+400
-48
lines changed

13 files changed

+400
-48
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Bell } from "lucide-react";
2+
import { useState, useEffect } from "react";
3+
import { useQuery, useMutation } from "convex/react";
4+
import { api } from "../../../../packages/backend/convex/_generated/api.js";
5+
import { useNavigate } from "@tanstack/react-router";
6+
import { Button } from "@/components/ui/button";
7+
8+
export default function NotificationsBell({ className }: { className?: string }) {
9+
const [open, setOpen] = useState(false);
10+
const [userId, setUserId] = useState<any>(null);
11+
12+
useEffect(() => {
13+
try {
14+
const u = JSON.parse(localStorage.getItem("user") || "null");
15+
setUserId(u?._id ?? null);
16+
} catch {
17+
setUserId(null);
18+
}
19+
}, []);
20+
21+
const notifications = useQuery(
22+
api.notifications.listByUser,
23+
userId ? { userId } : "skip"
24+
);
25+
26+
const markAsRead = useMutation(api.notifications.markAsRead);
27+
const navigate = useNavigate();
28+
29+
const unreadCount = (notifications || []).filter((n: any) => !n.read)
30+
.length;
31+
32+
const handleOpen = () => setOpen((s) => !s);
33+
34+
const handleClickNotification = async (n: any) => {
35+
try {
36+
if (!n.read) {
37+
await markAsRead({ id: n._id });
38+
}
39+
setOpen(false);
40+
if (n.url) {
41+
navigate({ to: n.url });
42+
}
43+
} catch (e) {
44+
console.error(e);
45+
}
46+
};
47+
48+
return (
49+
<div className={`relative ${className || ""}`}>
50+
<button
51+
onClick={handleOpen}
52+
className="relative p-2 rounded-full hover:bg-gray-100"
53+
title="Notificações"
54+
>
55+
<Bell className="w-6 h-6 text-[#7c6a5c]" />
56+
{unreadCount > 0 && (
57+
<span className="absolute -top-0 -right-0 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
58+
{unreadCount}
59+
</span>
60+
)}
61+
</button>
62+
63+
{open && (
64+
<div className="absolute right-0 mt-2 w-80 bg-white border rounded-lg shadow-lg z-50">
65+
<div className="p-2 border-b text-sm font-semibold">Notificações</div>
66+
<div className="max-h-64 overflow-y-auto">
67+
{(!notifications || notifications.length === 0) && (
68+
<div className="p-4 text-sm text-gray-600">Nenhuma notificação</div>
69+
)}
70+
{notifications && notifications.map((n: any) => (
71+
<button
72+
key={n._id}
73+
onClick={() => handleClickNotification(n)}
74+
className={`w-full text-left p-3 border-b hover:bg-gray-50 flex flex-col ${n.read ? "" : "bg-gray-50"}`}
75+
>
76+
<div className="flex items-center justify-between">
77+
<div className="font-medium text-sm">{n.title}</div>
78+
<div className="text-xs text-gray-400">{new Date(n.createdAt).toLocaleString()}</div>
79+
</div>
80+
<div className="text-xs text-gray-600 mt-1 truncate">{n.body}</div>
81+
</button>
82+
))}
83+
</div>
84+
<div className="p-2 text-right">
85+
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
86+
Fechar
87+
</Button>
88+
</div>
89+
</div>
90+
)}
91+
</div>
92+
);
93+
}

codigo-fonte/cultivo/apps/web/src/components/header-navigation.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LogIn, LogOut } from "lucide-react";
33
import { useEffect, useState } from "react";
44
import InstallPWAButton from "@/components/InstallPWAButton";
55
import logo from "@/assets/logo.png";
6+
import NotificationsBell from "@/components/NotificationsBell";
67

78
const getMenuItemsByUserType = (type?: string) => {
89
if (!type) {
@@ -91,6 +92,9 @@ export default function HeaderNavigation() {
9192
<div className="flex items-center gap-3">
9293
{sessionUser ? (
9394
<>
95+
<div className="mr-2">
96+
<NotificationsBell />
97+
</div>
9498
<span className="max-md:hidden">
9599
Olá, <strong>{sessionUser.name ?? sessionUser.email}</strong>
96100
</span>

codigo-fonte/cultivo/apps/web/src/routeTree.gen.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Route as GroupsNewRouteImport } from './routes/groups/new'
3232
import { Route as GroupsJoinRouteImport } from './routes/groups/join'
3333
import { Route as GroupsGroupIdRouteImport } from './routes/groups/$groupId'
3434
import { Route as ClassifierIdRouteImport } from './routes/classifier/$id'
35+
import { Route as GroupsGroupIdProposeRouteImport } from './routes/groups/$groupId/propose'
3536

3637
const SignupRoute = SignupRouteImport.update({
3738
id: '/signup',
@@ -148,6 +149,11 @@ const ClassifierIdRoute = ClassifierIdRouteImport.update({
148149
path: '/classifier/$id',
149150
getParentRoute: () => rootRouteImport,
150151
} as any)
152+
const GroupsGroupIdProposeRoute = GroupsGroupIdProposeRouteImport.update({
153+
id: '/propose',
154+
path: '/propose',
155+
getParentRoute: () => GroupsGroupIdRoute,
156+
} as any)
151157

152158
export interface FileRoutesByFullPath {
153159
'/': typeof IndexRoute
@@ -160,7 +166,7 @@ export interface FileRoutesByFullPath {
160166
'/menu': typeof MenuRoute
161167
'/signup': typeof SignupRoute
162168
'/classifier/$id': typeof ClassifierIdRoute
163-
'/groups/$groupId': typeof GroupsGroupIdRoute
169+
'/groups/$groupId': typeof GroupsGroupIdRouteWithChildren
164170
'/groups/join': typeof GroupsJoinRoute
165171
'/groups/new': typeof GroupsNewRoute
166172
'/groups/owned': typeof GroupsOwnedRoute
@@ -173,6 +179,7 @@ export interface FileRoutesByFullPath {
173179
'/harvests': typeof HarvestsIndexRoute
174180
'/proposals': typeof ProposalsIndexRoute
175181
'/search-harvests': typeof SearchHarvestsIndexRoute
182+
'/groups/$groupId/propose': typeof GroupsGroupIdProposeRoute
176183
}
177184
export interface FileRoutesByTo {
178185
'/': typeof IndexRoute
@@ -185,7 +192,7 @@ export interface FileRoutesByTo {
185192
'/menu': typeof MenuRoute
186193
'/signup': typeof SignupRoute
187194
'/classifier/$id': typeof ClassifierIdRoute
188-
'/groups/$groupId': typeof GroupsGroupIdRoute
195+
'/groups/$groupId': typeof GroupsGroupIdRouteWithChildren
189196
'/groups/join': typeof GroupsJoinRoute
190197
'/groups/new': typeof GroupsNewRoute
191198
'/groups/owned': typeof GroupsOwnedRoute
@@ -198,6 +205,7 @@ export interface FileRoutesByTo {
198205
'/harvests': typeof HarvestsIndexRoute
199206
'/proposals': typeof ProposalsIndexRoute
200207
'/search-harvests': typeof SearchHarvestsIndexRoute
208+
'/groups/$groupId/propose': typeof GroupsGroupIdProposeRoute
201209
}
202210
export interface FileRoutesById {
203211
__root__: typeof rootRouteImport
@@ -211,7 +219,7 @@ export interface FileRoutesById {
211219
'/menu': typeof MenuRoute
212220
'/signup': typeof SignupRoute
213221
'/classifier/$id': typeof ClassifierIdRoute
214-
'/groups/$groupId': typeof GroupsGroupIdRoute
222+
'/groups/$groupId': typeof GroupsGroupIdRouteWithChildren
215223
'/groups/join': typeof GroupsJoinRoute
216224
'/groups/new': typeof GroupsNewRoute
217225
'/groups/owned': typeof GroupsOwnedRoute
@@ -224,6 +232,7 @@ export interface FileRoutesById {
224232
'/harvests/': typeof HarvestsIndexRoute
225233
'/proposals/': typeof ProposalsIndexRoute
226234
'/search-harvests/': typeof SearchHarvestsIndexRoute
235+
'/groups/$groupId/propose': typeof GroupsGroupIdProposeRoute
227236
}
228237
export interface FileRouteTypes {
229238
fileRoutesByFullPath: FileRoutesByFullPath
@@ -251,6 +260,7 @@ export interface FileRouteTypes {
251260
| '/harvests'
252261
| '/proposals'
253262
| '/search-harvests'
263+
| '/groups/$groupId/propose'
254264
fileRoutesByTo: FileRoutesByTo
255265
to:
256266
| '/'
@@ -276,6 +286,7 @@ export interface FileRouteTypes {
276286
| '/harvests'
277287
| '/proposals'
278288
| '/search-harvests'
289+
| '/groups/$groupId/propose'
279290
id:
280291
| '__root__'
281292
| '/'
@@ -301,6 +312,7 @@ export interface FileRouteTypes {
301312
| '/harvests/'
302313
| '/proposals/'
303314
| '/search-harvests/'
315+
| '/groups/$groupId/propose'
304316
fileRoutesById: FileRoutesById
305317
}
306318
export interface RootRouteChildren {
@@ -314,7 +326,7 @@ export interface RootRouteChildren {
314326
MenuRoute: typeof MenuRoute
315327
SignupRoute: typeof SignupRoute
316328
ClassifierIdRoute: typeof ClassifierIdRoute
317-
GroupsGroupIdRoute: typeof GroupsGroupIdRoute
329+
GroupsGroupIdRoute: typeof GroupsGroupIdRouteWithChildren
318330
GroupsJoinRoute: typeof GroupsJoinRoute
319331
GroupsNewRoute: typeof GroupsNewRoute
320332
GroupsOwnedRoute: typeof GroupsOwnedRoute
@@ -492,9 +504,28 @@ declare module '@tanstack/react-router' {
492504
preLoaderRoute: typeof ClassifierIdRouteImport
493505
parentRoute: typeof rootRouteImport
494506
}
507+
'/groups/$groupId/propose': {
508+
id: '/groups/$groupId/propose'
509+
path: '/propose'
510+
fullPath: '/groups/$groupId/propose'
511+
preLoaderRoute: typeof GroupsGroupIdProposeRouteImport
512+
parentRoute: typeof GroupsGroupIdRoute
513+
}
495514
}
496515
}
497516

517+
interface GroupsGroupIdRouteChildren {
518+
GroupsGroupIdProposeRoute: typeof GroupsGroupIdProposeRoute
519+
}
520+
521+
const GroupsGroupIdRouteChildren: GroupsGroupIdRouteChildren = {
522+
GroupsGroupIdProposeRoute: GroupsGroupIdProposeRoute,
523+
}
524+
525+
const GroupsGroupIdRouteWithChildren = GroupsGroupIdRoute._addFileChildren(
526+
GroupsGroupIdRouteChildren,
527+
)
528+
498529
const rootRouteChildren: RootRouteChildren = {
499530
IndexRoute: IndexRoute,
500531
AssistantRoute: AssistantRoute,
@@ -506,7 +537,7 @@ const rootRouteChildren: RootRouteChildren = {
506537
MenuRoute: MenuRoute,
507538
SignupRoute: SignupRoute,
508539
ClassifierIdRoute: ClassifierIdRoute,
509-
GroupsGroupIdRoute: GroupsGroupIdRoute,
540+
GroupsGroupIdRoute: GroupsGroupIdRouteWithChildren,
510541
GroupsJoinRoute: GroupsJoinRoute,
511542
GroupsNewRoute: GroupsNewRoute,
512543
GroupsOwnedRoute: GroupsOwnedRoute,

codigo-fonte/cultivo/apps/web/src/routes/groups/$groupId.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ function GroupDetails() {
3030
const user = localStorage.getItem("user");
3131
return user ? JSON.parse(user)._id : null;
3232
}, []);
33+
// full current user object for role checks
34+
const currentUser = useMemo(() => {
35+
if (typeof window === "undefined") return null;
36+
const user = localStorage.getItem("user");
37+
return user ? JSON.parse(user) : null;
38+
}, []);
3339
const isOwner = useMemo(
3440
() => userId && group && String(group.createdBy) === String(userId),
3541
[userId, group]
@@ -40,6 +46,21 @@ function GroupDetails() {
4046
[group]
4147
);
4248

49+
// If current user is a representative, fetch proposals they've sent to check duplicates
50+
const sentProposals = useQuery(
51+
api.proposals.getSentProposals,
52+
currentUser?.tipo_usuario === "Representante" && currentUser?._id
53+
? { buyerId: currentUser._id as Id<"users"> }
54+
: "skip"
55+
);
56+
57+
const canPropose = currentUser?.tipo_usuario === "Representante";
58+
const alreadyProposed = Boolean(
59+
sentProposals &&
60+
group &&
61+
sentProposals.some((p: any) => String(p.group?._id ?? p.groupId) === String(group._id))
62+
);
63+
4364
async function handleRemoveParticipant(
4465
participantId: string,
4566
participantName: string
@@ -209,6 +230,19 @@ function GroupDetails() {
209230
{inviteCopied ? "✓ Link copiado!" : "📋 Convidar"}
210231
</Button>
211232
</Card>
233+
<div className="mt-3">
234+
<Button
235+
onClick={() => navigate({ to: `/groups/${groupId}/propose` })}
236+
className="w-full bg-green-600 text-white font-semibold hover:bg-green-700"
237+
disabled={!canPropose || alreadyProposed}
238+
>
239+
{alreadyProposed
240+
? "Proposta Enviada"
241+
: !canPropose
242+
? "Apenas representantes podem propor"
243+
: "Propor Compra"}
244+
</Button>
245+
</div>
212246
{!isOwner && (
213247
<Button
214248
variant="outline"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
2+
import { useEffect } from "react";
3+
4+
export const Route = createFileRoute("/groups/$groupId/propose")({
5+
component: ProposeRedirect,
6+
});
7+
8+
function ProposeRedirect() {
9+
const navigate = useNavigate();
10+
const { groupId } = Route.useParams();
11+
12+
useEffect(() => {
13+
if (!groupId) return;
14+
// Redirect to the proposals creation page with groupId in search params
15+
navigate({ to: "/proposals/create", search: { groupId } });
16+
}, [groupId, navigate]);
17+
18+
return (
19+
<div className="min-h-screen flex items-center justify-center p-4 mt-16">
20+
<div className="text-center">
21+
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-[#62331B] border-r-transparent align-[-0.125em]"></div>
22+
<p className="mt-4 text-[#62331B] font-medium">Redirecionando para criar proposta...</p>
23+
</div>
24+
</div>
25+
);
26+
}

codigo-fonte/cultivo/apps/web/src/routes/proposals/create.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
2525

2626
const searchSchema = z.object({
2727
harvestId: z.string().optional(),
28+
groupId: z.string().optional(),
2829
});
2930

3031
export const Route = createFileRoute("/proposals/create")({
@@ -49,7 +50,7 @@ type ProposalFormData = z.infer<typeof proposalSchema>;
4950

5051
function CreateProposalPage() {
5152
const navigate = useNavigate();
52-
const { harvestId } = Route.useSearch();
53+
const { harvestId, groupId } = Route.useSearch();
5354
const convex = useConvex();
5455
const [selectedHarvest, setSelectedHarvest] = useState<any>(null);
5556
const [selectedGroup, setSelectedGroup] = useState<any>(null);
@@ -108,6 +109,17 @@ function CreateProposalPage() {
108109
}
109110
}, [harvestId, harvests]);
110111

112+
// Pré-selecionar grupo se groupId foi passado na URL
113+
useEffect(() => {
114+
if (groupId && groups) {
115+
const group = groups.find((g) => g._id === groupId);
116+
if (group) {
117+
setSelectedGroup(group);
118+
setValue("recipientType", "group");
119+
}
120+
}
121+
}, [groupId, groups, setValue]);
122+
111123
// Buscar URL da imagem quando a colheita selecionada mudar
112124
useEffect(() => {
113125
const fetchImageUrl = async () => {

0 commit comments

Comments
 (0)