PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / trip-form / TestimonialsSelector.tsx

TestimonialsSelector.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.8, at resources/js/components/trip-form/TestimonialsSelector.tsx

259 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect } from "react";
2 import {
3 Card,
4 CardContent,
5 CardDescription,
6 CardHeader,
7 CardTitle,
8 } from "../ui/card";
9 import { Button } from "../ui/button";
10 import { Badge } from "../ui/badge";
11 import { Star, User, MapPin, Calendar, Loader2 } from "lucide-react";
12 import { apiService } from "../../lib/api-client";
13
14 const __ = (text: string) => {
15 return (window as any)?.yatraAdmin?.translations?.[text] || text;
16 };
17
18 interface Review {
19 id: number;
20 trip_id: number;
21 rating: number;
22 title: string;
23 content: string;
24 author_name: string;
25 author_location: string | null;
26 created_at: string;
27 status: "pending" | "approved" | "rejected";
28 }
29
30 interface TestimonialsSelectorProps {
31 tripId: number | null;
32 selectedReviewIds: number[];
33 onChange: (reviewIds: number[]) => void;
34 }
35
36 export const TestimonialsSelector: React.FC<TestimonialsSelectorProps> = ({
37 tripId,
38 selectedReviewIds,
39 onChange,
40 }) => {
41 const [reviews, setReviews] = useState<Review[]>([]);
42 const [loading, setLoading] = useState(false);
43 const [error, setError] = useState<string | null>(null);
44
45 useEffect(() => {
46 if (tripId) {
47 fetchReviews();
48 } else {
49 setReviews([]);
50 }
51 // eslint-disable-next-line react-hooks/exhaustive-deps
52 }, [tripId]);
53
54 const fetchReviews = async () => {
55 if (!tripId) return;
56
57 setLoading(true);
58 setError(null);
59
60 try {
61 const data = await apiService.getReviews({
62 trip_id: tripId,
63 status: "approved",
64 per_page: 100,
65 });
66
67 setReviews(data.data || []);
68 } catch (err) {
69 console.error("Error fetching reviews:", err);
70 setError(__("Failed to load reviews. Please try again."));
71 } finally {
72 setLoading(false);
73 }
74 };
75
76 const toggleReview = (reviewId: number) => {
77 const newSelection = selectedReviewIds.includes(reviewId)
78 ? selectedReviewIds.filter((id) => id !== reviewId)
79 : [...selectedReviewIds, reviewId];
80 onChange(newSelection);
81 };
82
83 const formatDate = (dateString: string) => {
84 const date = new Date(dateString);
85 return date.toLocaleDateString(undefined, {
86 year: "numeric",
87 month: "short",
88 day: "numeric",
89 });
90 };
91
92 const renderStars = (rating: number) => {
93 return (
94 <div className="flex gap-0.5">
95 {[1, 2, 3, 4, 5].map((star) => (
96 <Star
97 key={star}
98 className={`w-4 h-4 ${
99 star <= rating
100 ? "fill-yellow-400 text-yellow-400"
101 : "text-gray-300 dark:text-gray-600"
102 }`}
103 />
104 ))}
105 </div>
106 );
107 };
108
109 if (!tripId) {
110 return (
111 <Card>
112 <CardHeader>
113 <CardTitle>{__("Testimonials")}</CardTitle>
114 <CardDescription>
115 {__("Select reviews to display as testimonials")}
116 </CardDescription>
117 </CardHeader>
118 <CardContent>
119 <div className="p-6 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg text-center">
120 <p className="text-sm text-gray-600 dark:text-gray-400">
121 {__("Save the trip first to select testimonials from reviews")}
122 </p>
123 </div>
124 </CardContent>
125 </Card>
126 );
127 }
128
129 return (
130 <Card>
131 <CardHeader>
132 <CardTitle>{__("Testimonials")}</CardTitle>
133 <CardDescription>
134 {__(
135 "Select approved reviews to display as testimonials on the trip page",
136 )}
137 </CardDescription>
138 </CardHeader>
139 <CardContent>
140 {loading ? (
141 <div className="flex items-center justify-center py-8">
142 <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
143 <span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
144 {__("Loading reviews...")}
145 </span>
146 </div>
147 ) : error ? (
148 <div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
149 <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
150 <Button
151 type="button"
152 variant="outline"
153 size="sm"
154 onClick={fetchReviews}
155 className="mt-2"
156 >
157 {__("Retry")}
158 </Button>
159 </div>
160 ) : reviews.length === 0 ? (
161 <div className="p-6 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg text-center">
162 <User className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
163 <p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
164 {__("No approved reviews yet")}
165 </p>
166 <p className="text-xs text-gray-500 dark:text-gray-500">
167 {__(
168 "Reviews will appear here once customers submit and they are approved",
169 )}
170 </p>
171 </div>
172 ) : (
173 <div className="space-y-3">
174 <div className="flex items-center justify-between mb-3">
175 <p className="text-sm text-gray-600 dark:text-gray-400">
176 {selectedReviewIds.length > 0
177 ? __(`${selectedReviewIds.length} review(s) selected`)
178 : __("Select reviews to feature as testimonials")}
179 </p>
180 {selectedReviewIds.length > 0 && (
181 <Button
182 type="button"
183 variant="ghost"
184 size="sm"
185 onClick={() => onChange([])}
186 className="text-xs"
187 >
188 {__("Clear all")}
189 </Button>
190 )}
191 </div>
192
193 <div className="space-y-2 max-h-96 overflow-y-auto">
194 {reviews.map((review) => (
195 <div
196 key={review.id}
197 className={`p-4 border rounded-lg cursor-pointer transition-all ${
198 selectedReviewIds.includes(review.id)
199 ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
200 : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
201 }`}
202 onClick={() => toggleReview(review.id)}
203 >
204 <div className="flex items-start gap-3">
205 <input
206 type="checkbox"
207 checked={selectedReviewIds.includes(review.id)}
208 onChange={() => toggleReview(review.id)}
209 className="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
210 />
211 <div className="flex-1 min-w-0">
212 <div className="flex items-start justify-between gap-2 mb-2">
213 <div className="flex-1">
214 <div className="flex items-center gap-2 mb-1">
215 <span className="font-medium text-gray-900 dark:text-white">
216 {review.author_name}
217 </span>
218 {review.author_location && (
219 <span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
220 <MapPin className="w-3 h-3" />
221 {review.author_location}
222 </span>
223 )}
224 </div>
225 {renderStars(review.rating)}
226 </div>
227 <span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
228 <Calendar className="w-3 h-3" />
229 {formatDate(review.created_at)}
230 </span>
231 </div>
232
233 {review.title && (
234 <h4 className="font-medium text-sm text-gray-900 dark:text-white mb-1">
235 {review.title}
236 </h4>
237 )}
238
239 <p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
240 {review.content}
241 </p>
242
243 {selectedReviewIds.includes(review.id) && (
244 <Badge variant="info" className="mt-2">
245 {__("Selected as testimonial")}
246 </Badge>
247 )}
248 </div>
249 </div>
250 </div>
251 ))}
252 </div>
253 </div>
254 )}
255 </CardContent>
256 </Card>
257 );
258 };
259