PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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 2.0.10 All 83 releases
yatra / resources / js / components / trip-form / TestimonialsSelector.tsx

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

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