@duffcloudservices/cms 0.3.10 → 0.3.11

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,326 @@
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>
@@ -2,3 +2,9 @@ export { useTextContent } from './useTextContent'
2
2
  export { useSEO, createSiteSEO } from './useSEO'
3
3
  export { useReleaseNotes } from './useReleaseNotes'
4
4
  export { useSiteVersion } from './useSiteVersion'
5
+ export {
6
+ useReviewContent,
7
+ type ReviewItem,
8
+ type UseReviewContentConfig,
9
+ type UseReviewContentReturn,
10
+ } from './useReviewContent'
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Composable for reading curated review selections from DCS content.
3
+ * Reviews are stored in content.yaml by the visual editor's ReviewPickerSheet.
4
+ */
5
+ import { computed, type ComputedRef } from 'vue'
6
+
7
+ export interface ReviewItem {
8
+ id: string
9
+ platform: 'google' | 'meta' | string
10
+ rating: number
11
+ authorName: string
12
+ authorPhotoUrl?: string
13
+ text?: string
14
+ date?: string
15
+ replyText?: string
16
+ locationName?: string
17
+ sourceUrl?: string
18
+ }
19
+
20
+ export interface UseReviewContentConfig {
21
+ /** The section key matching the data-dcs-reviews attribute value */
22
+ sectionKey: string
23
+ /** Page slug for page-specific content lookup (defaults to current page) */
24
+ pageSlug?: string
25
+ /** Fallback reviews when no content is available */
26
+ defaults?: ReviewItem[]
27
+ }
28
+
29
+ export interface UseReviewContentReturn {
30
+ /** The curated review items from content */
31
+ reviews: ComputedRef<ReviewItem[]>
32
+ /** Whether any reviews are available */
33
+ hasReviews: ComputedRef<boolean>
34
+ /** Number of reviews */
35
+ count: ComputedRef<number>
36
+ }
37
+
38
+ // Declare the global content variable injected by dcsContentPlugin
39
+ declare const __DCS_CONTENT__: {
40
+ global?: Record<string, unknown>
41
+ pages?: Record<string, Record<string, unknown>>
42
+ } | undefined
43
+
44
+ export function useReviewContent(config: UseReviewContentConfig): UseReviewContentReturn {
45
+ const { sectionKey, pageSlug, defaults = [] } = config
46
+
47
+ const reviews = computed<ReviewItem[]>(() => {
48
+ if (typeof __DCS_CONTENT__ === 'undefined' || __DCS_CONTENT__ == null) {
49
+ return defaults
50
+ }
51
+
52
+ let reviewData: unknown = null
53
+
54
+ if (pageSlug && __DCS_CONTENT__.pages?.[pageSlug]) {
55
+ reviewData = __DCS_CONTENT__.pages[pageSlug][`reviews.${sectionKey}.items`]
56
+ ?? __DCS_CONTENT__.pages[pageSlug][`reviews.${sectionKey}`]
57
+ }
58
+
59
+ if (!reviewData && __DCS_CONTENT__.global) {
60
+ reviewData = __DCS_CONTENT__.global[`reviews.${sectionKey}.items`]
61
+ ?? __DCS_CONTENT__.global[`reviews.${sectionKey}`]
62
+ }
63
+
64
+ if (!reviewData || !Array.isArray(reviewData)) {
65
+ return defaults
66
+ }
67
+
68
+ return reviewData
69
+ .filter((item): item is Record<string, unknown> => (
70
+ item != null &&
71
+ typeof item === 'object' &&
72
+ 'id' in item
73
+ ))
74
+ .map((item) => ({
75
+ id: String(item.id ?? ''),
76
+ platform: String(item.platform ?? 'google'),
77
+ rating: Number(item.rating ?? 5),
78
+ authorName: String(item.authorName ?? 'Anonymous'),
79
+ authorPhotoUrl: item.authorPhotoUrl ? String(item.authorPhotoUrl) : undefined,
80
+ text: item.text ? String(item.text) : undefined,
81
+ date: item.date ? String(item.date) : undefined,
82
+ replyText: item.replyText ? String(item.replyText) : undefined,
83
+ locationName: item.locationName ? String(item.locationName) : undefined,
84
+ sourceUrl: item.sourceUrl ? String(item.sourceUrl) : undefined,
85
+ }))
86
+ })
87
+
88
+ const hasReviews = computed(() => reviews.value.length > 0)
89
+ const count = computed(() => reviews.value.length)
90
+
91
+ return { reviews, hasReviews, count }
92
+ }