mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
FEATURE: onboarding banner for admins (#37583)
We planned to add a new banner for newly created communities to help guide site owners through initial setup steps. At the moment, it has 3 items in the checklist: - Invite collaborators -> Opens invite modal - Start posting -> Opens composer with a seed icebreaker topic - Spread the word -> Copies forum link to clipboard You can see it working in this video: https://github.com/user-attachments/assets/ed693321-e416-43f9-8182-0243af193688 Current visuals: <img width="1723" height="393" alt="image" src="https://github.com/user-attachments/assets/3021c0b1-30e8-4ba6-aa43-aebf71061a8a" /> TODO: - [x] Tests - [x] Work on CSS Maybe todos: - [x] Exclude routes where the banner shouldn't appear - [x] Add metadata in ice breaker post to ✅ identify it as an ice breaker topic? - [x] Check if there is a way to determine if the user is the _first_ admin user? - [x] Should we have a happy indicator when the user finishes all tasks(maybe a toast?) --------- Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
co-authored by
Penar Musaraj
parent
2a1dae078b
commit
07a747eecb
@@ -45,6 +45,7 @@
|
||||
@import "post-translations";
|
||||
@import "relative-time-picker";
|
||||
@import "add-pm-participants";
|
||||
@import "admin-onboarding-banner";
|
||||
@import "download-calendar";
|
||||
@import "sidebar/edit-navigation-menu/categories-modal";
|
||||
@import "sidebar/edit-navigation-menu/modal";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "lib/viewport";
|
||||
@import "common/foundation/mixins";
|
||||
|
||||
.admin-onboarding-banner {
|
||||
@include viewport.until(sm) {
|
||||
display: none;
|
||||
}
|
||||
color: dark-light-choose(var(--secondary), var(--primary));
|
||||
|
||||
&__wrap {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
padding: var(--space-6) var(--space-3);
|
||||
border-radius: var(--d-border-radius-large);
|
||||
background: var(--accent-color, var(--tertiary-high));
|
||||
|
||||
@include viewport.until(md) {
|
||||
padding: var(--space-4) 8px var(--space-5);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@include viewport.until(sm) {
|
||||
display: block;
|
||||
padding: var(--space-2);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-4);
|
||||
margin-bottom: var(--space);
|
||||
}
|
||||
|
||||
&__content {
|
||||
text-align: center;
|
||||
padding: 0 var(--space-4);
|
||||
|
||||
@include viewport.until(md) {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
&__steps {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
|
||||
.onboarding-step {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: var(--space-2);
|
||||
|
||||
.btn-default {
|
||||
border-radius: var(--d-border-radius);
|
||||
|
||||
svg {
|
||||
color: dark-light-choose(
|
||||
var(--secondary-medium),
|
||||
var(--primary-medium)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
gap: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,16 @@ class Admin::SiteSettingsController < Admin::AdminController
|
||||
settings = [{ setting_name: id, value: params[id], backfill: }]
|
||||
end
|
||||
|
||||
SiteSetting::Update.call(params: { settings: }, guardian:) do
|
||||
SiteSetting::Update.call(
|
||||
guardian:,
|
||||
params: {
|
||||
settings:,
|
||||
},
|
||||
options: {
|
||||
# TODO: remove once the site setting is no longer hidden
|
||||
allow_changing_hidden: %i[enable_site_owner_onboarding],
|
||||
},
|
||||
) do
|
||||
on_success { head :no_content }
|
||||
on_exceptions { |e| raise Discourse::InvalidParameters, e }
|
||||
on_failed_policy(:settings_are_not_deprecated) do |policy|
|
||||
|
||||
@@ -454,6 +454,39 @@ en:
|
||||
pill: "Suggested"
|
||||
pm_title: "Suggested Messages"
|
||||
|
||||
admin_onboarding_banner:
|
||||
launch_in_easy_steps: "Launch in %{step_count} easy steps"
|
||||
skipped: "The admin onboarding checklist has now been skipped. If you would like to re-enable it, look for `enable site owner onboarding` in the site settings."
|
||||
congrats_onboarding_complete: "Congratulations on completing the onboarding process!"
|
||||
invite_collaborators:
|
||||
title: "Invite Collaborators"
|
||||
description: "Early members help bring your community to life"
|
||||
action: "Create invite"
|
||||
start_posting:
|
||||
title: "Start Posting"
|
||||
description: "Give people something to talk about together"
|
||||
action: "Icebreaker ideas"
|
||||
icebreakers:
|
||||
fun_facts:
|
||||
title: "What are some fun facts about you?"
|
||||
body: "Hi everyone! I'm excited to start this new community. To kick things off, let's share a fun fact about ourselves. I'll go first: I love hiking and exploring new trails! What's something interesting about you?"
|
||||
coolest_thing_you_have_seen_today:
|
||||
title: "What's the coolest thing you've seen today?"
|
||||
body: "What's the coolest thing you've seen or experienced today? It could be anything from a beautiful sunset to a random act of kindness. I'll start: I saw a dog helping an elderly person cross the street!"
|
||||
introduce_yourself:
|
||||
title: "Introduce yourself"
|
||||
body: "Welcome to the community! Let's get to know each other. Please introduce yourself and share a little bit about your interests. I'll go first: I'm passionate about technology and love teaching others!"
|
||||
what_is_your_favorite_food:
|
||||
title: "What is your favorite food?"
|
||||
body: "Share your favorite food with the community! I'll start: I love pizza, especially with extra cheese and pepperoni."
|
||||
|
||||
|
||||
spread_the_word:
|
||||
title: "Spread the Word!"
|
||||
description: "Share a link with everyone in your network"
|
||||
action: "Copy link"
|
||||
copied_to_clipboard: "Link copied to clipboard!"
|
||||
|
||||
about:
|
||||
edit: "Edit this page"
|
||||
simple_title: "About"
|
||||
|
||||
@@ -2713,6 +2713,7 @@ en:
|
||||
suggest_weekends_in_date_pickers: "Include weekends (Saturday and Sunday) in date picker suggestions (disable this if you use Discourse only on weekdays, Monday through Friday)."
|
||||
show_bottom_topic_map: "Shows the topic map at the bottom of the topic when it has 10 replies or more."
|
||||
show_topic_map_in_topics_without_replies: "Shows the topic map even if the topic has no replies."
|
||||
enable_site_owner_onboarding: "Shows a panel on the homepage to new admins to help them get started. Re-enable this setting to see the onboarding steps again."
|
||||
enable_welcome_banner: "Display a banner on your main topic list pages to welcome members and allow them to search site content"
|
||||
welcome_banner_image: "Set an image to display in the background of the welcome banner."
|
||||
welcome_banner_location: "Determines where on the page the welcome banner will appear."
|
||||
|
||||
@@ -3790,6 +3790,12 @@ uncategorized:
|
||||
default: true
|
||||
area: "posts_and_topics"
|
||||
|
||||
enable_site_owner_onboarding:
|
||||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
area: "site_admin"
|
||||
|
||||
enable_welcome_banner:
|
||||
client: true
|
||||
default: true
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { TrackedArray } from "@ember-compat/tracked-built-ins";
|
||||
import SiteSetting from "discourse/admin/models/site-setting";
|
||||
import OnboardingStep from "discourse/components/admin-onboarding/step";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import CreateInvite from "discourse/components/modal/create-invite";
|
||||
import { getAbsoluteURL } from "discourse/lib/get-url";
|
||||
import { clipboardCopy, defaultHomepage } from "discourse/lib/utilities";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const STEPS = [
|
||||
class StartPosting extends OnboardingStep {
|
||||
static name = "start_posting";
|
||||
|
||||
@service composer;
|
||||
@service appEvents;
|
||||
|
||||
icon = "comments";
|
||||
icebreaker_topics = [
|
||||
"fun_facts",
|
||||
"coolest_thing_you_have_seen_today",
|
||||
"introduce_yourself",
|
||||
"what_is_your_favorite_food",
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.appEvents.on("topic:created", this, this.checkIfPosted);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.appEvents.off("topic:created", this, this.checkIfPosted);
|
||||
}
|
||||
|
||||
checkIfPosted() {
|
||||
this.markAsCompleted();
|
||||
}
|
||||
|
||||
@action
|
||||
async performAction() {
|
||||
const randomTopic =
|
||||
this.icebreaker_topics[
|
||||
Math.floor(Math.random() * this.icebreaker_topics.length)
|
||||
];
|
||||
|
||||
this.composer.openNewTopic({
|
||||
title: i18n(
|
||||
`admin_onboarding_banner.start_posting.icebreakers.${randomTopic}.title`
|
||||
),
|
||||
body: i18n(
|
||||
`admin_onboarding_banner.start_posting.icebreakers.${randomTopic}.body`
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
class InviteCollaborators extends OnboardingStep {
|
||||
static name = "invite_collaborators";
|
||||
|
||||
@service modal;
|
||||
@service appEvents;
|
||||
|
||||
step = this.name;
|
||||
icon = "paper-plane";
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.appEvents.on("create-invite:saved", this, this.markAsCompleted);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.appEvents.off("create-invite:saved", this, this.markAsCompleted);
|
||||
}
|
||||
|
||||
@action
|
||||
performAction() {
|
||||
this.modal.show(CreateInvite, {
|
||||
model: { invites: new TrackedArray() },
|
||||
});
|
||||
}
|
||||
},
|
||||
class SpreadTheWord extends OnboardingStep {
|
||||
static name = "spread_the_word";
|
||||
@service toasts;
|
||||
|
||||
@tracked icon = "copy";
|
||||
|
||||
@action
|
||||
performAction() {
|
||||
clipboardCopy(getAbsoluteURL("/"));
|
||||
|
||||
this.toasts.success({
|
||||
data: {
|
||||
message: i18n(
|
||||
"admin_onboarding_banner.spread_the_word.copied_to_clipboard"
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
this.markAsCompleted();
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export default class AdminOnboardingBanner extends Component {
|
||||
@service siteSettings;
|
||||
@service currentUser;
|
||||
@service appEvents;
|
||||
@service keyValueStore;
|
||||
@service router;
|
||||
@service toasts;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.appEvents.on(
|
||||
"onboarding-step:completed",
|
||||
this,
|
||||
this.checkIfOnboardingIsComplete
|
||||
);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.appEvents.off(
|
||||
"onboarding-step:completed",
|
||||
this,
|
||||
this.checkIfOnboardingIsComplete
|
||||
);
|
||||
}
|
||||
|
||||
get shouldDisplay() {
|
||||
if (!this.currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.siteSettings.enable_site_owner_onboarding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.currentUser.admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { currentRouteName } = this.router;
|
||||
return currentRouteName === `discovery.${defaultHomepage()}`;
|
||||
}
|
||||
|
||||
checkIfOnboardingIsComplete() {
|
||||
const allStepsAreDone = STEPS.every(
|
||||
(Step) => !!this.keyValueStore.get(`onboarding_step_${Step.name}`)
|
||||
);
|
||||
|
||||
if (allStepsAreDone) {
|
||||
this.endOnboarding({ skipped: false });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async endOnboarding({ skipped = true } = {}) {
|
||||
await SiteSetting.update("enable_site_owner_onboarding", false);
|
||||
STEPS.forEach((Step) => {
|
||||
this.keyValueStore.remove(`onboarding_step_${Step.name}`);
|
||||
});
|
||||
|
||||
const label = skipped
|
||||
? "admin_onboarding_banner.skipped"
|
||||
: "admin_onboarding_banner.congrats_onboarding_complete";
|
||||
|
||||
this.toasts.success({
|
||||
data: {
|
||||
message: i18n(label),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.shouldDisplay}}
|
||||
<div class="admin-onboarding-banner">
|
||||
<div class="admin-onboarding-banner__wrap">
|
||||
<div class="admin-onboarding-banner__header">
|
||||
<h2>
|
||||
{{i18n
|
||||
"admin_onboarding_banner.launch_in_easy_steps"
|
||||
(hash step_count=STEPS.length)
|
||||
}}
|
||||
</h2>
|
||||
<DButton
|
||||
@action={{this.endOnboarding}}
|
||||
@icon="xmark"
|
||||
class="btn no-text btn-transparent btn-close"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-onboarding-banner__content">
|
||||
<div class="admin-onboarding-banner__steps">
|
||||
{{#each STEPS as |Step|}}
|
||||
<Step />
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { concat } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class OnboardingStep extends Component {
|
||||
static name() {
|
||||
throw new Error("Name is required for OnboardingStep");
|
||||
}
|
||||
|
||||
@service keyValueStore;
|
||||
@service appEvents;
|
||||
|
||||
@tracked
|
||||
completed = this.keyValueStore.get(`onboarding_step_${this.name}`) || false;
|
||||
|
||||
i18nKey = "admin_onboarding_banner.";
|
||||
|
||||
get name() {
|
||||
return this.constructor.name;
|
||||
}
|
||||
|
||||
get icon() {
|
||||
throw new Error("Icon is required for OnboardingStep");
|
||||
}
|
||||
|
||||
@action
|
||||
performAction() {
|
||||
throw new Error("performAction is required for OnboardingStep");
|
||||
}
|
||||
|
||||
markAsCompleted() {
|
||||
this.keyValueStore.set({
|
||||
key: `onboarding_step_${this.name}`,
|
||||
value: true,
|
||||
});
|
||||
this.completed = true;
|
||||
this.appEvents.trigger(`onboarding-step:completed`, this.name);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="onboarding-step" id={{this.name}}>
|
||||
<div class="onboarding-step__checkbox">
|
||||
{{~icon
|
||||
(if this.completed "square-check" "far-square")
|
||||
class=(if this.completed "checked")
|
||||
}}
|
||||
<span>{{i18n (concat this.i18nKey this.name ".title")}}</span>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-step__description">
|
||||
<span>
|
||||
{{i18n (concat this.i18nKey this.name ".description")}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-step__action">
|
||||
<DButton
|
||||
@icon={{this.icon}}
|
||||
@label={{concat this.i18nKey this.name ".action"}}
|
||||
@action={{this.performAction}}
|
||||
class="btn btn-default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default class CreateInvite extends Component {
|
||||
@service currentUser;
|
||||
@service siteSettings;
|
||||
@service site;
|
||||
@service appEvents;
|
||||
|
||||
@tracked saving = false;
|
||||
@tracked displayAdvancedOptions = false;
|
||||
@@ -147,6 +148,7 @@ export default class CreateInvite extends Component {
|
||||
}
|
||||
this.flashClass = "success";
|
||||
}
|
||||
this.appEvents.trigger("create-invite:saved", this.invite);
|
||||
} catch (error) {
|
||||
this.flashText = sanitize(extractError(error));
|
||||
this.flashClass = "error";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import A11yLiveRegions from "discourse/components/a11y/live-regions";
|
||||
import A11ySkipLinks from "discourse/components/a11y/skip-links";
|
||||
import AdminOnboardingBanner from "discourse/components/admin-onboarding/banner";
|
||||
import CardContainer from "discourse/components/card-container";
|
||||
import ComposerContainer from "discourse/components/composer-container";
|
||||
import CustomHtml from "discourse/components/custom-html";
|
||||
@@ -98,6 +99,10 @@ export default <template>
|
||||
<div id="main-outlet">
|
||||
<PluginOutlet @name="above-main-container" @connectorTagName="div" />
|
||||
|
||||
{{#if @controller.siteSettings.enable_site_owner_onboarding}}
|
||||
<AdminOnboardingBanner />
|
||||
{{/if}}
|
||||
|
||||
{{#if
|
||||
(eq
|
||||
@controller.siteSettings.welcome_banner_location
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { click, settled, visit } from "@ember/test-helpers";
|
||||
import { test } from "qunit";
|
||||
import sinon from "sinon";
|
||||
import { AUTO_GROUPS } from "discourse/lib/constants";
|
||||
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
acceptance("Admin - Onboarding Banner", function (needs) {
|
||||
needs.user({
|
||||
admin: true,
|
||||
groups: [AUTO_GROUPS.admins],
|
||||
});
|
||||
|
||||
needs.settings({
|
||||
enable_site_owner_onboarding: true,
|
||||
general_category_id: 1,
|
||||
default_composer_category: 1,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.put("/admin/site_settings/enable_site_owner_onboarding", () => {
|
||||
return helper.response(200, {
|
||||
success: "OK",
|
||||
});
|
||||
});
|
||||
|
||||
server.post("/invites", () => {
|
||||
return helper.response(200, {
|
||||
success: "OK",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
needs.hooks.beforeEach(() => {
|
||||
const mockClipboard = {
|
||||
writeText: sinon.stub().resolves(true),
|
||||
write: sinon.stub().resolves(true),
|
||||
};
|
||||
sinon.stub(window.navigator, "clipboard").get(() => mockClipboard);
|
||||
});
|
||||
|
||||
const withStep = (id, assert) => {
|
||||
return {
|
||||
checkbox() {
|
||||
return assert.dom(`div#${id} .onboarding-step__checkbox > svg`);
|
||||
},
|
||||
clickAction() {
|
||||
return click(`div#${id} .onboarding-step__action .btn`);
|
||||
},
|
||||
isChecked() {
|
||||
return this.checkbox().hasClass("checked", `${id} step is completed`);
|
||||
},
|
||||
isNotChecked() {
|
||||
return this.checkbox().doesNotHaveClass(
|
||||
"checked",
|
||||
`${id} step is not completed`
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("it shows onboarding banner", async function (assert) {
|
||||
await visit("/");
|
||||
assert.dom(".admin-onboarding-banner").exists("shows onboarding banner");
|
||||
});
|
||||
|
||||
test("it can end onboarding prematurely", async function (assert) {
|
||||
await visit("/");
|
||||
assert.dom(".admin-onboarding-banner").exists();
|
||||
|
||||
await click(".admin-onboarding-banner .btn-close");
|
||||
this.siteSettings.enable_site_owner_onboarding = false;
|
||||
|
||||
await settled();
|
||||
assert.dom(".admin-onboarding-banner").doesNotExist();
|
||||
});
|
||||
|
||||
test("it can complete `start_posting` step", async function (assert) {
|
||||
const step = withStep("start_posting", assert);
|
||||
await visit("/");
|
||||
|
||||
step.isNotChecked();
|
||||
|
||||
await step.clickAction();
|
||||
await click(".create");
|
||||
await visit("/");
|
||||
|
||||
step.isChecked();
|
||||
});
|
||||
|
||||
test("it can complete `invite_collaborators` step", async function (assert) {
|
||||
const step = withStep("invite_collaborators", assert);
|
||||
await visit("/");
|
||||
|
||||
step.isNotChecked();
|
||||
|
||||
await step.clickAction();
|
||||
await click(".d-modal__footer .btn-primary");
|
||||
await click(".modal-close");
|
||||
|
||||
step.isChecked();
|
||||
});
|
||||
|
||||
test("it can complete `spread_the_word` step", async function (assert) {
|
||||
const step = withStep("spread_the_word", assert);
|
||||
|
||||
await visit("/");
|
||||
|
||||
step.isNotChecked();
|
||||
await step.clickAction();
|
||||
step.isChecked();
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Admin - Onboarding Banner - non admin user", function (needs) {
|
||||
needs.user({ admin: false });
|
||||
needs.settings({
|
||||
enable_site_owner_onboarding: true,
|
||||
});
|
||||
|
||||
test("it does not show onboarding banner for non admin user", async function (assert) {
|
||||
await visit("/");
|
||||
assert
|
||||
.dom(".admin-onboarding-banner")
|
||||
.doesNotExist("does not show onboarding banner");
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Admin - Onboarding Banner - setting disabled", function (needs) {
|
||||
needs.user({
|
||||
admin: true,
|
||||
groups: [AUTO_GROUPS.admins],
|
||||
});
|
||||
needs.settings({
|
||||
enable_site_owner_onboarding: false,
|
||||
});
|
||||
|
||||
test("it does not show onboarding banner when setting is disabled", async function (assert) {
|
||||
await visit("/");
|
||||
assert
|
||||
.dom(".admin-onboarding-banner")
|
||||
.doesNotExist("does not show onboarding banner");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user