@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.
- package/dist/editor/editorBridge.d.ts +8 -0
- package/dist/editor/editorBridge.js +194 -3
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.js +34 -1
- package/dist/index.js.map +1 -1
- package/package.json +90 -83
- package/src/components/DcsReviewShowcase.vue +326 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useReviewContent.ts +92 -0
|
@@ -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>
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
+
}
|