diff --git a/packages/shared/src/components/modals/SentimentPopupModal.tsx b/packages/shared/src/components/modals/SentimentPopupModal.tsx new file mode 100644 index 0000000000..566a7f161b --- /dev/null +++ b/packages/shared/src/components/modals/SentimentPopupModal.tsx @@ -0,0 +1,188 @@ +import type { ReactElement } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import classNames from 'classnames'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { ModalSize } from './common/types'; +import { submitFeedSentiment } from '../../graphql/feedSentiment'; +import type { FeedSentiment } from '../../graphql/feedSentiment'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; + +const emojis = [ + { emoji: '😊', sentiment: 'good' as const, label: 'Happy' }, + { emoji: '😐', sentiment: 'neutral' as const, label: 'Neutral' }, + { emoji: '😞', sentiment: 'bad' as const, label: 'Unhappy' }, +]; + +// Fisher-Yates shuffle +const shuffleArray = (array: T[]): T[] => { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +}; + +const SentimentPopupModal = ({ + onRequestClose, + ...props +}: ModalProps): ReactElement => { + const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); + const [selectedSentiment, setSelectedSentiment] = + useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const shuffledEmojis = useMemo(() => shuffleArray(emojis), []); + + useEffect(() => { + logEvent({ + event_name: LogEvent.OpenFeedSentiment, + target_type: TargetType.FeedSentiment, + }); + }, [logEvent]); + + const { mutate: submitMutation, isPending } = useMutation({ + mutationFn: (sentiment: FeedSentiment) => submitFeedSentiment(sentiment), + onSuccess: () => { + setShowSuccess(true); + setTimeout(() => { + onRequestClose?.(null); + }, 1500); + }, + onError: () => { + displayToast('Failed to submit feedback. Please try again.'); + setIsSelecting(false); + }, + }); + + const handleEmojiClick = useCallback( + (sentiment: FeedSentiment) => { + if (isPending || isSelecting) return; + + setSelectedSentiment(sentiment); + setIsSelecting(true); + + logEvent({ + event_name: LogEvent.SubmitFeedSentiment, + target_type: TargetType.FeedSentiment, + extra: JSON.stringify({ sentiment }), + }); + + submitMutation(sentiment); + }, + [isPending, isSelecting, logEvent, submitMutation], + ); + + return ( + +
+ {/* Question */} +

+ How happy with the feed are you? +

+ + {/* Emoji options */} +
+ {shuffledEmojis.map((item, index) => ( + + ))} +
+ + {/* Success message */} + {showSuccess && ( +
+
+
+ Thanks for your feedback! +
+
+ )} +
+
+ ); +}; + +export default SentimentPopupModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 29edd4518e..bb4712f1ee 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -427,6 +427,13 @@ const AchievementSyncPromptModal = dynamic( ), ); +const FeedSentimentModal = dynamic( + () => + import( + /* webpackChunkName: "feedSentimentModal" */ './SentimentPopupModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -497,6 +504,7 @@ export const modals = { [LazyModal.CandidateSignIn]: CandidateSignInModal, [LazyModal.Feedback]: FeedbackModal, [LazyModal.AchievementSyncPrompt]: AchievementSyncPromptModal, + [LazyModal.FeedSentiment]: FeedSentimentModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index a87adfe33e..9ad85504d7 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -94,6 +94,7 @@ export enum LazyModal { CandidateSignIn = 'candidateSignIn', Feedback = 'feedback', AchievementSyncPrompt = 'achievementSyncPrompt', + FeedSentiment = 'feedSentiment', } export type ModalTabItem = { diff --git a/packages/shared/src/graphql/feedSentiment.ts b/packages/shared/src/graphql/feedSentiment.ts new file mode 100644 index 0000000000..e7cf465b31 --- /dev/null +++ b/packages/shared/src/graphql/feedSentiment.ts @@ -0,0 +1,18 @@ +import { gql } from 'graphql-request'; +import { gqlClient } from './common'; +import type { EmptyResponse } from './emptyResponse'; + +export type FeedSentiment = 'good' | 'neutral' | 'bad'; + +export const SUBMIT_FEED_SENTIMENT_MUTATION = gql` + mutation SubmitFeedSentiment($sentiment: String!) { + submitFeedSentiment(sentiment: $sentiment) { + _ + } + } +`; + +export const submitFeedSentiment = ( + sentiment: FeedSentiment, +): Promise => + gqlClient.request(SUBMIT_FEED_SENTIMENT_MUTATION, { sentiment }); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index e47af6f9d3..b656b92db5 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -377,6 +377,9 @@ export enum LogEvent { // Log ViewLogPage = 'view log page', ViewLogCard = 'view log card', + // Feed Sentiment + OpenFeedSentiment = 'open feed sentiment', + SubmitFeedSentiment = 'submit feed sentiment', } export enum TargetType { @@ -435,6 +438,7 @@ export enum TargetType { Recruiter = 'recruiter', ProfileCompletionCard = 'profile completion card', OpportunityInterestButton = 'opportunity interest button', + FeedSentiment = 'feed sentiment', } export enum TargetId { diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index e6dc7be5e2..c86dd72aab 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -48,3 +48,150 @@ margin-left: 20px; } } + +/* Sentiment Popup Modal Animations */ +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(40px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes fade-slide-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.4) rotate(-180deg); + } + to { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +@keyframes emoji-select { + 0% { + transform: scale(1); + } + 30% { + transform: scale(1.3) rotate(12deg); + } + 50% { + transform: scale(1.25) rotate(8deg); + } + 100% { + transform: scale(1) rotate(0deg); + opacity: 0; + } +} + +@keyframes glow-pulse { + 0% { + opacity: 0.8; + inset: -0.25rem; + } + 100% { + opacity: 0; + inset: -2.5rem; + } +} + +@keyframes emoji-disappear { + to { + opacity: 0; + transform: scale(0.5); + } +} + +@keyframes success-bounce { + from { + transform: scale(0) rotate(-180deg); + opacity: 0; + } + to { + transform: scale(1) rotate(0deg); + opacity: 1; + } +} + +@keyframes success-fade { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.3s cubic-bezier(0.22, 1, 0.36, 1); +} + +.animate-slide-up { + animation: slide-up 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.animate-fade-slide-in { + animation: fade-slide-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.2s backwards; +} + +.animate-pop-in { + animation: pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) backwards; +} + +.animation-delay-300 { + animation-delay: 0.3s; +} + +.animation-delay-380 { + animation-delay: 0.38s; +} + +.animation-delay-460 { + animation-delay: 0.46s; +} + +.animate-emoji-select { + animation: emoji-select 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.animate-glow-pulse { + animation: glow-pulse 0.6s ease-out !important; +} + +.animate-emoji-disappear { + animation: emoji-disappear 0.4s ease forwards; +} + +.animate-success-bounce { + animation: success-bounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.5s backwards; +} + +.animate-success-fade { + animation: success-fade 0.4s ease 0.7s backwards; +}