Customers reported that the CSAT survey was recording their rating the instant they tapped a star — leaving no chance to correct an accidental pick. This change lets the customer freely change their selection until they actually submit the comment/feedback. The rating still saves on click (so we don't lose ratings when a customer never types a comment), but it stays editable until the comment form is submitted. Once that happens, the rating locks. The flow on both surfaces: - Customer taps a star/emoji → rating is saved. - Customer taps a different star/emoji → previous save is overwritten with the new value. - Customer types a comment and submits → latest rating + comment are saved together. - After that submit, the rating is locked and can't be changed. Two surfaces are updated: - **Standalone survey page** (`/survey/responses/:uuid`) — the rating buttons remain re-tappable until the Feedback form is submitted; once submitted, both rating and feedback lock. - **In-conversation widget CSAT** — same behavior, the inline arrow-submit button on the feedback form is the locking action. In-flight guards prevent a race where the customer changes their pick mid-network-call (raised by the codex review on the earlier revision): while a save is in flight, the rating controls are temporarily disabled so the request and the displayed selection can't diverge. ## Closes - https://linear.app/chatwoot/issue/CW-7061/csat-star-rating-submits-on-first-click-needs-confirmation-step ## How to test **Standalone survey page** 1. Enable CSAT on any inbox (Settings → Inboxes → Configuration → CSAT survey). 2. Resolve a conversation in that inbox so a CSAT message is generated. 3. Open the survey URL: `{FRONTEND_URL}/survey/responses/{conversation.uuid}` (easiest: `bundle exec rails runner 'puts Conversation.joins(:messages).where(messages: { content_type: "input_csat" }).last.csat_survey_link'`). 4. Tap a star/emoji — confirm the rating saves (Network panel shows a PUT to `/public/api/v1/csat_survey/{uuid}`). 5. Tap a different star/emoji — confirm a second PUT goes out with the new value; the latest selection is reflected. 6. Type a comment and hit Submit feedback — confirm rating + feedback persist; both controls now lock. 7. Reload the page — the locked state is rehydrated correctly. **Widget CSAT** 1. From an inbox with CSAT enabled, resolve a conversation that has an active widget session. 2. In the widget, the CSAT card appears with stars/emojis + the inline comment box. 3. Tap a star/emoji — confirm a PATCH goes out and the selection visibly updates. 4. Tap different stars/emojis — confirm each overrides the previous save. 5. Type a comment and click the arrow — rating + comment submit together; stars lock. Both display types (emoji and 5-star) should behave consistently. ## What changed - `app/javascript/survey/views/Response.vue` — `selectRating()` saves on every tap and short-circuits while a save is in flight (or after feedback was submitted). Rating components are disabled by `isFeedbackSubmitted || isUpdating` so the lock follows the feedback submission, not the first rating tap. - `app/javascript/survey/components/Rating.vue` — new `isDisabled` prop. The disabled / hover styling and click guard key off it instead of the presence of `selectedRating`, so emojis stay re-clickable until the feedback step locks them. - `app/javascript/shared/components/CustomerSatisfaction.vue` — same shape for the widget: rating click auto-submits and re-clicks override the previous save; controls disabled while a submit is in flight; emoji-button styling and the inline `StarRating` lock on `isFeedbackSubmitted || isUpdating`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
217 lines
5.7 KiB
Vue
217 lines
5.7 KiB
Vue
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import Spinner from 'shared/components/Spinner.vue';
|
|
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
|
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
|
import StarRating from 'shared/components/StarRating.vue';
|
|
import { getContrastingTextColor } from '@chatwoot/utils';
|
|
|
|
export default {
|
|
components: {
|
|
Spinner,
|
|
FluentIcon,
|
|
StarRating,
|
|
},
|
|
props: {
|
|
messageContentAttributes: {
|
|
type: Object,
|
|
default: () => {},
|
|
},
|
|
messageId: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
displayType: {
|
|
type: String,
|
|
default: CSAT_DISPLAY_TYPES.EMOJI,
|
|
},
|
|
message: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
email: '',
|
|
ratings: CSAT_RATINGS,
|
|
selectedRating: null,
|
|
isUpdating: false,
|
|
feedback: '',
|
|
};
|
|
},
|
|
computed: {
|
|
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
|
|
isRatingSubmitted() {
|
|
return this.messageContentAttributes?.csat_survey_response?.rating;
|
|
},
|
|
isFeedbackSubmitted() {
|
|
return this.messageContentAttributes?.csat_survey_response
|
|
?.feedback_message;
|
|
},
|
|
isButtonDisabled() {
|
|
if (!(this.selectedRating && this.feedback)) return true;
|
|
if (this.isUpdating) return true;
|
|
return false;
|
|
},
|
|
textColor() {
|
|
return getContrastingTextColor(this.widgetColor);
|
|
},
|
|
title() {
|
|
return this.isRatingSubmitted
|
|
? this.$t('CSAT.SUBMITTED_TITLE')
|
|
: this.message || this.$t('CSAT.TITLE');
|
|
},
|
|
isEmojiType() {
|
|
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
|
|
},
|
|
isStarType() {
|
|
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
|
|
},
|
|
},
|
|
|
|
mounted() {
|
|
if (this.isRatingSubmitted) {
|
|
const {
|
|
csat_survey_response: { rating, feedback_message },
|
|
} = this.messageContentAttributes;
|
|
this.selectedRating = rating;
|
|
this.feedback = feedback_message;
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
buttonClass(rating) {
|
|
const isLocked = this.isFeedbackSubmitted || this.isUpdating;
|
|
return [
|
|
{ selected: rating.value === this.selectedRating },
|
|
{ disabled: isLocked },
|
|
{ hover: isLocked },
|
|
'emoji-button',
|
|
];
|
|
},
|
|
async onSubmit() {
|
|
if (this.isUpdating) return;
|
|
this.isUpdating = true;
|
|
try {
|
|
await this.$store.dispatch('message/update', {
|
|
submittedValues: {
|
|
csat_survey_response: {
|
|
rating: this.selectedRating,
|
|
feedback_message: this.feedback,
|
|
},
|
|
},
|
|
messageId: this.messageId,
|
|
});
|
|
} catch (error) {
|
|
// Ignore error
|
|
} finally {
|
|
this.isUpdating = false;
|
|
}
|
|
},
|
|
|
|
selectRating(rating) {
|
|
if (this.isFeedbackSubmitted || this.isUpdating) return;
|
|
this.selectedRating = rating.value;
|
|
this.onSubmit();
|
|
},
|
|
selectStarRating(value) {
|
|
if (this.isFeedbackSubmitted || this.isUpdating) return;
|
|
this.selectedRating = value;
|
|
this.onSubmit();
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="customer-satisfaction w-full bg-n-background dark:bg-n-solid-3 shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg inline-block leading-[1.5] mt-1 border-t-2 border-t-n-brand border-solid"
|
|
:style="{ borderColor: widgetColor }"
|
|
>
|
|
<h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
|
|
{{ title }}
|
|
</h6>
|
|
<div v-if="isEmojiType" class="ratings flex justify-around py-5 px-4">
|
|
<button
|
|
v-for="rating in ratings"
|
|
:key="rating.key"
|
|
:class="buttonClass(rating)"
|
|
@click="selectRating(rating)"
|
|
>
|
|
{{ rating.emoji }}
|
|
</button>
|
|
</div>
|
|
<StarRating
|
|
v-else-if="isStarType"
|
|
:selected-rating="selectedRating"
|
|
:is-disabled="isFeedbackSubmitted || isUpdating"
|
|
@select-rating="selectStarRating"
|
|
/>
|
|
<form
|
|
v-if="!isFeedbackSubmitted"
|
|
class="feedback-form flex"
|
|
@submit.prevent="onSubmit()"
|
|
>
|
|
<input
|
|
v-model="feedback"
|
|
:placeholder="$t('CSAT.PLACEHOLDER')"
|
|
@keydown.enter="onSubmit"
|
|
/>
|
|
<button
|
|
class="button small"
|
|
:disabled="isButtonDisabled"
|
|
:style="{
|
|
background: widgetColor,
|
|
borderColor: widgetColor,
|
|
color: textColor,
|
|
}"
|
|
>
|
|
<Spinner v-if="isUpdating && feedback" />
|
|
<FluentIcon v-else icon="chevron-right" />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.customer-satisfaction {
|
|
.ratings {
|
|
.emoji-button {
|
|
@apply shadow-none grayscale text-2xl outline-none transition-all duration-200;
|
|
|
|
&.selected,
|
|
&:hover,
|
|
&:focus,
|
|
&:active {
|
|
@apply grayscale-0 scale-[1.32];
|
|
}
|
|
|
|
&.disabled {
|
|
@apply cursor-not-allowed opacity-50 pointer-events-none;
|
|
}
|
|
}
|
|
}
|
|
|
|
.feedback-form {
|
|
input {
|
|
@apply h-10 dark:bg-n-alpha-black1 rtl:rounded-tl-[0] rtl:rounded-tr-[0] ltr:rounded-tr-[0] ltr:rounded-tl-[0] rtl:rounded-bl-[0] ltr:rounded-br-[0] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg p-2.5 w-full focus:ring-0 focus:outline-n-brand;
|
|
|
|
&::placeholder {
|
|
@apply text-n-slate-10;
|
|
}
|
|
}
|
|
|
|
.button {
|
|
@apply rtl:rounded-tr-[0] rtl:rounded-tl-[0] appearance-none ltr:rounded-tl-[0] ltr:rounded-tr-[0] rtl:rounded-br-[0] ltr:rounded-bl-[0] rounded-lg h-auto ltr:-ml-px rtl:-mr-px text-xl;
|
|
|
|
.spinner {
|
|
display: block;
|
|
padding: 0;
|
|
height: auto;
|
|
width: auto;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|