@duffcloudservices/cms 0.3.12 → 0.3.14

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.
@@ -1,326 +1,321 @@
1
- <script setup lang="ts">
2
- /**
3
- * Showcase component for displaying curated customer reviews.
4
- * Uses data-dcs-reviews attribute for visual editor integration.
5
- *
6
- * Usage:
7
- * <DcsReviewShowcase section-key="testimonials" :defaults="fallbackReviews" />
8
- */
9
- import { computed } from 'vue'
10
- import { useReviewContent, type ReviewItem } from '../composables/useReviewContent'
11
-
12
- const props = withDefaults(defineProps<{
13
- /** Key matching the data-dcs-reviews attribute for editor bridge discovery */
14
- sectionKey: string
15
- /** Page slug for page-specific content */
16
- pageSlug?: string
17
- /** Fallback reviews when no CMS content is available */
18
- defaults?: ReviewItem[]
19
- /** Maximum number of reviews to display */
20
- maxDisplay?: number
21
- /** Layout variant */
22
- layout?: 'grid' | 'carousel' | 'list'
23
- /** Whether to show the platform badge */
24
- showPlatform?: boolean
25
- /** Whether to show the review date */
26
- showDate?: boolean
27
- /** Whether to show reply text */
28
- showReply?: boolean
29
- }>(), {
30
- defaults: () => [],
31
- maxDisplay: 6,
32
- layout: 'grid',
33
- showPlatform: true,
34
- showDate: true,
35
- showReply: false,
36
- })
37
-
38
- const { reviews, hasReviews } = useReviewContent({
39
- sectionKey: props.sectionKey,
40
- pageSlug: props.pageSlug,
41
- defaults: props.defaults,
42
- })
43
-
44
- const visibleReviews = computed(() => reviews.value.slice(0, props.maxDisplay))
45
-
46
- const showEmptyState = computed(() => !hasReviews.value && isEditorContext())
47
-
48
- /** Get initials from author name for avatar fallback */
49
- function getInitials(name: string): string {
50
- return name
51
- .split(' ')
52
- .map(word => word[0])
53
- .filter(Boolean)
54
- .slice(0, 2)
55
- .join('')
56
- .toUpperCase()
57
- }
58
-
59
- /** Format date string to readable format */
60
- function formatDate(dateStr: string): string {
61
- const parsed = new Date(dateStr)
62
- if (Number.isNaN(parsed.getTime())) {
63
- return dateStr
64
- }
65
-
66
- return parsed.toLocaleDateString(undefined, {
67
- year: 'numeric',
68
- month: 'short',
69
- })
70
- }
71
-
72
- /** Get platform display label */
73
- function platformLabel(platform: string): string {
74
- switch (platform) {
75
- case 'google':
76
- return 'Google'
77
- case 'meta':
78
- return 'Facebook'
79
- default:
80
- return platform.charAt(0).toUpperCase() + platform.slice(1)
81
- }
82
- }
83
-
84
- function isEditorContext(): boolean {
85
- if (globalThis.window === undefined) {
86
- return false
87
- }
88
-
89
- try {
90
- return globalThis.self !== globalThis.top
91
- } catch {
92
- return true
93
- }
94
- }
95
- </script>
96
-
97
- <template>
98
- <div
99
- :data-dcs-reviews="sectionKey"
100
- class="dcs-review-showcase"
101
- :class="`dcs-review-showcase--${layout}`"
102
- >
103
- <template v-if="hasReviews">
104
- <div
105
- class="dcs-review-showcase__grid"
106
- :class="{
107
- 'dcs-review-grid': layout === 'grid',
108
- 'dcs-review-list': layout === 'list',
109
- 'dcs-review-carousel': layout === 'carousel',
110
- }"
111
- >
112
- <article
113
- v-for="review in visibleReviews"
114
- :key="review.id"
115
- class="dcs-review-card"
116
- >
117
- <div class="dcs-review-card__header">
118
- <div class="dcs-review-card__avatar">
119
- <img
120
- v-if="review.authorPhotoUrl"
121
- :src="review.authorPhotoUrl"
122
- :alt="review.authorName"
123
- class="dcs-review-card__avatar-img"
124
- loading="lazy"
125
- >
126
- <span v-else class="dcs-review-card__avatar-initials">
127
- {{ getInitials(review.authorName) }}
128
- </span>
129
- </div>
130
-
131
- <div class="dcs-review-card__author-info">
132
- <span class="dcs-review-card__author-name">{{ review.authorName }}</span>
133
- <span v-if="showPlatform" class="dcs-review-card__platform">
134
- {{ platformLabel(review.platform) }}
135
- </span>
136
- </div>
137
- </div>
138
-
139
- <div class="dcs-review-card__rating" :aria-label="`${review.rating} out of 5 stars`">
140
- <svg
141
- v-for="star in 5"
142
- :key="star"
143
- class="dcs-review-card__star"
144
- :class="star <= review.rating ? 'dcs-review-card__star--filled' : 'dcs-review-card__star--empty'"
145
- viewBox="0 0 20 20"
146
- width="18"
147
- height="18"
148
- aria-hidden="true"
149
- >
150
- <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 0 0 .95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 0 0-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 0 0-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 0 0-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 0 0 .951-.69l1.07-3.292z" />
151
- </svg>
152
- </div>
153
-
154
- <p v-if="review.text" class="dcs-review-card__text">
155
- {{ review.text }}
156
- </p>
157
-
158
- <time
159
- v-if="showDate && review.date"
160
- class="dcs-review-card__date"
161
- :datetime="review.date"
162
- >
163
- {{ formatDate(review.date) }}
164
- </time>
165
-
166
- <div v-if="showReply && review.replyText" class="dcs-review-card__reply">
167
- <span class="dcs-review-card__reply-label">Response:</span>
168
- <p class="dcs-review-card__reply-text">{{ review.replyText }}</p>
169
- </div>
170
- </article>
171
- </div>
172
- </template>
173
-
174
- <div v-else-if="showEmptyState" class="dcs-review-showcase__empty">
175
- <p>Click to add reviews from Google or Meta</p>
176
- </div>
177
- </div>
178
- </template>
179
-
180
- <style scoped>
181
- .dcs-review-grid {
182
- display: grid;
183
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
184
- gap: 1.5rem;
185
- }
186
-
187
- .dcs-review-list {
188
- display: flex;
189
- flex-direction: column;
190
- gap: 1rem;
191
- }
192
-
193
- .dcs-review-carousel {
194
- display: flex;
195
- gap: 1.5rem;
196
- overflow-x: auto;
197
- scroll-snap-type: x mandatory;
198
- -webkit-overflow-scrolling: touch;
199
- padding-bottom: 0.5rem;
200
- }
201
-
202
- .dcs-review-carousel .dcs-review-card {
203
- min-width: 300px;
204
- scroll-snap-align: start;
205
- }
206
-
207
- .dcs-review-card {
208
- background: var(--dcs-review-card-bg, #ffffff);
209
- border: 1px solid var(--dcs-review-card-border, #e5e7eb);
210
- border-radius: var(--dcs-review-card-radius, 0.75rem);
211
- padding: var(--dcs-review-card-padding, 1.5rem);
212
- transition: box-shadow 0.2s ease;
213
- }
214
-
215
- .dcs-review-card:hover {
216
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
217
- }
218
-
219
- .dcs-review-card__header {
220
- display: flex;
221
- align-items: center;
222
- gap: 0.75rem;
223
- margin-bottom: 0.75rem;
224
- }
225
-
226
- .dcs-review-card__avatar {
227
- width: 2.5rem;
228
- height: 2.5rem;
229
- border-radius: 9999px;
230
- overflow: hidden;
231
- flex-shrink: 0;
232
- background: var(--dcs-review-avatar-bg, #e5e7eb);
233
- display: flex;
234
- align-items: center;
235
- justify-content: center;
236
- }
237
-
238
- .dcs-review-card__avatar-img {
239
- width: 100%;
240
- height: 100%;
241
- object-fit: cover;
242
- }
243
-
244
- .dcs-review-card__avatar-initials {
245
- font-size: 0.75rem;
246
- font-weight: 600;
247
- color: var(--dcs-review-avatar-text, #6b7280);
248
- }
249
-
250
- .dcs-review-card__author-info {
251
- display: flex;
252
- flex-direction: column;
253
- min-width: 0;
254
- }
255
-
256
- .dcs-review-card__author-name {
257
- font-weight: 600;
258
- font-size: 0.9rem;
259
- color: var(--dcs-review-author-color, #111827);
260
- }
261
-
262
- .dcs-review-card__platform {
263
- font-size: 0.75rem;
264
- color: var(--dcs-review-platform-color, #6b7280);
265
- }
266
-
267
- .dcs-review-card__rating {
268
- display: flex;
269
- gap: 0.125rem;
270
- margin-bottom: 0.5rem;
271
- }
272
-
273
- .dcs-review-card__star {
274
- flex-shrink: 0;
275
- }
276
-
277
- .dcs-review-card__star--filled {
278
- fill: var(--dcs-review-star-filled, #f59e0b);
279
- color: var(--dcs-review-star-filled, #f59e0b);
280
- }
281
-
282
- .dcs-review-card__star--empty {
283
- fill: var(--dcs-review-star-empty, #d1d5db);
284
- color: var(--dcs-review-star-empty, #d1d5db);
285
- }
286
-
287
- .dcs-review-card__text {
288
- font-size: 0.9rem;
289
- line-height: 1.6;
290
- color: var(--dcs-review-text-color, #374151);
291
- margin: 0 0 0.75rem;
292
- }
293
-
294
- .dcs-review-card__date {
295
- font-size: 0.75rem;
296
- color: var(--dcs-review-date-color, #9ca3af);
297
- }
298
-
299
- .dcs-review-card__reply {
300
- margin-top: 0.75rem;
301
- padding: 0.75rem;
302
- background: var(--dcs-review-reply-bg, #f9fafb);
303
- border-radius: 0.5rem;
304
- border-left: 3px solid var(--dcs-review-reply-border, #3b82f6);
305
- }
306
-
307
- .dcs-review-card__reply-label {
308
- font-size: 0.75rem;
309
- font-weight: 600;
310
- color: var(--dcs-review-reply-label-color, #3b82f6);
311
- }
312
-
313
- .dcs-review-card__reply-text {
314
- font-size: 0.85rem;
315
- color: var(--dcs-review-reply-text-color, #4b5563);
316
- margin: 0.25rem 0 0;
317
- }
318
-
319
- .dcs-review-showcase__empty {
320
- padding: 2rem;
321
- text-align: center;
322
- color: var(--dcs-review-empty-color, #9ca3af);
323
- border: 2px dashed var(--dcs-review-empty-border, #e5e7eb);
324
- border-radius: 0.75rem;
325
- }
326
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * Showcase component for displaying curated customer reviews.
4
+ * Uses data-dcs-reviews attribute for visual editor integration.
5
+ *
6
+ * Usage:
7
+ * <DcsReviewShowcase section-key="testimonials" :defaults="fallbackReviews" />
8
+ */
9
+ import { computed, ref } from 'vue'
10
+ import { useReviewContent, type ReviewItem } from '../composables/useReviewContent'
11
+
12
+ const props = withDefaults(defineProps<{
13
+ /** Key matching the data-dcs-reviews attribute for editor bridge discovery */
14
+ sectionKey: string
15
+ /** Page slug for page-specific content */
16
+ pageSlug?: string
17
+ /** Fallback reviews when no CMS content is available */
18
+ defaults?: ReviewItem[]
19
+ /** Maximum number of reviews to display */
20
+ maxDisplay?: number
21
+ /** Layout variant */
22
+ layout?: 'grid' | 'carousel' | 'list'
23
+ /** Whether to show the platform badge */
24
+ showPlatform?: boolean
25
+ /** Whether to show the review date */
26
+ showDate?: boolean
27
+ /** Whether to show reply text */
28
+ showReply?: boolean
29
+ }>(), {
30
+ defaults: () => [],
31
+ maxDisplay: 6,
32
+ layout: 'grid',
33
+ showPlatform: true,
34
+ showDate: true,
35
+ showReply: false,
36
+ })
37
+
38
+ const { reviews, hasReviews } = useReviewContent({
39
+ sectionKey: props.sectionKey,
40
+ pageSlug: props.pageSlug,
41
+ defaults: props.defaults,
42
+ })
43
+
44
+ const visibleReviews = computed(() => reviews.value.slice(0, props.maxDisplay))
45
+
46
+ const showEmptyState = computed(() => !hasReviews.value && isEditorContext())
47
+ const failedAvatarIds = ref(new Set<string>())
48
+
49
+ function shouldShowAvatar(review: ReviewItem): boolean {
50
+ return Boolean(review.authorPhotoUrl?.trim()) && !failedAvatarIds.value.has(review.id)
51
+ }
52
+
53
+ function markAvatarFailed(reviewId: string) {
54
+ if (failedAvatarIds.value.has(reviewId)) {
55
+ return
56
+ }
57
+
58
+ const next = new Set(failedAvatarIds.value)
59
+ next.add(reviewId)
60
+ failedAvatarIds.value = next
61
+ }
62
+
63
+ /** Format date string to readable format */
64
+ function formatDate(dateStr: string): string {
65
+ const parsed = new Date(dateStr)
66
+ if (Number.isNaN(parsed.getTime())) {
67
+ return dateStr
68
+ }
69
+
70
+ return parsed.toLocaleDateString(undefined, {
71
+ year: 'numeric',
72
+ month: 'short',
73
+ })
74
+ }
75
+
76
+ /** Get platform display label */
77
+ function platformLabel(platform: string): string {
78
+ switch (platform) {
79
+ case 'google':
80
+ return 'Google'
81
+ case 'meta':
82
+ return 'Facebook'
83
+ default:
84
+ return platform.charAt(0).toUpperCase() + platform.slice(1)
85
+ }
86
+ }
87
+
88
+ function isEditorContext(): boolean {
89
+ if (globalThis.window === undefined) {
90
+ return false
91
+ }
92
+
93
+ try {
94
+ return globalThis.self !== globalThis.top
95
+ } catch {
96
+ return true
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <div
103
+ :data-dcs-reviews="sectionKey"
104
+ class="dcs-review-showcase"
105
+ :class="`dcs-review-showcase--${layout}`"
106
+ >
107
+ <template v-if="hasReviews">
108
+ <div
109
+ class="dcs-review-showcase__grid"
110
+ :class="{
111
+ 'dcs-review-grid': layout === 'grid',
112
+ 'dcs-review-list': layout === 'list',
113
+ 'dcs-review-carousel': layout === 'carousel',
114
+ }"
115
+ >
116
+ <article
117
+ v-for="review in visibleReviews"
118
+ :key="review.id"
119
+ class="dcs-review-card"
120
+ >
121
+ <div class="dcs-review-card__header">
122
+ <div v-if="shouldShowAvatar(review)" class="dcs-review-card__avatar">
123
+ <img
124
+ :src="review.authorPhotoUrl ?? ''"
125
+ :alt="review.authorName"
126
+ class="dcs-review-card__avatar-img"
127
+ loading="lazy"
128
+ @error="markAvatarFailed(review.id)"
129
+ >
130
+ </div>
131
+
132
+ <div class="dcs-review-card__author-info">
133
+ <span class="dcs-review-card__author-name">{{ review.authorName }}</span>
134
+ <span v-if="showPlatform" class="dcs-review-card__platform">
135
+ {{ platformLabel(review.platform) }}
136
+ </span>
137
+ </div>
138
+ </div>
139
+
140
+ <div class="dcs-review-card__rating" :aria-label="`${review.rating} out of 5 stars`">
141
+ <svg
142
+ v-for="star in 5"
143
+ :key="star"
144
+ class="dcs-review-card__star"
145
+ :class="star <= review.rating ? 'dcs-review-card__star--filled' : 'dcs-review-card__star--empty'"
146
+ viewBox="0 0 20 20"
147
+ width="18"
148
+ height="18"
149
+ aria-hidden="true"
150
+ >
151
+ <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 0 0 .95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 0 0-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 0 0-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 0 0-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 0 0 .951-.69l1.07-3.292z" />
152
+ </svg>
153
+ </div>
154
+
155
+ <p v-if="review.text" class="dcs-review-card__text">
156
+ {{ review.text }}
157
+ </p>
158
+
159
+ <time
160
+ v-if="showDate && review.date"
161
+ class="dcs-review-card__date"
162
+ :datetime="review.date"
163
+ >
164
+ {{ formatDate(review.date) }}
165
+ </time>
166
+
167
+ <div v-if="showReply && review.replyText" class="dcs-review-card__reply">
168
+ <span class="dcs-review-card__reply-label">Response:</span>
169
+ <p class="dcs-review-card__reply-text">{{ review.replyText }}</p>
170
+ </div>
171
+ </article>
172
+ </div>
173
+ </template>
174
+
175
+ <div v-else-if="showEmptyState" class="dcs-review-showcase__empty">
176
+ <p>Click to add reviews from Google or Meta</p>
177
+ </div>
178
+ </div>
179
+ </template>
180
+
181
+ <style scoped>
182
+ .dcs-review-grid {
183
+ display: grid;
184
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
185
+ gap: 1.5rem;
186
+ }
187
+
188
+ .dcs-review-list {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 1rem;
192
+ }
193
+
194
+ .dcs-review-carousel {
195
+ display: flex;
196
+ gap: 1.5rem;
197
+ overflow-x: auto;
198
+ scroll-snap-type: x mandatory;
199
+ -webkit-overflow-scrolling: touch;
200
+ padding-bottom: 0.5rem;
201
+ }
202
+
203
+ .dcs-review-carousel .dcs-review-card {
204
+ min-width: 300px;
205
+ scroll-snap-align: start;
206
+ }
207
+
208
+ .dcs-review-card {
209
+ background: var(--dcs-review-card-bg, #ffffff);
210
+ border: 1px solid var(--dcs-review-card-border, #e5e7eb);
211
+ border-radius: var(--dcs-review-card-radius, 0.75rem);
212
+ padding: var(--dcs-review-card-padding, 1.5rem);
213
+ transition: box-shadow 0.2s ease;
214
+ }
215
+
216
+ .dcs-review-card:hover {
217
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
218
+ }
219
+
220
+ .dcs-review-card__header {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 0.75rem;
224
+ margin-bottom: 0.75rem;
225
+ }
226
+
227
+ .dcs-review-card__avatar {
228
+ width: 2.5rem;
229
+ height: 2.5rem;
230
+ border-radius: 9999px;
231
+ overflow: hidden;
232
+ flex-shrink: 0;
233
+ background: var(--dcs-review-avatar-bg, #e5e7eb);
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ }
238
+
239
+ .dcs-review-card__avatar-img {
240
+ width: 100%;
241
+ height: 100%;
242
+ object-fit: cover;
243
+ }
244
+
245
+ .dcs-review-card__author-info {
246
+ display: flex;
247
+ flex-direction: column;
248
+ min-width: 0;
249
+ }
250
+
251
+ .dcs-review-card__author-name {
252
+ font-weight: 600;
253
+ font-size: 0.9rem;
254
+ color: var(--dcs-review-author-color, #111827);
255
+ }
256
+
257
+ .dcs-review-card__platform {
258
+ font-size: 0.75rem;
259
+ color: var(--dcs-review-platform-color, #6b7280);
260
+ }
261
+
262
+ .dcs-review-card__rating {
263
+ display: flex;
264
+ gap: 0.125rem;
265
+ margin-bottom: 0.5rem;
266
+ }
267
+
268
+ .dcs-review-card__star {
269
+ flex-shrink: 0;
270
+ }
271
+
272
+ .dcs-review-card__star--filled {
273
+ fill: var(--dcs-review-star-filled, #f59e0b);
274
+ color: var(--dcs-review-star-filled, #f59e0b);
275
+ }
276
+
277
+ .dcs-review-card__star--empty {
278
+ fill: var(--dcs-review-star-empty, #d1d5db);
279
+ color: var(--dcs-review-star-empty, #d1d5db);
280
+ }
281
+
282
+ .dcs-review-card__text {
283
+ font-size: 0.9rem;
284
+ line-height: 1.6;
285
+ color: var(--dcs-review-text-color, #374151);
286
+ margin: 0 0 0.75rem;
287
+ }
288
+
289
+ .dcs-review-card__date {
290
+ font-size: 0.75rem;
291
+ color: var(--dcs-review-date-color, #9ca3af);
292
+ }
293
+
294
+ .dcs-review-card__reply {
295
+ margin-top: 0.75rem;
296
+ padding: 0.75rem;
297
+ background: var(--dcs-review-reply-bg, #f9fafb);
298
+ border-radius: 0.5rem;
299
+ border-left: 3px solid var(--dcs-review-reply-border, #3b82f6);
300
+ }
301
+
302
+ .dcs-review-card__reply-label {
303
+ font-size: 0.75rem;
304
+ font-weight: 600;
305
+ color: var(--dcs-review-reply-label-color, #3b82f6);
306
+ }
307
+
308
+ .dcs-review-card__reply-text {
309
+ font-size: 0.85rem;
310
+ color: var(--dcs-review-reply-text-color, #4b5563);
311
+ margin: 0.25rem 0 0;
312
+ }
313
+
314
+ .dcs-review-showcase__empty {
315
+ padding: 2rem;
316
+ text-align: center;
317
+ color: var(--dcs-review-empty-color, #9ca3af);
318
+ border: 2px dashed var(--dcs-review-empty-border, #e5e7eb);
319
+ border-radius: 0.75rem;
320
+ }
321
+ </style>