first
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
export type ApplyGlobalsPayload = { globalJavascript: string; globalCSS: string; id: number };
|
||||
type ApplyGlobalsFN = (payload: ApplyGlobalsPayload, body?: HTMLElement) => void;
|
||||
|
||||
export const applyGlobals: ApplyGlobalsFN = (payload, body = document.body) => {
|
||||
const document = body.ownerDocument;
|
||||
const { globalJavascript, globalCSS, id: experimentId } = payload;
|
||||
try {
|
||||
if (globalJavascript) {
|
||||
const id = `pertento-global-javascript-${experimentId}`;
|
||||
const existingScript = document.querySelector(id);
|
||||
const script = document.createElement('script');
|
||||
script.id = id;
|
||||
script.innerHTML = `;(function(){${globalJavascript}})();`;
|
||||
|
||||
if (existingScript) existingScript.remove();
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
if (globalCSS) {
|
||||
const id = `pertento-global-css-${experimentId}`;
|
||||
const existingStyle = document.querySelector(id);
|
||||
const style = document.createElement('style');
|
||||
style.id = id;
|
||||
style.innerHTML = globalCSS;
|
||||
if (existingStyle) existingStyle.remove();
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('applyGlobals error', error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LocalChange } from 'injector/types';
|
||||
|
||||
export const applyHtmlChange = (element: HTMLElement, item: LocalChange) => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
if (!item.value) return;
|
||||
const newElement = parser.parseFromString(item.value, 'text/html').body.firstChild as HTMLElement | null;
|
||||
if (!newElement) return;
|
||||
|
||||
if (item.action === 'replace') {
|
||||
element.replaceWith(newElement);
|
||||
} else {
|
||||
item.undoElement = newElement;
|
||||
if (item.action === 'after') {
|
||||
element.after(newElement);
|
||||
} else if (item.action === 'before') {
|
||||
element.before(newElement);
|
||||
} else if (item.action === 'append') {
|
||||
element.append(newElement);
|
||||
} else if (item.action === 'insert') {
|
||||
element.insertBefore(newElement, element.firstChild);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LocalChange } from 'injector/types';
|
||||
|
||||
type GetElementFromChangeFn = (change: LocalChange, body?: HTMLElement) => HTMLElement | null;
|
||||
export const getElementFromChange: GetElementFromChangeFn = (change, body = document.body) => {
|
||||
try {
|
||||
let element = !!change.selector && body.querySelector(change.selector);
|
||||
if (element) {
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!change.friendlySelector) return null;
|
||||
const elements = body.querySelectorAll(change.friendlySelector);
|
||||
if (elements.length === 1) {
|
||||
return elements[0] as HTMLElement;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
const index = change.friendlySelectorIndex;
|
||||
if (typeof index !== 'number') return null;
|
||||
if (elements[index]) {
|
||||
return elements[index] as HTMLElement;
|
||||
}
|
||||
}
|
||||
} catch (ex) {}
|
||||
} catch (ex) {}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LocalChange } from 'injector/types';
|
||||
import { applyHtmlChange } from './apply-html-change';
|
||||
import { getElementFromChange } from './get-element-from-change';
|
||||
export { getElementFromChange } from './get-element-from-change';
|
||||
export * from './apply-globals';
|
||||
|
||||
export const applyChanges = (changes: LocalChange[], body = document.body) => {
|
||||
const unfoundChanges: LocalChange[] = [];
|
||||
|
||||
for (let item of changes) {
|
||||
try {
|
||||
if (item.selectors?.length) {
|
||||
const multipleChanges: LocalChange[] = [];
|
||||
const selectors = item.selectors ?? [];
|
||||
const friendlySelectors = item.friendlySelectors ?? [];
|
||||
const friendlySelectorIndexes = item.friendlySelectorIndexes ?? [];
|
||||
for (let i = 0; i < selectors.length; i++) {
|
||||
const singleChange = {
|
||||
...item,
|
||||
selectors: [],
|
||||
friendlySelectors: [],
|
||||
friendlySelectorIndexes: [],
|
||||
selector: selectors[i],
|
||||
friendlySelector: friendlySelectors[i],
|
||||
friendlySelectorIndex: friendlySelectorIndexes[i],
|
||||
} as LocalChange;
|
||||
multipleChanges.push(singleChange);
|
||||
}
|
||||
applyChanges(multipleChanges, body);
|
||||
continue;
|
||||
}
|
||||
if (item.selector) {
|
||||
const element: HTMLElement = getElementFromChange(item, body) as HTMLElement;
|
||||
|
||||
if (!element) {
|
||||
unfoundChanges.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
item.element = element;
|
||||
item.clone = item.clone || (element.cloneNode(true) as HTMLElement);
|
||||
|
||||
if (item.property === 'html') {
|
||||
applyHtmlChange(element, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.property === 'src' && item.value) {
|
||||
(element as HTMLImageElement).src = item.value || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['textContent', 'text'].includes(item.property as string)) {
|
||||
element.textContent = item.value || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.property === 'backgroundImage') {
|
||||
element.style.backgroundImage = item.value ? `url(${item.value})` : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.property === 'dragPosition' && item.value) {
|
||||
const parsed = JSON.parse(item.value);
|
||||
if ('positionType' in parsed) {
|
||||
element.style.position = parsed.positionType;
|
||||
element.style.left = `${parsed.left}px`;
|
||||
element.style.top = `${parsed.top}px`;
|
||||
} else {
|
||||
element.style.position = parsed.position;
|
||||
element.style.left = parsed.left;
|
||||
element.style.top = parsed.top;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.property === 'domMove' && item.value) {
|
||||
const { newParentSelector, newNextSiblingSelector } = JSON.parse(item.value);
|
||||
const newParent = newParentSelector ? body.querySelector(newParentSelector) : null;
|
||||
const newNextSibling = newNextSiblingSelector ? body.querySelector(newNextSiblingSelector) : null;
|
||||
if (newParent) {
|
||||
if (newNextSibling) {
|
||||
newParent.insertBefore(element, newNextSibling);
|
||||
} else {
|
||||
newParent.appendChild(element);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof item.property === 'string') {
|
||||
element.style[item.property as any] = item.value || '';
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return unfoundChanges;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const console = window?.console;
|
||||
const isDebug = !!localStorage.getItem('PERTENTO_DEBUG');
|
||||
|
||||
const log = (...args: any[]) => {
|
||||
isDebug && console.info('PERTENTOAI:', ...args);
|
||||
};
|
||||
|
||||
export { log };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RuntimeCookieTargeting } from 'types/runtime';
|
||||
|
||||
export const matchCookieTargeting = (cookie: string, cookieTargeting: RuntimeCookieTargeting[]) => {
|
||||
if (!cookieTargeting || cookieTargeting.length === 0) return true;
|
||||
if (!cookie) return false;
|
||||
|
||||
for (let targeting of cookieTargeting) {
|
||||
for (let cookieValue of targeting.cookieValues) {
|
||||
if (cookie.includes(cookieValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import { detectDevice } from './detect-device';
|
||||
|
||||
describe('detectDevice', () => {
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const originalScreen = globalThis.screen;
|
||||
|
||||
const mockEnvironment = (userAgent: string, maxTouchPoints: number, screenWidth: number) => {
|
||||
// @ts-expect-error - mocking navigator
|
||||
globalThis.navigator = {
|
||||
userAgent,
|
||||
maxTouchPoints,
|
||||
};
|
||||
// @ts-expect-error - mocking screen
|
||||
globalThis.screen = {
|
||||
width: screenWidth,
|
||||
};
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.navigator = originalNavigator;
|
||||
globalThis.screen = originalScreen;
|
||||
});
|
||||
|
||||
describe('user agent detection', () => {
|
||||
test('detects iPad as Tablet', () => {
|
||||
mockEnvironment('Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)', 0, 1024);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('detects tablet keyword as Tablet', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Linux; Android 10; SM-T500) Tablet', 0, 800);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('detects iPhone as Mobile', () => {
|
||||
mockEnvironment('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)', 0, 375);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
|
||||
test('detects iPod as Mobile', () => {
|
||||
mockEnvironment('Mozilla/5.0 (iPod touch; CPU iPhone OS 14_0 like Mac OS X)', 0, 320);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
|
||||
test('detects Android mobile as Mobile', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Linux; Android 10; SM-G960F) Mobile', 0, 360);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
|
||||
test('detects generic mobile keyword as Mobile', () => {
|
||||
mockEnvironment('Mozilla/5.0 Mobile Safari', 0, 400);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch and screen size detection', () => {
|
||||
test('touch device with small screen is Mobile', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 600);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
|
||||
test('touch device with medium screen is Tablet', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 800);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('touch device at 768px boundary is Tablet', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 768);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('touch device at 1280px boundary is Tablet', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 1280);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('touch device above 1280px is Desktop', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 5, 1400);
|
||||
expect(detectDevice()).toBe('Desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('desktop detection', () => {
|
||||
test('non-touch device with large screen is Desktop', () => {
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 0, 1920);
|
||||
expect(detectDevice()).toBe('Desktop');
|
||||
});
|
||||
|
||||
test('non-touch device with small screen is still Desktop', () => {
|
||||
// No touch and no mobile UA means desktop regardless of screen size
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 0, 600);
|
||||
expect(detectDevice()).toBe('Desktop');
|
||||
});
|
||||
|
||||
test('single touch point (mouse) is not considered touch', () => {
|
||||
// maxTouchPoints = 1 means mouse, not touch screen
|
||||
mockEnvironment('Mozilla/5.0 (Windows NT 10.0)', 1, 800);
|
||||
expect(detectDevice()).toBe('Desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
test('detects IPAD in uppercase', () => {
|
||||
mockEnvironment('Mozilla/5.0 IPAD something', 0, 1024);
|
||||
// UA is lowercased in the function
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('detects Mobile in mixed case', () => {
|
||||
mockEnvironment('Mozilla/5.0 MoBiLe Safari', 0, 375);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority of detection', () => {
|
||||
test('tablet UA takes priority over mobile-sized touch screen', () => {
|
||||
// iPad UA should return Tablet even with small screen
|
||||
mockEnvironment('Mozilla/5.0 (iPad)', 5, 500);
|
||||
expect(detectDevice()).toBe('Tablet');
|
||||
});
|
||||
|
||||
test('mobile UA takes priority over tablet-sized touch screen', () => {
|
||||
// iPhone UA should return Mobile even with tablet-sized screen
|
||||
mockEnvironment('Mozilla/5.0 (iPhone)', 5, 1000);
|
||||
expect(detectDevice()).toBe('Mobile');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export function detectDevice() {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isTouch = navigator.maxTouchPoints > 1;
|
||||
const width = screen.width;
|
||||
|
||||
if (/tablet|ipad/.test(ua)) return 'Tablet';
|
||||
if (/mobile|iphone|ipod|android/.test(ua)) return 'Mobile';
|
||||
|
||||
if (isTouch && width < 768) return 'Mobile';
|
||||
if (isTouch && width >= 768 && width <= 1280) return 'Tablet';
|
||||
return 'Desktop';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const spaceRegex = /\s+/g;
|
||||
const endDotRegex = /\.$/;
|
||||
const doubleDotRegex = /\.+/g;
|
||||
|
||||
type GetFriendlySelectorFn = (element: HTMLElement) => string;
|
||||
export const getFriendlySelector: GetFriendlySelectorFn = (element) => {
|
||||
if (element.tagName.toLowerCase() == 'body') return 'body';
|
||||
let str = element.tagName.toLowerCase();
|
||||
|
||||
if (element.className && element.className.split) {
|
||||
let classes = element.className.split(spaceRegex);
|
||||
for (let i = 0; i < classes.length; i++) {
|
||||
if (typeof classes[i] === 'string') {
|
||||
const theClass = classes[i];
|
||||
if (!theClass) continue;
|
||||
if (theClass.trim().length > 0) {
|
||||
if (theClass.indexOf(':') === -1 && theClass.indexOf('[') === -1) {
|
||||
str += '.' + theClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
str = str.replace(endDotRegex, '').replace(doubleDotRegex, '.');
|
||||
}
|
||||
|
||||
const id = element.getAttribute('id');
|
||||
str += !!id && id.indexOf('=') < 0 ? '#' + id : '';
|
||||
|
||||
return getFriendlySelector(element.parentNode as HTMLElement) + ' > ' + str;
|
||||
};
|
||||
|
||||
type GetSelectorFn = (element: HTMLElement) => string;
|
||||
export const getSelector: GetSelectorFn = (element) => {
|
||||
if (!element || element === document.documentElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var selector = '';
|
||||
|
||||
if (element.tagName) {
|
||||
selector = element.tagName;
|
||||
}
|
||||
|
||||
if (element.parentNode) {
|
||||
var index = Array.from(element.parentNode.children).indexOf(element) + 1;
|
||||
selector += ':nth-child(' + index + ')';
|
||||
}
|
||||
|
||||
var parentSelector = getSelector(element.parentNode as HTMLElement);
|
||||
if (parentSelector) {
|
||||
selector = parentSelector + '>' + selector;
|
||||
}
|
||||
|
||||
return selector.trim();
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "injector",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"./console": "./console.ts",
|
||||
"./apply-changes": "./apply-changes/index.ts",
|
||||
"./cookie-target-match": "./cookie-target-match.ts",
|
||||
"./detect-device": "./detect-device.ts",
|
||||
"./get-selector": "./get-selector.ts",
|
||||
"./get-style-map": "./get-style-map.ts",
|
||||
"./types": "./types/index.ts",
|
||||
"./use-client": "./use-client.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const removeOutline = (elements: HTMLElement | HTMLElement[] | null) => {
|
||||
if (!elements) return;
|
||||
const elemArray = Array.isArray(elements) ? elements : [elements];
|
||||
for (const element of elemArray) {
|
||||
element.style.outline = '';
|
||||
if (!element.style[0]) element.removeAttribute('style');
|
||||
removeOutline(Array.from(element.children) as HTMLElement[]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const setNativeValue = (element: HTMLElement, value: string | number) => {
|
||||
if (!Object) return;
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(element, 'value')?.set;
|
||||
const prototype = Object.getPrototypeOf(element);
|
||||
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
||||
|
||||
if (valueSetter && valueSetter !== prototypeValueSetter) {
|
||||
prototypeValueSetter?.call(element, value);
|
||||
} else {
|
||||
valueSetter?.call(element, value);
|
||||
}
|
||||
};
|
||||
|
||||
export default setNativeValue;
|
||||
@@ -0,0 +1,42 @@
|
||||
export type CodeLanguage = 'html' | 'css' | 'js';
|
||||
|
||||
type Change = {
|
||||
selector?: string;
|
||||
selectors?: string[];
|
||||
friendlySelector?: string;
|
||||
friendlySelectors?: string[];
|
||||
friendlySelectorIndex?: number;
|
||||
friendlySelectorIndexes?: number[];
|
||||
property?: string;
|
||||
value?: string;
|
||||
action?: 'replace' | 'after' | 'before' | 'append' | 'insert';
|
||||
};
|
||||
|
||||
export type Margin = { top: number; left: number };
|
||||
|
||||
export type IsContextOpenType = { left: number; top: number } | false;
|
||||
|
||||
export type OpenContextMenuProps = { dims: Dims; clientX: number; clientY: number; isHierarchy: boolean };
|
||||
|
||||
export type GlobalChanges = { globalJavascript: string; globalCSS: string };
|
||||
export type ChangesType = [GlobalChanges, ...LocalChange[]];
|
||||
|
||||
export type HTMLElementWithHierarchy = Partial<HTMLElement> & {
|
||||
hierarchy?: HTMLElementWithHierarchy[];
|
||||
element?: HTMLElement;
|
||||
selector?: string;
|
||||
friendlySelector?: string;
|
||||
friendlySelectorIndex?: number;
|
||||
outerHTML?: string;
|
||||
computedStyles?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type Dims = Partial<DOMRect>;
|
||||
|
||||
export type LocalChange = Partial<Change> & {
|
||||
hierarchy?: HTMLElementWithHierarchy[];
|
||||
dims?: Dims;
|
||||
element?: HTMLElement;
|
||||
undoElement?: HTMLElement;
|
||||
clone?: HTMLElement;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
type UndoRedoItem = {
|
||||
property: string;
|
||||
action: string;
|
||||
value: string;
|
||||
clone: Node;
|
||||
undoElement?: ChildNode | null;
|
||||
};
|
||||
|
||||
export const applyUndo = (element: HTMLElement, item: UndoRedoItem) => {
|
||||
if (item.property === 'html') {
|
||||
if (item.action !== 'replace') {
|
||||
item.undoElement?.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
element.replaceWith(item.clone);
|
||||
};
|
||||
|
||||
export const applyRedo = (element: HTMLElement, item: UndoRedoItem) => {
|
||||
if (item.property === 'html') {
|
||||
const parser = new DOMParser();
|
||||
const newElement = parser.parseFromString(item.value, 'text/html').body.firstChild;
|
||||
|
||||
if (item.action === 'before') {
|
||||
item.undoElement = newElement;
|
||||
element.before(newElement as Node);
|
||||
return;
|
||||
}
|
||||
if (item.action === 'after') {
|
||||
item.undoElement = newElement;
|
||||
element.after(newElement as Node);
|
||||
return;
|
||||
}
|
||||
if (item.action === 'append') {
|
||||
element.append(newElement as Node);
|
||||
return;
|
||||
}
|
||||
if (item.action === 'insert') {
|
||||
item.undoElement = newElement;
|
||||
element.insertBefore(newElement as Node, element.firstChild);
|
||||
return;
|
||||
}
|
||||
}
|
||||
element.replaceWith(item.clone);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
function getHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function get<T>(uri: string, baseUrl: string = ''): Promise<T> {
|
||||
const headers = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, { headers });
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function post<T>(uri: string, payload?: unknown, baseUrl: string = ''): Promise<T> {
|
||||
const headers = getHeaders();
|
||||
const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri;
|
||||
const res = await fetch(theUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers,
|
||||
});
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
interface HttpClient {
|
||||
baseUrl: string;
|
||||
get: <T>(url: string) => Promise<T>;
|
||||
post: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
}
|
||||
|
||||
export function createClient(baseUrl?: string): HttpClient {
|
||||
const finalBaseUrl = baseUrl || '';
|
||||
return {
|
||||
baseUrl: finalBaseUrl,
|
||||
get: <T>(url: string) => get<T>(url, finalBaseUrl),
|
||||
post: <T>(url: string, payload?: unknown) => post<T>(url, payload, finalBaseUrl),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user