Skip to content

Commit d8e2b0a

Browse files
❤️
FEAT: added feedback button
1 parent b6d6ebf commit d8e2b0a

File tree

6 files changed

+239
-27
lines changed

6 files changed

+239
-27
lines changed

api/server/controllers/EnterpriseContactController.js

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ const {
88
} = require('~/models/EnterpriseContact');
99

1010
/**
11-
* Creates a new enterprise contact
11+
* Creates a new enterprise contact or general feedback
1212
*/
1313
const createEnterpriseContactController = async (req, res) => {
1414
try {
1515
const {
16+
feedbackType = 'enterprise',
1617
firstName,
1718
lastName,
1819
workEmail,
@@ -25,30 +26,47 @@ const createEnterpriseContactController = async (req, res) => {
2526
complianceNeeds = [],
2627
timeline,
2728
additionalInfo,
29+
conversationId,
30+
userId: bodyUserId,
2831
} = req.body;
2932

30-
// Validate required fields
31-
if (!firstName || !lastName || !workEmail) {
32-
return res.status(400).json({
33-
error: 'Missing required fields: firstName, lastName, and workEmail are required',
34-
});
33+
// Get user info from authenticated user if available, fallback to body
34+
const userId = req.user?._id?.toString() || req.user?.id?.toString() || bodyUserId;
35+
36+
// Validate required fields based on feedback type
37+
if (feedbackType === 'enterprise') {
38+
if (!firstName || !lastName || !workEmail) {
39+
return res.status(400).json({
40+
error:
41+
'Missing required fields: firstName, lastName, and workEmail are required for enterprise contacts',
42+
});
43+
}
44+
} else if (feedbackType === 'general') {
45+
if (!additionalInfo || additionalInfo.trim().length === 0) {
46+
return res.status(400).json({
47+
error: 'Feedback content is required',
48+
});
49+
}
3550
}
3651

37-
// Check for existing contact with same email
38-
const existingContact = await getEnterpriseContactById(workEmail);
39-
if (existingContact) {
40-
logger.info(`Duplicate enterprise contact submission for email: ${workEmail}`);
41-
// Return success to avoid exposing existing contacts, but don't create duplicate
42-
return res.status(200).json({
43-
message: 'Contact submission received successfully',
44-
contactId: existingContact.contactId,
45-
});
52+
// Check for existing contact with same email (only for enterprise contacts)
53+
if (feedbackType === 'enterprise' && workEmail) {
54+
const existingContact = await getEnterpriseContactById(workEmail);
55+
if (existingContact) {
56+
logger.info(`Duplicate enterprise contact submission for email: ${workEmail}`);
57+
// Return success to avoid exposing existing contacts, but don't create duplicate
58+
return res.status(200).json({
59+
message: 'Contact submission received successfully',
60+
contactId: existingContact.contactId,
61+
});
62+
}
4663
}
4764

4865
const contactData = {
49-
firstName: firstName.trim(),
50-
lastName: lastName.trim(),
51-
workEmail: workEmail.trim().toLowerCase(),
66+
feedbackType,
67+
firstName: firstName?.trim(),
68+
lastName: lastName?.trim(),
69+
workEmail: workEmail?.trim().toLowerCase(),
5270
phoneNumber: phoneNumber?.trim(),
5371
companyWebsite: companyWebsite?.trim(),
5472
problemToSolve: problemToSolve?.trim(),
@@ -58,14 +76,21 @@ const createEnterpriseContactController = async (req, res) => {
5876
complianceNeeds: Array.isArray(complianceNeeds) ? complianceNeeds : [],
5977
timeline,
6078
additionalInfo: additionalInfo?.trim(),
79+
userId: userId?.toString(),
80+
conversationId: conversationId?.trim(),
6181
};
6282

6383
const contact = await createEnterpriseContact(contactData);
6484

65-
logger.info(`New enterprise contact created: ${contact.contactId} - ${workEmail}`);
85+
logger.info(
86+
`New ${feedbackType} contact created: ${contact.contactId} - ${workEmail || userId}`,
87+
);
6688

6789
res.status(201).json({
68-
message: 'Contact submission received successfully',
90+
message:
91+
feedbackType === 'general'
92+
? 'Feedback submitted successfully'
93+
: 'Contact submission received successfully',
6994
contactId: contact.contactId,
7095
});
7196
} catch (error) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useState } from 'react';
2+
import { useLocalize, useMediaQuery } from '~/hooks';
3+
import FeedbackModal from './FeedbackModal';
4+
5+
interface FeedbackButtonProps {
6+
conversationId?: string;
7+
}
8+
9+
export default function FeedbackButton({ conversationId }: FeedbackButtonProps) {
10+
const localize = useLocalize();
11+
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
12+
const isSmallScreen = useMediaQuery('(max-width: 768px)');
13+
14+
// Only show button if we have a valid conversation
15+
if (!conversationId || conversationId === 'new' || conversationId === 'search') {
16+
return null;
17+
}
18+
19+
return (
20+
<>
21+
<button
22+
onClick={() => setShowFeedbackModal(true)}
23+
className={`inline-flex flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 ${
24+
isSmallScreen ? 'size-10' : 'h-10 px-3'
25+
}`}
26+
aria-label="Send feedback"
27+
>
28+
<span className={isSmallScreen ? '' : 'mr-1'}>❤️</span>
29+
{!isSmallScreen && <span className="text-sm font-medium">FEEDBACK</span>}
30+
</button>
31+
<FeedbackModal
32+
open={showFeedbackModal}
33+
onOpenChange={setShowFeedbackModal}
34+
conversationId={conversationId}
35+
/>
36+
</>
37+
);
38+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useState } from 'react';
2+
import { useAuthContext } from '~/hooks';
3+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
4+
import { useToastContext } from '~/Providers';
5+
6+
interface FeedbackModalProps {
7+
open: boolean;
8+
onOpenChange: (open: boolean) => void;
9+
conversationId: string;
10+
}
11+
12+
export default function FeedbackModal({ open, onOpenChange, conversationId }: FeedbackModalProps) {
13+
const { user } = useAuthContext();
14+
const { showToast } = useToastContext();
15+
const [feedback, setFeedback] = useState('');
16+
const [isSubmitting, setIsSubmitting] = useState(false);
17+
18+
const handleSubmit = async (e: React.FormEvent) => {
19+
e.preventDefault();
20+
21+
if (!feedback.trim()) {
22+
showToast({
23+
message: 'Please enter your feedback',
24+
status: 'error',
25+
});
26+
return;
27+
}
28+
29+
setIsSubmitting(true);
30+
31+
try {
32+
const response = await fetch('/api/enterprise-contact', {
33+
method: 'POST',
34+
headers: {
35+
'Content-Type': 'application/json',
36+
},
37+
credentials: 'include',
38+
body: JSON.stringify({
39+
feedbackType: 'general',
40+
additionalInfo: feedback,
41+
conversationId,
42+
userId: user?.id,
43+
}),
44+
});
45+
46+
if (!response.ok) {
47+
const errorData = await response.json();
48+
throw new Error(errorData.error || 'Failed to submit feedback');
49+
}
50+
51+
showToast({
52+
message: 'Thank you for your feedback!',
53+
status: 'success',
54+
});
55+
56+
setFeedback('');
57+
onOpenChange(false);
58+
} catch (error) {
59+
console.error('Error submitting feedback:', error);
60+
showToast({
61+
message: error instanceof Error ? error.message : 'Failed to submit feedback',
62+
status: 'error',
63+
});
64+
} finally {
65+
setIsSubmitting(false);
66+
}
67+
};
68+
69+
const handleClose = () => {
70+
if (!isSubmitting) {
71+
setFeedback('');
72+
onOpenChange(false);
73+
}
74+
};
75+
76+
return (
77+
<Dialog open={open} onOpenChange={handleClose}>
78+
<DialogContent className="max-w-md">
79+
<DialogHeader className="px-6 pt-6">
80+
<DialogTitle>Send Feedback</DialogTitle>
81+
</DialogHeader>
82+
<form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4">
83+
<div>
84+
<label htmlFor="feedback" className="mb-2 block text-sm font-medium text-text-primary">
85+
🔧 Your feedback will help us improve the product.
86+
</label>
87+
<textarea
88+
id="feedback"
89+
value={feedback}
90+
onChange={(e) => setFeedback(e.target.value)}
91+
placeholder="Tell us what you think..."
92+
rows={5}
93+
className="w-full resize-none rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-text-primary placeholder-text-secondary focus:border-border-heavy focus:outline-none focus:ring-1 focus:ring-border-heavy"
94+
disabled={isSubmitting}
95+
/>
96+
</div>
97+
<div className="flex justify-center pt-4">
98+
<button
99+
type="submit"
100+
disabled={isSubmitting || !feedback.trim()}
101+
className="btn btn-primary"
102+
>
103+
{isSubmitting ? 'Submitting...' : 'Submit Feedback'}
104+
</button>
105+
</div>
106+
</form>
107+
</DialogContent>
108+
</Dialog>
109+
);
110+
}

client/src/components/Chat/Header.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Permissions,
77
EModelEndpoint,
88
} from 'librechat-data-provider';
9+
import { useRecoilValue } from 'recoil';
910
import type { ContextType } from '~/common';
1011
import ModelSelector from './Menus/Endpoints/ModelSelector';
1112
import { PresetsMenu, HeaderNewChat, OpenSidebar, IntegrationsButton } from './Menus';
@@ -17,7 +18,9 @@ import BookmarkMenu from './Menus/BookmarkMenu';
1718
import { TemporaryChat } from './TemporaryChat';
1819
import AddMultiConvo from './AddMultiConvo';
1920
import HeaderAgentSelect from './HeaderAgentSelect';
21+
import FeedbackButton from './FeedbackButton';
2022
import { mapEndpoints } from '~/utils';
23+
import store from '~/store';
2124

2225
const defaultInterface = getConfigDefaults().interface;
2326

@@ -28,6 +31,7 @@ export default function Header() {
2831
const assistantsMap = useAssistantsMapContext();
2932
const { data: endpointsConfig } = useGetEndpointsQuery();
3033
const { data: endpoints = [] } = useGetEndpointsQuery({ select: mapEndpoints });
34+
const conversation = useRecoilValue(store.conversationByIndex(0));
3135

3236
const interfaceConfig = useMemo(
3337
() => startupConfig?.interface ?? defaultInterface,
@@ -102,6 +106,7 @@ export default function Header() {
102106
{hasAccessToMultiConvo === true && <AddMultiConvo />}
103107
{isSmallScreen && (
104108
<>
109+
<FeedbackButton conversationId={conversation?.conversationId} />
105110
<ExportAndShareMenu
106111
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
107112
/>
@@ -111,6 +116,7 @@ export default function Header() {
111116
</div>
112117
{!isSmallScreen && (
113118
<div className="flex items-center gap-2">
119+
<FeedbackButton conversationId={conversation?.conversationId} />
114120
<ExportAndShareMenu
115121
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
116122
/>

client/src/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export { default as ThemeSelector } from './ThemeSelector';
4141
export { default as SelectDropDown } from './SelectDropDown';
4242
export { default as MultiSelectPop } from './MultiSelectPop';
4343
export { default as ModelParameters } from './ModelParameters';
44+
export { default as DialogTemplate } from './DialogTemplate';
4445
export { default as OGDialogTemplate } from './OGDialogTemplate';
4546
export { default as InputWithDropdown } from './InputWithDropDown';
4647
export { default as SelectDropDownPop } from './SelectDropDownPop';

0 commit comments

Comments
 (0)