UNPKG

@etsoo/website

Version:

ETSOO CMS Based NextJs Website Framework

535 lines (462 loc) 16 kB
import { NotificationMessageType } from '@etsoo/notificationbase'; import { ApiDataError, createClient, IApi } from '@etsoo/restclient'; import { DataTypes, DomUtils, IActionResult, Utils } from '@etsoo/shared'; import { wxe } from '@etsoo/weixin'; import { INotifierContainer, NotifierContainer } from '../notifier/NotifierContainer'; import { SendEmailRQ } from '../rq/site/SendEmailRQ'; import { SiteUtils } from './SiteUtils'; type ScrollActions = { backToTopSelector?: string; backToTopThreshold?: number; stickyTopSelector?: string; stickyTopThreshold?: number; }; type SetupOptions = ScrollActions & { drawflowStyle?: string; drawVersion?: string; }; /** * Client site */ export class ClientSite { /** * API * 接口调用对象 */ readonly api: IApi; /** * Notifier * 通知器 */ readonly notifier: INotifierContainer; private _isReady = false; /** * Is ready * 是否准备就绪 */ get isReady() { return this._isReady; } private backToTopDispose?: () => void; private windowScrollDispose?: () => void; private formSubmitDispose?: () => void; /** * Constructor * 构造函数 * @param culture Culture, like en, zh-Hans * @param apiUrl Headless CMS API Url * @param errorHandler Custom error handler */ constructor( public readonly culture: 'en' | 'zh-Hans' | 'zh-Hant' | string, apiUrl: string, errorHandler?: (e: ApiDataError) => void ) { // Notifier this.notifier = new NotifierContainer(); // Default error hanlder errorHandler ??= (e) => this.notifier.message( NotificationMessageType.Danger, e.message, 'API error' ); const api = createClient(); api.baseUrl = apiUrl; api.onError = errorHandler; // Add content-language header api.setContentLanguage(culture); this.api = api; } /** * Dispose * 释放资源 */ dispose() { this.notifier.dispose(); if (this.backToTopDispose) { this.backToTopDispose(); this.backToTopDispose = undefined; } if (this.windowScrollDispose) { this.windowScrollDispose(); this.windowScrollDispose = undefined; } if (this.formSubmitDispose) { this.formSubmitDispose(); this.formSubmitDispose = undefined; } this._isReady = false; } /** * Get document meta content * 获取文档 Meta 内容 * @param name Name * @returns content */ getMeta(name: string) { return document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`) ?.content; } /** * Get document Open Graph * https://ogp.me/ * 获取文档 Open Graph 内容 * @param name Name * @returns content */ getOG(name: string) { return document.querySelector<HTMLMetaElement>( `meta[property="og:${name}"]` )?.content; } /** * Get Google reCaptcha token * @param action Action * @param callback Callback */ grecaptcha(action: string, callback: (token: string) => void) { if ( typeof grecaptcha == undefined || typeof globalThis.googleGecaptchaSiteKey == undefined ) { callback(''); return; } grecaptcha.ready(function () { grecaptcha .execute(globalThis.googleGecaptchaSiteKey, { action: action }) .then(function (token) { callback(token); }); }); } /** * Send email * 发送邮件 * @param rq Request data * @param api Function API * @returns Result */ async sendEmail(rq: SendEmailRQ, api = 'Public/SendEmail') { // Pass the JSON data if (typeof rq.data === 'object') rq.data = JSON.stringify(rq.data); // API call return await this.api.post<IActionResult<{ successMessage: string }>>( api, rq ); } /** * Setup * @param resources Custom resources * @param options Setup options */ setup(resources: DataTypes.StringRecord = {}, options?: SetupOptions) { // Spinner const spinner = document.getElementById('spinner'); spinner?.classList.remove('show'); // Check ready to avoid multiple setup if (this.isReady) return; // Destruct options const { drawflowStyle, drawVersion, ...actions } = options ?? {}; // Setup utils SiteUtils.setup(this.culture, resources); // Setup scroll actions this.setupScrollActions(actions); // Setup contact form this.setupContactForm(); // Setup drawflow viewer this.setupDrawflowViewer(drawflowStyle, drawVersion); // Ready this._isReady = true; } /** * Setup scroll actions * 设置滚动动作 * @param actions Scroll actions */ setupScrollActions(actions?: ScrollActions) { const { backToTopSelector = '.back-to-top', backToTopThreshold = 300, stickyTopSelector = '.sticky-top', stickyTopThreshold = 100 } = actions ?? {}; const stickyNav = document.querySelector<HTMLElement>(stickyTopSelector); const gotoTop = document.querySelector<HTMLElement>(backToTopSelector); if (gotoTop) { const backToTopHandler = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; gotoTop.addEventListener('click', backToTopHandler); this.backToTopDispose = () => { gotoTop.removeEventListener('click', backToTopHandler); }; } if (stickyNav || gotoTop) { const windowScrollHandler = () => { if (stickyNav) { if (window.scrollY > stickyTopThreshold) { stickyNav.style.top = '0px'; } else { stickyNav.style.top = `${-stickyTopThreshold}px`; } } if (gotoTop) { if (window.scrollY > backToTopThreshold) { gotoTop.style.display = 'flex'; } else { gotoTop.style.display = 'none'; } } }; window.addEventListener('scroll', windowScrollHandler); this.windowScrollDispose = () => { window.removeEventListener('scroll', windowScrollHandler); }; } } /** * Setup contact form * @param selectors Form selectors, default is "form[name='contact-form']" * @param recipientField Recipient field name, default is "email" * @param templateName Email template name */ setupContactForm( selectors: string = "form[name='contact-form']", recipientField: string = 'email', templateName?: string ) { const form = document.querySelector<HTMLFormElement>(selectors); if (form) { // Labels with 'for' attribute const labels = form.querySelectorAll<HTMLLabelElement>('label[for]'); labels.forEach((label) => { if ( label.control && 'required' in label.control && label.control.required ) { label.classList.add('form-label-required'); } }); if (typeof form.name !== 'string') { this.notifier.alert( 'The form name is required. And please do not name a form field with "name".' ); return; } // Default values from query string const searchParams = new URLSearchParams(location.search); searchParams.forEach((value, key) => { const field = form.querySelector<HTMLInputElement>( `[name="${key}"]` ); if (field) field.value = value; }); // Action (A-Za-z/_) from form name const action = form.name .replace('-', '_') .replace(/[^A-Za-z_]/g, ''); // Default email template name const template = templateName ?? `${action.toUpperCase()}_EMAIL_TEMPLATE`; // Submit handler const submitHandler = (event: SubmitEvent) => { event.preventDefault(); const recipentField = form.querySelector<HTMLInputElement>( `[name="${recipientField}"]` ); if (recipentField == null) { this.notifier.alert('Recipient field not found'); return; } // Form data const data = Object.fromEntries(new FormData(form)); // Remove empty values Utils.removeEmptyValues(data); // Check recipient const recipient = data[recipientField].toString(); if (!recipient.isEmail()) { recipentField.focus(); return; } const submitButton = form.querySelector<HTMLButtonElement>( 'button[type="submit"]' ); if (submitButton) SiteUtils.toggleButtonSpinner(submitButton); this.grecaptcha(action, async (token) => { const result = await this.sendEmail({ recipient, template, data, token }); if (result) { if (result.ok) { this.notifier.alert( result.data?.successMessage ?? 'Your enquiry has been sent successfully', () => form.reset(), NotificationMessageType.Success ); } else { this.notifier.alert(result.title ?? 'Error'); } } if (submitButton) SiteUtils.toggleButtonSpinner(submitButton); }); }; form.addEventListener('submit', submitHandler); this.formSubmitDispose = () => { form.removeEventListener('submit', submitHandler); }; } } /** * Setup drawflow viewer * 设置 Drawflow 视图 * @param drawflowStyle Drawflow style * @param drawVersion Drawflow version */ setupDrawflowViewer(drawflowStyle?: string, drawVersion?: string) { // Check the export data container const dataContainer = document.querySelector<HTMLElement>( "pre[name='drawflow-data']" ); if (dataContainer == null) return; // Copy styles const iframe = document.createElement('iframe'); iframe.style.width = dataContainer.style.width; iframe.style.height = dataContainer.style.height; // Copy classes dataContainer.classList.forEach((c) => { if (c === 'd-none') return; iframe.classList.add(c); }); // Parent font-awesome icons, keep the same version and avoid duplicate loading const fontAwesome = document.querySelector<HTMLLinkElement>( 'link[rel="stylesheet"][href*="/font-awesome"]' ); // Drawflow iframe drawflowStyle ??= '/drawflow.css'; const version = drawVersion ? `@${drawVersion}` : ''; const html = `<!DOCTYPE html> <html> <head> <link href="https://cdn.jsdelivr.net/gh/jerosoler/Drawflow${version}/dist/drawflow.min.css" rel="stylesheet" /> <link href="${drawflowStyle}" rel="stylesheet" /> ${ fontAwesome ? `<link href="${fontAwesome.href}" rel="stylesheet" />` : '' } <script src="https://cdn.jsdelivr.net/gh/jerosoler/Drawflow${version}/dist/drawflow.min.js"></script> </head> <body> </body> <script> (function() { const jsonData = ${dataContainer.innerHTML}; const drawflow = new Drawflow(document.body); drawflow.editor_mode = "view"; drawflow.start(); drawflow.import(jsonData); })(); </script> </html> `; if ('srcdoc' in iframe && !DomUtils.isWechatClient()) { iframe.srcdoc = html; dataContainer.after(iframe); } else { // contentWindow is null when the iframe is not in the DOM dataContainer.after(iframe); const doc = iframe.contentWindow?.document; if (doc) { doc.open(); doc.write(html); doc.close(); } } dataContainer.remove(); } /** * Setup wechat share * 设置微信分享 * @param share Shared data * @param api Wechat configuration API * @returns Result */ async setupWechat( share?: wx.UpdateAppMessageShareDataParams, api = 'Public/CreateJsApiSignature' ) { try { // Load config const data = await this.api.put<wx.ConfigBase>( api, { url: location.href }, { showLoading: false } ); if (data == null) return; // Check exists if (typeof wx === undefined) return; // Apis const apis: wx.ApiName[] = [ 'updateAppMessageShareData', 'onMenuShareAppMessage', 'updateTimelineShareData', 'onMenuShareTimeline' ]; // Config const result = await wxe.configAsync({ ...data, jsApiList: apis }); if (result != null) { console.log('wxe.configAsync', result); return; } // Check const ckResult = await wxe.checkJsApiAsync({ jsApiList: apis }); if (!ckResult.errMsg.endsWith('ok')) { console.log('checkJsApiAsync', ckResult.errMsg); } // Share data if (share == null) { const title = this.getOG('title') ?? document.title; const link = this.getOG('url') ?? location.href; const desc = this.getOG('description') ?? this.getMeta('description') ?? ''; let imgUrl = this.getOG('image') ?? this.getMeta('image_src') ?? '/og.jpg'; if (!imgUrl.includes('://')) imgUrl = location.protocol + '//' + location.host + imgUrl; share = { title, link, imgUrl, desc }; } // Setup share wxe.setupShare(share, ckResult.checkResult); } catch (e) { console.log('WX setup failed with an error', e); } } }