@@ -65,3 +65,8 @@ export const useMasqueradeData = () => useSelector(requestSelectors.masquerade);
|
||||
|
||||
export const useRequestIsPending = (requestName) => useSelector(requestSelectors.isPending(requestName));
|
||||
export const useRequestIsFailed = (requestName) => useSelector(requestSelectors.isFailed(requestName));
|
||||
|
||||
export const useTrackCourseEvent = (tracker, cardId, ...args) => {
|
||||
const { courseId } = module.useCardCourseRunData(cardId);
|
||||
return (e) => tracker(courseId, ...args)(e);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { StrictDict } from 'utils';
|
||||
import { handleEvent } from 'data/services/segment/utils';
|
||||
import { eventNames } from 'data/services/segment/constants';
|
||||
import { actions, selectors } from 'data/redux';
|
||||
import { post } from 'data/services/lms/utils';
|
||||
|
||||
@@ -34,10 +32,6 @@ export const sendConfirmEmail = () => (dispatch, getState) => post(
|
||||
|
||||
export const newEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => {
|
||||
const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId);
|
||||
handleEvent(eventNames.sessionChange({ action: 'new' }), {
|
||||
fromCourseRun: null,
|
||||
toCourseRun: selection,
|
||||
});
|
||||
dispatch(requests.newEntitlementEnrollment({
|
||||
uuid,
|
||||
courseId: selection,
|
||||
@@ -46,12 +40,7 @@ export const newEntitlementEnrollment = (cardId, selection) => (dispatch, getSta
|
||||
};
|
||||
|
||||
export const switchEntitlementEnrollment = (cardId, selection) => (dispatch, getState) => {
|
||||
const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId);
|
||||
const { uuid } = selectors.app.courseCard.entitlement(getState(), cardId);
|
||||
handleEvent(eventNames.sessionChange({ action: 'switch' }), {
|
||||
fromCourseRun: courseId,
|
||||
toCourseRun: selection,
|
||||
});
|
||||
dispatch(requests.switchEntitlementEnrollment({
|
||||
uuid,
|
||||
courseId: selection,
|
||||
@@ -60,12 +49,7 @@ export const switchEntitlementEnrollment = (cardId, selection) => (dispatch, get
|
||||
};
|
||||
|
||||
export const leaveEntitlementSession = (cardId) => (dispatch, getState) => {
|
||||
const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId);
|
||||
const { uuid, isRefundable } = selectors.app.courseCard.entitlement(getState(), cardId);
|
||||
handleEvent(eventNames.entitlementUnenroll, {
|
||||
leaveCourseRun: courseId,
|
||||
isRefundable,
|
||||
});
|
||||
dispatch(requests.leaveEntitlementSession({
|
||||
uuid,
|
||||
isRefundable,
|
||||
@@ -73,16 +57,8 @@ export const leaveEntitlementSession = (cardId) => (dispatch, getState) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const unenrollFromCourse = (cardId, reason) => (dispatch, getState) => {
|
||||
export const unenrollFromCourse = (cardId) => (dispatch, getState) => {
|
||||
const { courseId } = selectors.app.courseCard.courseRun(getState(), cardId);
|
||||
if (reason) {
|
||||
handleEvent(eventNames.unenrollReason, {
|
||||
category: 'user-engagement',
|
||||
displayName: 'v1',
|
||||
label: reason,
|
||||
course_id: courseId,
|
||||
});
|
||||
}
|
||||
dispatch(requests.unenrollFromCourse({
|
||||
courseId,
|
||||
onSuccess: () => dispatch(module.initialize()),
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { keyStore } from 'utils';
|
||||
import { handleEvent } from 'data/services/segment/utils';
|
||||
import { eventNames } from 'data/services/segment/constants';
|
||||
import { post } from 'data/services/lms/utils';
|
||||
import { actions, selectors } from 'data/redux';
|
||||
import { post } from 'data/services/lms/utils';
|
||||
|
||||
import requests from './requests';
|
||||
|
||||
import * as module from './app';
|
||||
|
||||
jest.mock('data/services/segment/utils', () => ({
|
||||
handleEvent: jest.fn(),
|
||||
}));
|
||||
jest.mock('data/services/lms/utils', () => ({
|
||||
post: jest.fn(),
|
||||
}));
|
||||
@@ -108,13 +105,6 @@ describe('app thunk actions', () => {
|
||||
beforeEach(() => {
|
||||
module.newEntitlementEnrollment(cardId, selection)(dispatch, getState);
|
||||
});
|
||||
it('handles sessionChange(new) tracking event', () => {
|
||||
expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(handleEvent).toHaveBeenCalledWith(
|
||||
eventNames.sessionChange({ action: 'new' }),
|
||||
{ fromCourseRun: null, toCourseRun: selection },
|
||||
);
|
||||
});
|
||||
it('dispatches newEntitlementEnrollment request then re-init on success', () => {
|
||||
const request = dispatch.mock.calls[0][0];
|
||||
expect(request.newEntitlementEnrollment.uuid).toEqual(uuid);
|
||||
@@ -129,14 +119,6 @@ describe('app thunk actions', () => {
|
||||
beforeEach(() => {
|
||||
module.switchEntitlementEnrollment(cardId, selection)(dispatch, getState);
|
||||
});
|
||||
it('handles sessionChange(switch) tracking event', () => {
|
||||
expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(handleEvent).toHaveBeenCalledWith(
|
||||
eventNames.sessionChange({ action: 'switch' }),
|
||||
{ fromCourseRun: courseId, toCourseRun: selection },
|
||||
);
|
||||
});
|
||||
it('dispatches switchEntitlementEnrollment request then re-init on success', () => {
|
||||
const request = dispatch.mock.calls[0][0];
|
||||
expect(request.switchEntitlementEnrollment.uuid).toEqual(uuid);
|
||||
@@ -151,14 +133,6 @@ describe('app thunk actions', () => {
|
||||
beforeEach(() => {
|
||||
module.leaveEntitlementSession(cardId)(dispatch, getState);
|
||||
});
|
||||
it('handles sessionChange(leave) tracking event', () => {
|
||||
expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(selectors.app.courseCard.entitlement).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(handleEvent).toHaveBeenCalledWith(
|
||||
eventNames.entitlementUnenroll,
|
||||
{ leaveCourseRun: courseId, isRefundable },
|
||||
);
|
||||
});
|
||||
it('dispatches leaveEntitlementEnrollment request then re-init on success', () => {
|
||||
const request = dispatch.mock.calls[0][0];
|
||||
expect(request.leaveEntitlementSession.uuid).toEqual(uuid);
|
||||
@@ -174,21 +148,6 @@ describe('app thunk actions', () => {
|
||||
beforeEach(() => {
|
||||
initializeSpy.mockImplementationOnce(mockInitialize);
|
||||
});
|
||||
it('handles unenroll reason tracking event if reason provided', () => {
|
||||
module.unenrollFromCourse(cardId, reason)(dispatch, getState);
|
||||
expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(handleEvent).toHaveBeenCalledWith(eventNames.unenrollReason, {
|
||||
category: 'user-engagement',
|
||||
displayName: 'v1',
|
||||
label: reason,
|
||||
course_id: courseId,
|
||||
});
|
||||
});
|
||||
it('does not handle unenroll reason event if reason not provided', () => {
|
||||
module.unenrollFromCourse(cardId)(dispatch, getState);
|
||||
expect(selectors.app.courseCard.courseRun).toHaveBeenCalledWith(testState, cardId);
|
||||
expect(handleEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
it('dispatches unenrollFromCourse request action, re-initializing on success', () => {
|
||||
module.unenrollFromCourse(cardId, reason)(dispatch, getState);
|
||||
const request = dispatch.mock.calls[0][0];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import eventNames from 'tracking/constants';
|
||||
import {
|
||||
client,
|
||||
get,
|
||||
@@ -10,33 +11,55 @@ import {
|
||||
enableEmailsAction,
|
||||
} from './constants';
|
||||
import urls from './urls';
|
||||
import * as module from './api';
|
||||
|
||||
/*********************************************************************************
|
||||
* GET Actions
|
||||
*********************************************************************************/
|
||||
const initializeList = ({ user } = {}) => get(stringifyUrl(
|
||||
urls.init,
|
||||
{ [apiKeys.user]: user },
|
||||
));
|
||||
export const initializeList = ({ user } = {}) => get(
|
||||
stringifyUrl(urls.init, { [apiKeys.user]: user }),
|
||||
);
|
||||
|
||||
const updateEntitlementEnrollment = ({ uuid, courseId }) => post(
|
||||
export const updateEntitlementEnrollment = ({ uuid, courseId }) => post(
|
||||
urls.entitlementEnrollment(uuid),
|
||||
{ [apiKeys.courseRunId]: courseId },
|
||||
);
|
||||
|
||||
const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client().delete(stringifyUrl(
|
||||
urls.entitlementEnrollment(uuid),
|
||||
{ [apiKeys.isRefund]: isRefundable },
|
||||
));
|
||||
export const deleteEntitlementEnrollment = ({ uuid, isRefundable }) => client().delete(
|
||||
stringifyUrl(urls.entitlementEnrollment(uuid), { [apiKeys.isRefund]: isRefundable }),
|
||||
);
|
||||
|
||||
const updateEmailSettings = ({ courseId, enable }) => post(
|
||||
stringifyUrl(urls.updateEmailSettings),
|
||||
export const updateEmailSettings = ({ courseId, enable }) => post(
|
||||
urls.updateEmailSettings,
|
||||
{ [apiKeys.courseId]: courseId, ...(enable && enableEmailsAction) },
|
||||
);
|
||||
|
||||
const unenrollFromCourse = ({ courseId }) => post(stringifyUrl(urls.courseUnenroll), {
|
||||
[apiKeys.courseId]: courseId,
|
||||
...unenrollmentAction,
|
||||
export const unenrollFromCourse = ({ courseId }) => post(
|
||||
urls.courseUnenroll,
|
||||
{ [apiKeys.courseId]: courseId, ...unenrollmentAction },
|
||||
);
|
||||
|
||||
export const logEvent = ({ eventName, data, courseId }) => post(urls.event, {
|
||||
courserun_key: courseId,
|
||||
event_type: eventName,
|
||||
page: window.location.href,
|
||||
event: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const logUpgrade = ({ courseId }) => module.logEvent({
|
||||
eventName: eventNames.upgradeButtonClickedEnrollment,
|
||||
courseId,
|
||||
data: { location: 'learner-dashboard' },
|
||||
});
|
||||
|
||||
export const logShare = ({ courseId, site }) => module.logEvent({
|
||||
eventName: eventNames.shareClicked,
|
||||
courseId,
|
||||
data: {
|
||||
course_id: courseId,
|
||||
social_media_site: site,
|
||||
location: 'dashboard',
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
@@ -45,4 +68,6 @@ export default {
|
||||
updateEmailSettings,
|
||||
updateEntitlementEnrollment,
|
||||
deleteEntitlementEnrollment,
|
||||
logUpgrade,
|
||||
logShare,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import api from './api';
|
||||
import { mockLocation } from 'testUtils';
|
||||
import { keyStore } from 'utils';
|
||||
import eventNames from 'tracking/constants';
|
||||
import * as api from './api';
|
||||
import * as utils from './utils';
|
||||
import urls from './urls';
|
||||
import {
|
||||
@@ -20,9 +23,11 @@ jest.mock('./utils', () => {
|
||||
|
||||
const testUser = 'test-user';
|
||||
const testUuid = 'test-UUID';
|
||||
const testCourseId = 'TEST-course-ID';
|
||||
const courseId = 'TEST-course-ID';
|
||||
const isRefundable = 'test-is-refundable';
|
||||
|
||||
const moduleKeys = keyStore(api);
|
||||
|
||||
describe('lms api methods', () => {
|
||||
describe('initializeList', () => {
|
||||
test('calls get with the correct url and user', () => {
|
||||
@@ -37,11 +42,11 @@ describe('lms api methods', () => {
|
||||
describe('updateEntitlementEnrollment', () => {
|
||||
it('calls post on entitlementEnrollment url with uuid and course run ID', () => {
|
||||
expect(
|
||||
api.updateEntitlementEnrollment({ uuid: testUuid, courseId: testCourseId }),
|
||||
api.updateEntitlementEnrollment({ uuid: testUuid, courseId }),
|
||||
).toEqual(
|
||||
utils.post(
|
||||
urls.entitlementEnrollment(testUuid),
|
||||
{ [apiKeys.courseRunId]: testCourseId },
|
||||
{ [apiKeys.courseRunId]: courseId },
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -62,20 +67,19 @@ describe('lms api methods', () => {
|
||||
describe('disable', () => {
|
||||
it('calls post on updateEmailSettings url with course ID', () => {
|
||||
expect(
|
||||
api.updateEmailSettings({ courseId: testCourseId, enable: false }),
|
||||
api.updateEmailSettings({ courseId, enable: false }),
|
||||
).toEqual(
|
||||
utils.post(utils.stringifyUrl(urls.updateEmailSettings),
|
||||
{ [apiKeys.courseId]: testCourseId }),
|
||||
utils.post(urls.updateEmailSettings, { [apiKeys.courseId]: courseId }),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('enable', () => {
|
||||
it('calls post on updateEmailSettings url with course ID and enableEmailsAction', () => {
|
||||
expect(
|
||||
api.updateEmailSettings({ courseId: testCourseId, enable: true }),
|
||||
api.updateEmailSettings({ courseId, enable: true }),
|
||||
).toEqual(
|
||||
utils.post(utils.stringifyUrl(urls.updateEmailSettings),
|
||||
{ [apiKeys.courseId]: testCourseId, ...enableEmailsAction }),
|
||||
utils.post(urls.updateEmailSettings,
|
||||
{ [apiKeys.courseId]: courseId, ...enableEmailsAction }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -83,12 +87,52 @@ describe('lms api methods', () => {
|
||||
describe('unenrollFromCourse', () => {
|
||||
it('calls post on unenrollFromCourse url with courseId and unenrollment action', () => {
|
||||
expect(
|
||||
api.unenrollFromCourse({ courseId: testCourseId }),
|
||||
api.unenrollFromCourse({ courseId }),
|
||||
).toEqual(
|
||||
utils.post(utils.stringifyUrl(
|
||||
urls.courseUnenroll,
|
||||
), { [apiKeys.courseId]: testCourseId, ...unenrollmentAction }),
|
||||
utils.post(urls.courseUnenroll,
|
||||
{ [apiKeys.courseId]: courseId, ...unenrollmentAction }),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('logging events', () => {
|
||||
describe('logEvent', () => {
|
||||
it('posts to event url with event data', () => {
|
||||
const href = 'test-href';
|
||||
const eventName = 'test-event-key';
|
||||
const data = { some: 'data' };
|
||||
mockLocation(href);
|
||||
expect(
|
||||
api.logEvent({ courseId, eventName, data }),
|
||||
).toEqual(
|
||||
utils.post(urls.event, {
|
||||
courserun_key: courseId,
|
||||
event_type: eventName,
|
||||
page: href,
|
||||
event: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('logged events', () => {
|
||||
const logEvent = (args) => ({ logEvent: args });
|
||||
beforeEach(() => {
|
||||
jest.spyOn(api, moduleKeys.logEvent).mockImplementation(logEvent);
|
||||
});
|
||||
test('logUpgrade sends enrollment upgrade click event with learner dashboard location', () => {
|
||||
expect(api.logUpgrade({ courseId })).toEqual(logEvent({
|
||||
eventName: eventNames.upgradeButtonClickedEnrollment,
|
||||
courseId,
|
||||
data: { location: 'learner-dashboard' },
|
||||
}));
|
||||
});
|
||||
test('logShare sends share clicke vent with course id, side and location', () => {
|
||||
const site = 'test-site';
|
||||
expect(api.logShare({ courseId, site })).toEqual(logEvent({
|
||||
eventName: eventNames.shareClicked,
|
||||
courseId,
|
||||
data: { course_id: courseId, social_media_site: site, location: 'dashboard' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const api = `${baseUrl}/api`;
|
||||
// const init = `${api}learner_home/mock/init`; // mock endpoint for testing
|
||||
const init = `${api}/learner_home/init`;
|
||||
|
||||
const event = `${baseUrl}/event`;
|
||||
const courseUnenroll = `${baseUrl}/change_enrollment`;
|
||||
const updateEmailSettings = `${api}/change_email_settings`;
|
||||
const entitlementEnrollment = (uuid) => `${api}/entitlements/v1/entitlements/${uuid}/enrollments`;
|
||||
@@ -23,11 +24,12 @@ const programsUrl = baseAppUrl('/dashboard/programs');
|
||||
|
||||
export default StrictDict({
|
||||
api,
|
||||
init,
|
||||
courseUnenroll,
|
||||
updateEmailSettings,
|
||||
entitlementEnrollment,
|
||||
baseAppUrl,
|
||||
courseUnenroll,
|
||||
entitlementEnrollment,
|
||||
event,
|
||||
init,
|
||||
learningMfeUrl,
|
||||
programsUrl,
|
||||
updateEmailSettings,
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
export const events = StrictDict({
|
||||
courseEnroll: 'courseEnroll',
|
||||
entitlementUnenroll: 'entitlementUnenroll',
|
||||
sessionChange: 'sessionChange',
|
||||
unenrollReason: 'unenrollReason',
|
||||
upgradeCourse: 'upgradeCourse',
|
||||
});
|
||||
|
||||
export const eventNames = StrictDict({
|
||||
[events.courseEnroll]: 'edx.bi.user.program-details.enrollment',
|
||||
[events.upgradeCourse]: 'learner_home.course_card.upgrade',
|
||||
[events.entitlementUnenroll]: 'entitlement_unenrollment_reason.selected',
|
||||
[events.sessionChange]: ({ action }) => `course-dashboard.${action}-session`, // 'switch', 'new', 'leave'
|
||||
[events.unenrollReason]: 'unenrollment_reason.selected',
|
||||
});
|
||||
|
||||
export const trackingCategory = 'learner-home';
|
||||
|
||||
export const pageViewEvent = { category: trackingCategory };
|
||||
@@ -1,18 +1,16 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { trackEvent } from '@redux-beacon/segment';
|
||||
import { trackingCategory as category } from './constants';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { appName } from 'tracking/constants';
|
||||
|
||||
export const handleEvent = (name, options = {}) => trackEvent(
|
||||
(event = {}) => {
|
||||
const { payload } = event;
|
||||
const { propsFn, extrasFn } = options;
|
||||
return {
|
||||
name,
|
||||
...(extrasFn && extrasFn(payload)),
|
||||
properties: {
|
||||
category,
|
||||
...(propsFn && propsFn(payload)),
|
||||
},
|
||||
};
|
||||
},
|
||||
export const LINK_TIMEOUT = 300;
|
||||
|
||||
export const createEventTracker = (name, options = {}) => () => sendTrackEvent(
|
||||
name,
|
||||
{ ...options, app_name: appName },
|
||||
);
|
||||
|
||||
export const createLinkTracker = (tracker, href) => (e) => {
|
||||
e.preventDefault();
|
||||
tracker();
|
||||
return setTimeout(() => { global.location.href = href; }, LINK_TIMEOUT);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
import * as constants from './constants';
|
||||
import { handleEvent } from './utils';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
jest.mock('@redux-beacon/segment', () => ({
|
||||
trackEvent: (handleFn) => ({ trackEvent: handleFn }),
|
||||
import { appName } from 'tracking/constants';
|
||||
|
||||
import { createEventTracker, createLinkTracker, LINK_TIMEOUT } from './utils';
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(global, 'setTimeout');
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const category = 'AFakeCategory';
|
||||
describe('segment service utils', () => {
|
||||
beforeAll(() => {
|
||||
global.window = Object.create(window);
|
||||
constants.trackingCategory = category;
|
||||
describe('createEventTracker', () => {
|
||||
const name = 'aName';
|
||||
const options = { field1: 'some data', field2: 'other data' };
|
||||
it('call sendTrackEvent', () => {
|
||||
createEventTracker(name, options)();
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(name, { ...options, app_name: appName });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEvent', () => {
|
||||
const name = 'aName';
|
||||
const payload = { field1: 'some data', field2: 'other data' };
|
||||
describe('when called with just a name', () => {
|
||||
it('returns a TrackEvent call with the name and tracking category', () => {
|
||||
const handler = handleEvent(name).trackEvent;
|
||||
expect(handler(payload)).toEqual({
|
||||
name,
|
||||
properties: { category },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when a propsFn is provided', () => {
|
||||
it('adds the output of propsFn(event.payload) to properties', () => {
|
||||
const propsFn = ({ field1 }) => ({ field1 });
|
||||
const handler = handleEvent(name, { propsFn }).trackEvent;
|
||||
expect(handler({ payload })).toEqual({
|
||||
name,
|
||||
properties: { category, field1: payload.field1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when an extrasFn object is provided', () => {
|
||||
it('adds the output of extrasFn(event.payload) to top-level object', () => {
|
||||
const extrasFn = ({ field2 }) => ({ field2 });
|
||||
const handler = handleEvent(name, { extrasFn }).trackEvent;
|
||||
expect(handler({ payload })).toEqual({
|
||||
name,
|
||||
field2: payload.field2,
|
||||
properties: { category },
|
||||
});
|
||||
});
|
||||
describe('createLinkTracker', () => {
|
||||
const tracker = jest.fn();
|
||||
const href = 'https://www.example.com';
|
||||
const event = { preventDefault: jest.fn() };
|
||||
it('call tracker', () => {
|
||||
createLinkTracker(tracker, href)(event);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(tracker).toHaveBeenCalled();
|
||||
expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), LINK_TIMEOUT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user