widget improvement + install for customer sites guideline

This commit is contained in:
2026-09-15 12:56:13 +03:30
parent 1378dcdd11
commit a4815b37b3
2 changed files with 358 additions and 117 deletions

View File

@@ -1,59 +1,54 @@
import { Controller, Get, Query, Header, BadRequestException, Req, Res } from '@nestjs/common'; import { Controller, Get, Query, BadRequestException, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { Public } from 'src/auth/auth.decorator'; import { Public } from 'src/auth/auth.decorator';
import { WidgetService } from './widget.service'; import { WidgetService } from './widget.service';
@Controller('widget') @Controller('widget')
@Public() @Public()
export class WidgetController { export class WidgetController {
constructor(private readonly widgetService: WidgetService) { } constructor(private readonly widgetService: WidgetService) {}
@Get('script') @Get('script')
@Header('Content-Type', 'application/javascript')
@Header('Access-Control-Allow-Origin', 'https://staging.hdmplus.ir/, https://si24.ir')
@Header('X-Frame-Options', 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir')
@Header(
'Content-Security-Policy',
"frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir",
)
@Header('X-Content-Type-Options', 'nosniff')
getWidgetScript( getWidgetScript(
@Query('apiKey') apiKey: string, @Query('apiKey') apiKey: string,
@Req() req: Request, @Req() req: Request,
): string { @Res() res: Response,
if (!apiKey) { ) {
throw new BadRequestException('API key is required'); if (!this.widgetService.isValidApiKey(apiKey)) {
throw new BadRequestException(
apiKey ? 'Invalid API key' : 'API key is required',
);
} }
const isValidApiKey = this.widgetService.applyScriptHeaders(req, res);
Buffer.from(apiKey).length === Buffer.from('si24samanwebsite').length && return res.send(this.widgetService.generateWidgetScript());
Buffer.from(apiKey).compare(Buffer.from('si24samanwebsite')) === 0;
if (!isValidApiKey) {
throw new BadRequestException('Invalid API key');
}
return this.widgetService.generateWidgetScript();
} }
@Get('iframe') @Get('iframe')
async getIframeScript( getIframeScript(
@Query('apiKey') apiKey: string, @Query('apiKey') apiKey: string,
@Req() req: Request, @Req() req: Request,
@Res() res, @Res() res: Response,
) { ) {
if (!apiKey || apiKey !== 'si24samanwebsite') { if (!this.widgetService.isValidApiKey(apiKey)) {
throw new BadRequestException('API key is invalid or missing'); throw new BadRequestException('API key is invalid or missing');
} }
res.set({ this.widgetService.applyScriptHeaders(req, res);
'Content-Type': 'application/javascript', return res.send(this.widgetService.generateWidgetScriptWithoutStyle());
'Access-Control-Allow-Origin': 'https://staging.hdmplus.ir/, https://si24.ir',
'X-Frame-Options': 'ALLOW-FROM https://staging.hdmplus.ir/ https://si24.ir',
'Content-Security-Policy': "frame-ancestors 'self' https://staging.hdmplus.ir/ https://si24.ir",
'X-Content-Type-Options': 'nosniff',
});
const script = this.widgetService.generateWidgetScriptWithoutStyle();
return res.send(script);
} }
}
@Get('demo')
getWidgetDemo(@Res() res: Response) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'no-store');
return res.send(this.widgetService.generateDemoHtml());
}
@Get('frame')
getWidgetFrame(@Res() res: Response) {
this.widgetService.applyFrameHeaders(res);
return res.send(this.widgetService.generateFrameHtml());
}
}

View File

@@ -1,27 +1,90 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Request, Response } from 'express';
@Injectable() @Injectable()
export class WidgetService { export class WidgetService {
/** Full iframe src, may include a path (e.g. /widget/frame). */
private readonly frontendUrl: string; private readonly frontendUrl: string;
constructor(private readonly configService: ConfigService) { /** Origin only — required by postMessage targetOrigin. */
this.frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'https://chatbot.si24.ir'; private readonly frontendOrigin: string;
// Khahesh mikonam private readonly apiKey: string;
// 1. az env estefadeh konid private readonly iconUrl: string;
// 2. agar chizi hardcode hast hamahang konid private readonly allowedParentOrigins: string[];
// -------- az env estefade shude ---------
// c------ chiziam hardcode nist ----
constructor(private readonly configService: ConfigService) {
this.frontendUrl = WidgetService.requireUrl(
this.configService.get<string>('FRONTEND_URL'),
'FRONTEND_URL',
);
this.frontendOrigin = new URL(this.frontendUrl).origin;
this.apiKey = WidgetService.requireValue(
this.configService.get<string>('WIDGET_API_KEY'),
'WIDGET_API_KEY',
);
this.iconUrl =
WidgetService.optionalValue(
this.configService.get<string>('WIDGET_ICON_URL'),
) || WidgetService.defaultIconDataUri();
this.allowedParentOrigins = WidgetService.parseOrigins(
this.configService.get<string>('WIDGET_ALLOWED_ORIGINS'),
);
}
isValidApiKey(apiKey?: string): boolean {
return Boolean(apiKey) && apiKey === this.apiKey;
}
/**
* Browsers accept only a single origin in Access-Control-Allow-Origin.
* Echo the request Origin when it is in WIDGET_ALLOWED_ORIGINS (or FRONTEND_URL).
*
* Do not set CSP frame-ancestors here. That header answers "who may iframe
* THIS response?" Nobody iframes the JS file. Who may iframe the SPA is
* decided by chatbot HTML/nginx, e.g.:
* Content-Security-Policy: frame-ancestors https://anothersite.com
*
* Previous loader header (wrong place):
* res.setHeader(
* 'Content-Security-Policy',
* `frame-ancestors 'self' ${this.allowedParentOrigins.join(' ')}`,
* );
*/
applyScriptHeaders(req: Request, res: Response): void {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('X-Content-Type-Options', 'nosniff');
const origin = WidgetService.normalizeOrigin(req.headers.origin);
if (origin && this.corsOrigins().includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Cache-Control', 'public, max-age=300');
}
/**
* Headers for the document that sits inside the widget iframe.
* Global middleware sets X-Frame-Options: DENY; that must be removed
* or the panel stays blank.
*/
applyFrameHeaders(res: Response): void {
res.removeHeader('X-Frame-Options');
const ancestors = ["'self'", ...this.allowedParentOrigins].join(' ');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors}`);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'no-store');
} }
generateIframe(): string { generateIframe(): string {
const frontendUrl = JSON.stringify(this.frontendUrl);
return ` return `
(function () { (function () {
if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return; if (window.samanChatbotLoaded || document.getElementById('saman-widget-iframe')) return;
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.id = 'saman-widget-iframe'; iframe.id = 'saman-widget-iframe';
iframe.src = '${this.frontendUrl}'; iframe.src = ${frontendUrl};
iframe.style.position = 'fixed'; iframe.style.position = 'fixed';
iframe.style.top = '0'; iframe.style.top = '0';
iframe.style.left = '0'; iframe.style.left = '0';
@@ -40,7 +103,51 @@ export class WidgetService {
})(); })();
`; `;
} }
/**
* Previous iframe attributes (kept for rollback if third-party cookies
* break again). Added while trying to make login/storage work when the
* SPA is embedded on another site. `credentialless="false"` is a boolean
* HTML attribute: presence turns it ON, so it could strip cookies.
* The second `allow` also overwrote microphone/camera.
*
* iframe.allow = 'microphone; camera; storage-access-api';
* iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
* iframe.setAttribute('loading', 'lazy');
* iframe.setAttribute('credentialless', 'false');
* iframe.setAttribute('allow', 'storage-access-api *');
*
* Parent-document Storage Access API (removed). Parent site
* (e.g. anothername.com) auth is unrelated to chatbot iframe auth.
* requestStorageAccess() only helps if the iframe document calls it.
* The loader still postMessages REQUEST_STORAGE_ACCESS into the iframe.
*
* if (event.data.type === 'REQUEST_STORAGE_ACCESS' &&
* 'requestStorageAccess' in document &&
* typeof document.requestStorageAccess === 'function') {
* document.requestStorageAccess().then(() => {
* iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_GRANTED' }, event.origin);
* }).catch(() => {
* iframe.contentWindow.postMessage({ type: 'STORAGE_ACCESS_DENIED' }, event.origin);
* });
* }
*
* Eager iframe (removed): iframe was created and src set on script load.
* loading=lazy does not help a position:fixed iframe. SPA now loads on
* first bubble click via ensureIframe().
*
* Parent navigation (removed). Widget must not send anothersite.com
* elsewhere via postMessage:
* if (event.data.type === 'REDIRECT_REQUEST') {
* window.top.location.href = event.data.url;
* }
*/
generateWidgetScript(): string { generateWidgetScript(): string {
const frontendUrl = JSON.stringify(this.frontendUrl);
const frontendOrigin = JSON.stringify(this.frontendOrigin);
const iconHtml = JSON.stringify(
`<img src="${this.iconUrl}" alt="chat">`,
);
const allowedOrigins = JSON.stringify(this.messageOrigins());
return ` return `
(function () { (function () {
if (window.samanChatbotLoaded) return; if (window.samanChatbotLoaded) return;
@@ -105,29 +212,44 @@ export class WidgetService {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.id = 'saman-widget-btn'; btn.id = 'saman-widget-btn';
btn.innerHTML = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" alt="chat">'; btn.innerHTML = ${iconHtml};
document.body.appendChild(btn); document.body.appendChild(btn);
const iframe = document.createElement('iframe'); let iframe = null;
iframe.id = 'saman-widget-iframe';
iframe.src = '${this.frontendUrl}';
iframe.allow = 'microphone; camera; storage-access-api';
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
iframe.setAttribute('loading', 'lazy');
iframe.setAttribute('credentialless', 'false');
iframe.setAttribute('allow', 'storage-access-api *');
document.body.appendChild(iframe);
let closeBtn; let closeBtn;
const allowedOrigins = ${allowedOrigins};
function ensureIframe() {
if (iframe) return iframe;
iframe = document.createElement('iframe');
iframe.id = 'saman-widget-iframe';
iframe.allow = 'microphone; camera; storage-access-api';
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups allow-storage-access-by-user-activation';
iframe.addEventListener('load', function() {
if (!iframe.contentWindow) return;
iframe.contentWindow.postMessage({
type: 'IFRAME_LOADED'
}, ${frontendOrigin});
iframe.contentWindow.postMessage({
type: 'REQUEST_STORAGE_ACCESS'
}, ${frontendOrigin});
});
document.body.appendChild(iframe);
iframe.src = ${frontendUrl};
return iframe;
}
function openWidget() { function openWidget() {
iframe.classList.add('visible'); const alreadyCreated = Boolean(iframe);
const frame = ensureIframe();
frame.classList.add('visible');
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
iframe.style.top = '0'; frame.style.top = '0';
iframe.style.left = '0'; frame.style.left = '0';
iframe.style.width = '100%'; frame.style.width = '100%';
iframe.style.height = '100%'; frame.style.height = '100%';
iframe.style.borderRadius = '0'; frame.style.borderRadius = '0';
closeBtn = document.createElement('button'); closeBtn = document.createElement('button');
closeBtn.id = 'saman-close-btn'; closeBtn.id = 'saman-close-btn';
@@ -142,20 +264,16 @@ export class WidgetService {
btn.style.setProperty('pointer-events', 'none', 'important'); btn.style.setProperty('pointer-events', 'none', 'important');
} }
setTimeout(() => { if (alreadyCreated && frame.contentWindow) {
if (iframe.contentWindow && frame.contentWindow.postMessage({
'requestStorageAccess' in document && type: 'REQUEST_STORAGE_ACCESS'
typeof document.requestStorageAccess === 'function') { }, ${frontendOrigin});
iframe.contentWindow.postMessage({ }
type: 'REQUEST_STORAGE_ACCESS'
}, '${this.frontendUrl}');
}
}, 1000);
} }
function closeWidget() { function closeWidget() {
iframe.classList.remove('visible'); if (iframe) iframe.classList.remove('visible');
if (closeBtn) { if (closeBtn) {
closeBtn.remove(); closeBtn.remove();
closeBtn = null; closeBtn = null;
@@ -163,21 +281,19 @@ export class WidgetService {
document.body.style.overflow = ''; document.body.style.overflow = '';
document.documentElement.style.overflow = ''; document.documentElement.style.overflow = '';
// Show the widget button again when closing
btn.style.setProperty('visibility', 'visible', 'important'); btn.style.setProperty('visibility', 'visible', 'important');
btn.style.setProperty('opacity', '1', 'important'); btn.style.setProperty('opacity', '1', 'important');
btn.style.setProperty('pointer-events', 'auto', 'important'); btn.style.setProperty('pointer-events', 'auto', 'important');
} }
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
if (iframe.classList.contains('visible')) closeWidget(); if (iframe && iframe.classList.contains('visible')) closeWidget();
else openWidget(); else openWidget();
}); });
const allowedOrigins = ['https://chatbot.si24.ir', 'https://staging.hdmplus.ir'];
window.addEventListener('message', function(event) { window.addEventListener('message', function(event) {
if (!allowedOrigins.includes(event.origin)) return; if (!allowedOrigins.includes(event.origin)) return;
if (!iframe || !iframe.contentWindow) return;
if (event.data.type === 'REQUEST_DATA') { if (event.data.type === 'REQUEST_DATA') {
iframe.contentWindow.postMessage({ iframe.contentWindow.postMessage({
@@ -185,37 +301,15 @@ export class WidgetService {
payload: event.data.payload payload: event.data.payload
}, event.origin); }, event.origin);
} }
if (event.data.type === 'REDIRECT_REQUEST') {
window.top.location.href = event.data.url;
}
if (event.data.type === 'REQUEST_STORAGE_ACCESS' &&
'requestStorageAccess' in document &&
typeof document.requestStorageAccess === 'function') {
document.requestStorageAccess().then(() => {
iframe.contentWindow.postMessage({
type: 'STORAGE_ACCESS_GRANTED'
}, event.origin);
}).catch(() => {
iframe.contentWindow.postMessage({
type: 'STORAGE_ACCESS_DENIED'
}, event.origin);
});
}
});
iframe.addEventListener('load', function() {
setTimeout(() => {
iframe.contentWindow.postMessage({
type: 'IFRAME_LOADED'
}, '${this.frontendUrl}');
}, 500);
}); });
})(); })();
`; `;
} }
generateWidgetScriptWithoutStyle(): string { generateWidgetScriptWithoutStyle(): string {
const frontendUrl = JSON.stringify(this.frontendUrl);
const iconHtml = JSON.stringify(
`<img src="${this.iconUrl}" style="width:22px;height:22px">`,
);
return ` return `
(function() { (function() {
if (window.samanChatbotLoaded) return; if (window.samanChatbotLoaded) return;
@@ -223,24 +317,31 @@ export class WidgetService {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease"; btn.style.cssText = "position:fixed;bottom:16px;right:16px;width:45px;height:45px;border-radius:50%;border:none;background:linear-gradient(135deg,#0062ff,#007bff);cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2147483647;transition:width 0.3s ease,height 0.3s ease";
btn.innerHTML = '<img src="https://cdn-icons-png.flaticon.com/512/4712/4712035.png" style="width:22px;height:22px">'; btn.innerHTML = ${iconHtml};
document.body.appendChild(btn); document.body.appendChild(btn);
const iframe = document.createElement('iframe'); let iframe = null;
iframe.src = '${this.frontendUrl}';
iframe.allow = 'microphone;camera';
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups';
iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)";
document.body.appendChild(iframe);
let closeBtn; let closeBtn;
function ensureIframe() {
if (iframe) return iframe;
iframe = document.createElement('iframe');
iframe.allow = 'microphone;camera';
iframe.sandbox = 'allow-same-origin allow-scripts allow-forms allow-popups';
iframe.style.cssText = "position:fixed;bottom:80px;right:16px;width:380px;height:600px;border:none;border-radius:12px;display:none;z-index:2147483646;box-shadow:0 10px 40px rgba(0,0,0,0.2)";
document.body.appendChild(iframe);
iframe.src = ${frontendUrl};
return iframe;
}
function openWidget() { function openWidget() {
iframe.style.display = 'block'; const frame = ensureIframe();
frame.style.display = 'block';
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
iframe.style.width = '100%'; frame.style.width = '100%';
iframe.style.height = '100%'; frame.style.height = '100%';
iframe.style.borderRadius = '0'; frame.style.borderRadius = '0';
closeBtn = document.createElement('button'); closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕'; closeBtn.innerHTML = '✕';
@@ -258,7 +359,7 @@ export class WidgetService {
} }
function closeWidget() { function closeWidget() {
iframe.style.display = 'none'; if (iframe) iframe.style.display = 'none';
if (closeBtn) { closeBtn.remove(); closeBtn = null; } if (closeBtn) { closeBtn.remove(); closeBtn = null; }
document.body.style.overflow = ''; document.body.style.overflow = '';
document.documentElement.style.overflow = ''; document.documentElement.style.overflow = '';
@@ -268,7 +369,7 @@ export class WidgetService {
} }
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
if (iframe.style.display === 'block') closeWidget(); if (iframe && iframe.style.display === 'block') closeWidget();
else openWidget(); else openWidget();
}); });
})(); })();
@@ -280,4 +381,149 @@ export class WidgetService {
getScript(): string { getScript(): string {
return `<script>${this.generateWidgetScript()}</script>`; return `<script>${this.generateWidgetScript()}</script>`;
} }
generateDemoHtml(): string {
const scriptSrc = `/widget/script?apiKey=${encodeURIComponent(this.apiKey)}`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Widget parent demo (anothersite.com)</title>
<style>
body { font-family: sans-serif; margin: 0; background: #f4f4f5; color: #18181b; }
header { background: #fff; padding: 16px 24px; border-bottom: 1px solid #e4e4e7; }
main { max-width: 720px; margin: 40px auto; padding: 0 24px; }
h1 { font-size: 22px; }
p { line-height: 1.5; color: #3f3f46; }
code { background: #e4e4e7; padding: 2px 6px; border-radius: 4px; }
.note { background: #fff; border: 1px solid #e4e4e7; padding: 16px; border-radius: 8px; }
</style>
</head>
<body>
<header>anothersite.com — fake customer page</header>
<main>
<h1>Parent site</h1>
<p>This page is the host (like example.com). Auth here is unrelated to chat login inside the widget.</p>
<div class="note">
<p>Look at the bottom-right bubble. Click it.</p>
<ul>
<li>Bubble only on load — pass</li>
<li>Panel opens on click — pass</li>
<li>This URL stays <code>/widget/demo</code> — pass (widget must not navigate the parent)</li>
</ul>
<p>Iframe src is <code>${this.escapeHtml(this.frontendUrl)}</code></p>
</div>
</main>
<script src="${this.escapeHtml(scriptSrc)}" async></script>
</body>
</html>`;
}
generateFrameHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Widget iframe stub</title>
<style>
body { font-family: sans-serif; margin: 0; background: #0f766e; color: #fff; }
.panel { padding: 16px; }
h1 { font-size: 16px; margin: 0 0 8px; }
p, li { font-size: 13px; line-height: 1.4; }
#log { margin-top: 12px; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 6px; font-family: monospace; font-size: 12px; min-height: 48px; }
</style>
</head>
<body>
<div class="panel">
<h1>Chat iframe loaded</h1>
<p>If you see this after clicking the bubble, the loader works. This stub is not the production SPA.</p>
<p>Set <code>FRONTEND_URL</code> to the real chatbot app when embedding on a customer site. That app must send <code>frame-ancestors</code> for the parent origin.</p>
<div id="log">waiting for postMessage…</div>
</div>
<script>
const log = document.getElementById('log');
window.addEventListener('message', function (event) {
const type = event.data && event.data.type;
log.textContent = 'from ' + event.origin + ' → ' + (type || JSON.stringify(event.data));
if (type === 'REQUEST_STORAGE_ACCESS' && document.requestStorageAccess) {
document.requestStorageAccess().catch(function () {});
}
});
</script>
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
private messageOrigins(): string[] {
return Array.from(
new Set([this.frontendOrigin, ...this.allowedParentOrigins]),
);
}
private corsOrigins(): string[] {
return this.messageOrigins();
}
private static requireUrl(raw: string | undefined, name: string): string {
const value = WidgetService.normalizeOrigin(raw);
if (!value || !/^https?:\/\//i.test(value)) {
throw new Error(`${name} environment variable not configured`);
}
return value;
}
private static requireValue(raw: string | undefined, name: string): string {
const value = WidgetService.optionalValue(raw);
if (!value) {
throw new Error(`${name} environment variable not configured`);
}
return value;
}
private static optionalValue(raw: string | undefined): string {
if (raw == null) return '';
let value = String(raw).trim();
if (
(value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1).trim();
}
return value;
}
private static parseOrigins(raw: string | undefined): string[] {
const value = WidgetService.optionalValue(raw);
if (!value) return [];
return Array.from(
new Set(
value
.split(',')
.map((item) => WidgetService.normalizeOrigin(item))
.filter((item): item is string => Boolean(item)),
),
);
}
private static normalizeOrigin(raw: string | undefined): string {
const value = WidgetService.optionalValue(raw);
if (!value) return '';
return value.replace(/\/+$/, '');
}
private static defaultIconDataUri(): string {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>';
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
} }