Skip to content

Commit 67d87c4

Browse files
authored
Merge pull request #587 from Whats-Cookin/website-testimonial
Website testimonial
2 parents 49c72e9 + 5470522 commit 67d87c4

File tree

11 files changed

+2006
-8
lines changed

11 files changed

+2006
-8
lines changed

public/embed/linkedtrust-badge.js

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/**
2+
* LinkedTrust Badge Web Component
3+
*
4+
* Usage:
5+
* <script src="https://live.linkedtrust.us/embed/linkedtrust-badge.js"></script>
6+
* <linkedtrust-badge claim-id="123"></linkedtrust-badge>
7+
*
8+
* Attributes:
9+
* claim-id: Required. The claim ID to display.
10+
* theme: Optional. "light" (default) or "dark"
11+
* show-video: Optional. "true" to show video testimonial if available (default: true)
12+
*/
13+
14+
(function() {
15+
const API_BASE = 'https://live.linkedtrust.us/api';
16+
const SITE_BASE = 'https://live.linkedtrust.us';
17+
18+
class LinkedTrustBadge extends HTMLElement {
19+
constructor() {
20+
super();
21+
this.attachShadow({ mode: 'open' });
22+
this._videoExpanded = false;
23+
}
24+
25+
static get observedAttributes() {
26+
return ['claim-id', 'theme', 'show-video'];
27+
}
28+
29+
connectedCallback() {
30+
this.render();
31+
this.loadData();
32+
}
33+
34+
attributeChangedCallback() {
35+
this.render();
36+
this.loadData();
37+
}
38+
39+
get claimId() {
40+
return this.getAttribute('claim-id');
41+
}
42+
43+
get theme() {
44+
return this.getAttribute('theme') || 'light';
45+
}
46+
47+
get showVideo() {
48+
return this.getAttribute('show-video') !== 'false'; // Default true
49+
}
50+
51+
async loadData() {
52+
if (!this.claimId) {
53+
this.renderError('No claim-id specified');
54+
return;
55+
}
56+
57+
try {
58+
// Fetch claim data
59+
const claimRes = await fetch(`${API_BASE}/claims/${this.claimId}`);
60+
if (!claimRes.ok) throw new Error('Claim not found');
61+
const claimData = await claimRes.json();
62+
63+
// Fetch validation count and videos
64+
let validationCount = 0;
65+
let videos = [];
66+
try {
67+
const reportRes = await fetch(`${API_BASE}/reports/claim/${this.claimId}`);
68+
if (reportRes.ok) {
69+
const reportData = await reportRes.json();
70+
validationCount = reportData.validations?.length || 0;
71+
// Get videos from images array
72+
videos = (reportData.images || []).filter(img =>
73+
img.metadata?.type === 'video' || img.url?.includes('.webm') || img.url?.includes('.mp4')
74+
);
75+
}
76+
} catch (e) {
77+
// No validations/videos
78+
}
79+
80+
this.renderBadge(claimData.claim, validationCount, videos);
81+
} catch (error) {
82+
this.renderError('Could not load testimonial');
83+
}
84+
}
85+
86+
renderError(message) {
87+
const isDark = this.theme === 'dark';
88+
this.shadowRoot.innerHTML = `
89+
<style>
90+
:host {
91+
display: inline-block;
92+
}
93+
.badge {
94+
padding: 16px 24px;
95+
border-radius: 8px;
96+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
97+
background: ${isDark ? '#2a2a2a' : '#f5f5f5'};
98+
color: ${isDark ? '#888' : '#666'};
99+
font-size: 14px;
100+
}
101+
</style>
102+
<div class="badge">${message}</div>
103+
`;
104+
}
105+
106+
renderBadge(claim, validationCount, videos = []) {
107+
const isDark = this.theme === 'dark';
108+
const statement = claim.statement || 'Verified testimonial';
109+
const truncatedStatement = statement.length > 120
110+
? statement.substring(0, 120).trim() + '...'
111+
: statement;
112+
const stars = claim.stars || 0;
113+
const certificateUrl = `${SITE_BASE}/certificate/${this.claimId}`;
114+
const hasVideo = this.showVideo && videos.length > 0;
115+
const videoUrl = hasVideo ? videos[0].url : null;
116+
117+
// Generate star HTML
118+
const starHtml = stars > 0 ? `
119+
<div class="stars">
120+
${Array(5).fill(0).map((_, i) => `
121+
<svg class="star ${i < stars ? 'filled' : ''}" viewBox="0 0 24 24" width="18" height="18">
122+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
123+
</svg>
124+
`).join('')}
125+
</div>
126+
` : '';
127+
128+
// Video section HTML
129+
const videoHtml = hasVideo ? `
130+
<div class="video-section" id="video-section">
131+
<div class="video-preview" id="video-preview">
132+
<div class="play-button" id="play-btn">
133+
<svg viewBox="0 0 24 24" width="48" height="48">
134+
<circle cx="12" cy="12" r="11" fill="rgba(0,0,0,0.6)"/>
135+
<path d="M10 8l6 4-6 4V8z" fill="white"/>
136+
</svg>
137+
</div>
138+
<span class="video-label">Video Testimonial</span>
139+
</div>
140+
<div class="video-player" id="video-player" style="display: none;">
141+
<video id="video" controls playsinline>
142+
<source src="${videoUrl}" type="video/webm">
143+
</video>
144+
<button class="close-video" id="close-btn">×</button>
145+
</div>
146+
</div>
147+
` : '';
148+
149+
this.shadowRoot.innerHTML = `
150+
<style>
151+
:host {
152+
display: inline-block;
153+
}
154+
.badge-container {
155+
max-width: 360px;
156+
}
157+
.badge {
158+
background: ${isDark
159+
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
160+
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'};
161+
color: white;
162+
padding: 20px 24px;
163+
border-radius: ${hasVideo ? '12px 12px 0 0' : '12px'};
164+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
165+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
166+
text-decoration: none;
167+
display: block;
168+
transition: transform 0.2s, box-shadow 0.2s;
169+
cursor: pointer;
170+
}
171+
.badge:hover {
172+
transform: translateY(-2px);
173+
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
174+
}
175+
.header {
176+
display: flex;
177+
align-items: center;
178+
gap: 8px;
179+
margin-bottom: 12px;
180+
}
181+
.verified-icon {
182+
width: 18px;
183+
height: 18px;
184+
fill: currentColor;
185+
opacity: 0.9;
186+
}
187+
.verified-text {
188+
font-size: 12px;
189+
font-weight: 600;
190+
text-transform: uppercase;
191+
letter-spacing: 0.5px;
192+
opacity: 0.9;
193+
}
194+
.endorsements {
195+
margin-left: auto;
196+
background: rgba(255,255,255,0.2);
197+
padding: 4px 8px;
198+
border-radius: 4px;
199+
font-size: 11px;
200+
}
201+
.statement {
202+
font-size: 18px;
203+
line-height: 1.5;
204+
margin-bottom: 12px;
205+
font-weight: 500;
206+
}
207+
.stars {
208+
display: flex;
209+
gap: 2px;
210+
margin-bottom: 12px;
211+
}
212+
.star {
213+
fill: rgba(255,255,255,0.3);
214+
}
215+
.star.filled {
216+
fill: #ffc107;
217+
}
218+
.footer {
219+
display: flex;
220+
align-items: center;
221+
justify-content: flex-end;
222+
font-size: 12px;
223+
opacity: 0.8;
224+
}
225+
.footer svg {
226+
width: 14px;
227+
height: 14px;
228+
margin-left: 4px;
229+
fill: currentColor;
230+
}
231+
232+
/* Video section styles */
233+
.video-section {
234+
background: ${isDark ? '#0d0d1a' : '#1a1a2e'};
235+
border-radius: 0 0 12px 12px;
236+
overflow: hidden;
237+
}
238+
.video-preview {
239+
padding: 16px;
240+
display: flex;
241+
align-items: center;
242+
gap: 12px;
243+
cursor: pointer;
244+
transition: background 0.2s;
245+
}
246+
.video-preview:hover {
247+
background: rgba(255,255,255,0.05);
248+
}
249+
.play-button {
250+
flex-shrink: 0;
251+
}
252+
.play-button svg {
253+
display: block;
254+
}
255+
.video-label {
256+
color: rgba(255,255,255,0.9);
257+
font-family: system-ui, sans-serif;
258+
font-size: 14px;
259+
font-weight: 500;
260+
}
261+
.video-player {
262+
position: relative;
263+
background: #000;
264+
}
265+
.video-player video {
266+
width: 100%;
267+
display: block;
268+
max-height: 240px;
269+
}
270+
.close-video {
271+
position: absolute;
272+
top: 8px;
273+
right: 8px;
274+
width: 28px;
275+
height: 28px;
276+
border-radius: 50%;
277+
border: none;
278+
background: rgba(0,0,0,0.6);
279+
color: white;
280+
font-size: 18px;
281+
cursor: pointer;
282+
display: flex;
283+
align-items: center;
284+
justify-content: center;
285+
}
286+
.close-video:hover {
287+
background: rgba(0,0,0,0.8);
288+
}
289+
</style>
290+
<div class="badge-container">
291+
<a href="${certificateUrl}" target="_blank" rel="noopener noreferrer" class="badge">
292+
<div class="header">
293+
<svg class="verified-icon" viewBox="0 0 24 24">
294+
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
295+
</svg>
296+
<span class="verified-text">Verified Testimonial</span>
297+
${validationCount > 0 ? `<span class="endorsements">${validationCount} endorsement${validationCount > 1 ? 's' : ''}</span>` : ''}
298+
</div>
299+
<div class="statement">"${truncatedStatement}"</div>
300+
${starHtml}
301+
<div class="footer">
302+
<span>View on LinkedTrust</span>
303+
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
304+
</div>
305+
</a>
306+
${videoHtml}
307+
</div>
308+
`;
309+
310+
// Add video interaction handlers
311+
if (hasVideo) {
312+
const playBtn = this.shadowRoot.getElementById('play-btn');
313+
const videoPreview = this.shadowRoot.getElementById('video-preview');
314+
const videoPlayer = this.shadowRoot.getElementById('video-player');
315+
const video = this.shadowRoot.getElementById('video');
316+
const closeBtn = this.shadowRoot.getElementById('close-btn');
317+
318+
const showVideo = (e) => {
319+
e.preventDefault();
320+
e.stopPropagation();
321+
videoPreview.style.display = 'none';
322+
videoPlayer.style.display = 'block';
323+
video.play();
324+
};
325+
326+
const hideVideo = (e) => {
327+
e.preventDefault();
328+
e.stopPropagation();
329+
video.pause();
330+
videoPlayer.style.display = 'none';
331+
videoPreview.style.display = 'flex';
332+
};
333+
334+
videoPreview.addEventListener('click', showVideo);
335+
closeBtn.addEventListener('click', hideVideo);
336+
}
337+
}
338+
339+
render() {
340+
const isDark = this.theme === 'dark';
341+
this.shadowRoot.innerHTML = `
342+
<style>
343+
:host {
344+
display: inline-block;
345+
}
346+
.loading {
347+
padding: 20px 24px;
348+
border-radius: 12px;
349+
background: ${isDark
350+
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
351+
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'};
352+
color: white;
353+
font-family: system-ui, sans-serif;
354+
font-size: 14px;
355+
max-width: 320px;
356+
}
357+
</style>
358+
<div class="loading">Loading testimonial...</div>
359+
`;
360+
}
361+
}
362+
363+
// Register the custom element
364+
if (!customElements.get('linkedtrust-badge')) {
365+
customElements.define('linkedtrust-badge', LinkedTrustBadge);
366+
}
367+
})();

src/App.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import Privacy from './containers/Privacy'
2121
import { ClaimCredential } from './containers/ClaimCredential'
2222
import { checkAuth } from './utils/authUtils'
2323
import CertificateView from './components/Certificate/CertificateView'
24+
import Present from './components/Present'
25+
import RequestRating from './components/RequestRating'
2426
import './App.css'
2527

2628
const App = () => {
@@ -167,8 +169,19 @@ const App = () => {
167169
isAuthenticated ? <ClaimCredential /> : <Navigate to='/login' replace state={{ from: location }} />
168170
}
169171
/>
170-
<Route path='/certificate/:id' element={<CertificateView />} /> {/* Alias for common typo */}
171-
<Route path='/certificatet/:id' element={<CertificateView />} />
172+
<Route path='/certificate/:id' element={<CertificateView />} />
173+
<Route path='/certificatet/:id' element={<CertificateView />} /> {/* Alias for common typo */}
174+
<Route path='/present/:id' element={<Present />} />
175+
<Route
176+
path='/request-rating'
177+
element={
178+
isAuthenticated ? (
179+
<RequestRating />
180+
) : (
181+
<Navigate to='/login' replace state={{ from: location }} />
182+
)
183+
}
184+
/>
172185
{/* Catch-all to avoid blank pages */}
173186
<Route path='*' element={<Navigate to='/feed' replace />} />
174187
</Routes>

0 commit comments

Comments
 (0)