@reviewlico/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { ReviewFormProps } from '../../shared/types';
5
+ import { useSubmitReview } from '../../shared/hooks/useSubmitReview';
6
+ import { StarRating } from './StarRating';
7
+
8
+ export function ReviewForm({
9
+ config,
10
+ onSuccess,
11
+ onError,
12
+ title = 'Write a Review',
13
+ submitLabel = 'Submit Review',
14
+ className,
15
+ }: ReviewFormProps) {
16
+ const { state, submit } = useSubmitReview(config);
17
+
18
+ const [rating, setRating] = useState(0);
19
+ const [text, setText] = useState('');
20
+ const [reviewerName, setReviewerName] = useState('');
21
+ const [reviewerEmail, setReviewerEmail] = useState('');
22
+ const [errors, setErrors] = useState<Record<string, string>>({});
23
+
24
+ useEffect(() => {
25
+ if (state.status === 'success') {
26
+ const t = setTimeout(() => onSuccess?.(state.review), 2000);
27
+ return () => clearTimeout(t);
28
+ }
29
+ if (state.status === 'error') onError?.(state.message);
30
+ }, [state, onSuccess, onError]);
31
+
32
+ function validate() {
33
+ const next: Record<string, string> = {};
34
+ if (rating === 0) next.rating = 'Please select a star rating.';
35
+ if (!text.trim()) next.text = 'Review text is required.';
36
+ if (text.trim().length > 5000) next.text = 'Review must be 5000 characters or less.';
37
+ if (!reviewerName.trim()) next.reviewerName = 'Your name is required.';
38
+ if (!reviewerEmail.trim()) next.reviewerEmail = 'Your email is required.';
39
+ else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail.trim())) {
40
+ next.reviewerEmail = 'Enter a valid email address.';
41
+ }
42
+ setErrors(next);
43
+ return Object.keys(next).length === 0;
44
+ }
45
+
46
+ function handleSubmit(e: React.FormEvent) {
47
+ e.preventDefault();
48
+ if (!validate()) return;
49
+ void submit({
50
+ rating,
51
+ text: text.trim(),
52
+ reviewerName: reviewerName.trim(),
53
+ reviewerEmail: reviewerEmail.trim(),
54
+ });
55
+ }
56
+
57
+ const rootClass = ['rc-root', 'rc-form', className].filter(Boolean).join(' ');
58
+
59
+ if (state.status === 'success') {
60
+ return (
61
+ <div className={rootClass}>
62
+ <div className="rc-form__success">
63
+ <span className="rc-form__success-icon">✓</span>
64
+ <p className="rc-form__success-heading">Thank you!</p>
65
+ <p className="rc-form__success-message">Your review has been submitted.</p>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <form className={rootClass} onSubmit={handleSubmit} noValidate>
73
+ <h3 className="rc-form__title">{title}</h3>
74
+
75
+ <div className="rc-field">
76
+ <span className="rc-label">Rating</span>
77
+ <StarRating value={rating} onChange={setRating} />
78
+ {errors.rating ? <span className="rc-field__error">{errors.rating}</span> : null}
79
+ </div>
80
+
81
+ <div className="rc-field">
82
+ <label className="rc-label" htmlFor="rc-text">Review</label>
83
+ <textarea
84
+ id="rc-text"
85
+ className="rc-textarea"
86
+ value={text}
87
+ onChange={(e) => setText(e.target.value)}
88
+ placeholder="Share your experience…"
89
+ rows={4}
90
+ />
91
+ {errors.text ? <span className="rc-field__error">{errors.text}</span> : null}
92
+ </div>
93
+
94
+ <div className="rc-field">
95
+ <label className="rc-label" htmlFor="rc-name">Your name</label>
96
+ <input
97
+ id="rc-name"
98
+ className="rc-input"
99
+ type="text"
100
+ value={reviewerName}
101
+ onChange={(e) => setReviewerName(e.target.value)}
102
+ placeholder="Jane Smith"
103
+ />
104
+ {errors.reviewerName ? <span className="rc-field__error">{errors.reviewerName}</span> : null}
105
+ </div>
106
+
107
+ <div className="rc-field">
108
+ <label className="rc-label" htmlFor="rc-email">Email address</label>
109
+ <input
110
+ id="rc-email"
111
+ className="rc-input"
112
+ type="email"
113
+ value={reviewerEmail}
114
+ onChange={(e) => setReviewerEmail(e.target.value)}
115
+ placeholder="jane@example.com"
116
+ />
117
+ {errors.reviewerEmail ? <span className="rc-field__error">{errors.reviewerEmail}</span> : null}
118
+ </div>
119
+
120
+ {state.status === 'error' ? (
121
+ <p key={state.message} className="rc-error-message">{state.message}</p>
122
+ ) : null}
123
+
124
+ <button
125
+ type="submit"
126
+ className={['rc-button', state.status === 'submitting' ? 'rc-button--loading' : ''].filter(Boolean).join(' ')}
127
+ disabled={state.status === 'submitting'}
128
+ >
129
+ {state.status === 'submitting' ? 'Submitting…' : submitLabel}
130
+ </button>
131
+ </form>
132
+ );
133
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { ReviewListProps } from '../../shared/types';
5
+ import { useReviews } from '../../shared/hooks/useReviews';
6
+ import { ReviewCard } from './ReviewCard';
7
+ import { ReviewForm } from './ReviewForm';
8
+
9
+ function ReviewListInner({
10
+ config,
11
+ limit = 10,
12
+ title = 'Customer Reviews',
13
+ showForm = false,
14
+ className,
15
+ }: ReviewListProps) {
16
+ const [page, setPage] = useState(1);
17
+ const [formVisible, setFormVisible] = useState(false);
18
+ const { state, reload } = useReviews(config, page, limit);
19
+
20
+ const rootClass = ['rc-root', 'rc-list', className].filter(Boolean).join(' ');
21
+
22
+ const pagination = state.status === 'success' ? state.data.pagination : null;
23
+
24
+ function handleFormSuccess() {
25
+ setFormVisible(false);
26
+ if (page === 1) {
27
+ reload();
28
+ } else {
29
+ setPage(1);
30
+ }
31
+ }
32
+
33
+ return (
34
+ <div className={rootClass}>
35
+ <div className="rc-list__header">
36
+ <h3 className="rc-list__title">{title}</h3>
37
+ {showForm && !formVisible ? (
38
+ <button
39
+ type="button"
40
+ className="rc-list__toggle-form"
41
+ onClick={() => setFormVisible(true)}
42
+ >
43
+ Write a review
44
+ </button>
45
+ ) : null}
46
+ </div>
47
+
48
+ {formVisible ? (
49
+ <ReviewForm config={config} onSuccess={handleFormSuccess} />
50
+ ) : null}
51
+
52
+ {state.status === 'loading' ? (
53
+ <div className="rc-cards">
54
+ {Array.from({ length: 3 }).map((_, i) => (
55
+ <div key={i} className="rc-skeleton rc-skeleton--card" />
56
+ ))}
57
+ </div>
58
+ ) : state.status === 'error' ? (
59
+ <p className="rc-list__error">{state.message}</p>
60
+ ) : state.status === 'success' && state.data.reviews.length === 0 ? (
61
+ <p className="rc-list__empty">No reviews yet. Be the first!</p>
62
+ ) : state.status === 'success' ? (
63
+ <div className="rc-cards">
64
+ {state.data.reviews.map((review) => (
65
+ <ReviewCard key={review._id} review={review} />
66
+ ))}
67
+ </div>
68
+ ) : null}
69
+
70
+ {pagination && pagination.totalPages > 1 ? (
71
+ <div className="rc-pagination">
72
+ <button
73
+ type="button"
74
+ className="rc-pagination__btn"
75
+ onClick={() => setPage((p) => p - 1)}
76
+ disabled={page <= 1 || state.status === 'loading'}
77
+ >
78
+ ← Previous
79
+ </button>
80
+ <span className="rc-pagination__info">
81
+ Page {page} of {pagination.totalPages} · {pagination.total} reviews
82
+ </span>
83
+ <button
84
+ type="button"
85
+ className="rc-pagination__btn"
86
+ onClick={() => setPage((p) => p + 1)}
87
+ disabled={page >= pagination.totalPages || state.status === 'loading'}
88
+ >
89
+ Next →
90
+ </button>
91
+ </div>
92
+ ) : null}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ export function ReviewList(props: ReviewListProps) {
98
+ const { config } = props;
99
+ const key = [
100
+ config.apiUrl ?? '',
101
+ config.apiKey ?? '',
102
+ config.externalProductId ?? '',
103
+ ].join('|');
104
+ return <ReviewListInner key={key} {...props} />;
105
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ interface StarRatingProps {
6
+ value: number;
7
+ onChange?: (value: number) => void;
8
+ size?: 'sm' | 'md';
9
+ }
10
+
11
+ export function StarRating({ value, onChange, size = 'md' }: StarRatingProps) {
12
+ const [hovered, setHovered] = useState(0);
13
+ const [justSelected, setJustSelected] = useState(0);
14
+ const interactive = typeof onChange !== 'undefined';
15
+ const display = hovered > 0 ? hovered : value;
16
+
17
+ const starsClass = ['rc-stars rc-root', size === 'sm' ? 'rc-stars--sm' : ''].filter(Boolean).join(' ');
18
+
19
+ function handleClick(star: number) {
20
+ onChange!(star);
21
+ setJustSelected(star);
22
+ setTimeout(() => setJustSelected(0), 400);
23
+ }
24
+
25
+ return (
26
+ <span className={starsClass}>
27
+ {[1, 2, 3, 4, 5].map((star) => {
28
+ const filled = star <= display;
29
+ const starClass = [
30
+ 'rc-star',
31
+ filled ? 'rc-star--filled' : '',
32
+ interactive ? 'rc-star--interactive' : '',
33
+ interactive && justSelected === star ? 'rc-star--just-selected' : '',
34
+ ]
35
+ .filter(Boolean)
36
+ .join(' ');
37
+
38
+ if (interactive) {
39
+ return (
40
+ <button
41
+ key={star}
42
+ type="button"
43
+ className={starClass}
44
+ aria-label={`Rate ${star} star${star !== 1 ? 's' : ''}`}
45
+ onClick={() => handleClick(star)}
46
+ onMouseEnter={() => setHovered(star)}
47
+ onMouseLeave={() => setHovered(0)}
48
+ >
49
+
50
+ </button>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <span key={star} className={starClass}>
56
+ {filled ? '★' : '☆'}
57
+ </span>
58
+ );
59
+ })}
60
+ </span>
61
+ );
62
+ }