@etsoo/website
Version:
ETSOO CMS Based NextJs Website Framework
535 lines (462 loc) • 16 kB
text/typescript
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);
}
}
}