Merged in feat/LOY-497-Flag-Profiling-Consent (pull request #3292)
refactor(LOY-497): hide profiling consent behind feature flag * refactor(LOY-497): hide profiling consent behind feature flag * chore(LOY-497): up to date consent readme Approved-by: Matilda Landström
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||||
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
|
import { getEurobonusMembership } from "@scandic-hotels/trpc/routers/user/helpers"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import {
|
import {
|
||||||
getProfileSafely,
|
getProfileSafely,
|
||||||
getProfilingConsent,
|
getProfilingConsent,
|
||||||
@@ -17,12 +18,22 @@ import { ModalTracking } from "@/utils/tracking/profilingConsent"
|
|||||||
|
|
||||||
import styles from "./layout.module.css"
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
export default async function MyPagesLayout({
|
type MyPagesLayoutProps = React.PropsWithChildren<{
|
||||||
|
breadcrumbs: React.ReactNode
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default async function MyPagesLayout(props: MyPagesLayoutProps) {
|
||||||
|
if (env.ENABLE_PROFILE_CONSENT) {
|
||||||
|
return <MyPagesLayoutWithConsent {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MyPagesLayoutBase {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function MyPagesLayoutWithConsent({
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<{
|
}: MyPagesLayoutProps) {
|
||||||
breadcrumbs: React.ReactNode
|
|
||||||
}>) {
|
|
||||||
const profile = await getProfileSafely()
|
const profile = await getProfileSafely()
|
||||||
const eurobonusMembership = profile?.loyalty
|
const eurobonusMembership = profile?.loyalty
|
||||||
? getEurobonusMembership(profile.loyalty)
|
? getEurobonusMembership(profile.loyalty)
|
||||||
@@ -37,6 +48,8 @@ export default async function MyPagesLayout({
|
|||||||
|
|
||||||
const lang = await getLang()
|
const lang = await getLang()
|
||||||
|
|
||||||
|
const showConsentModal = memberKey && profilingConsent && !hasConsent
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfilingConsentAlertProvider>
|
<ProfilingConsentAlertProvider>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -51,7 +64,7 @@ export default async function MyPagesLayout({
|
|||||||
|
|
||||||
{eurobonusMembership && <SASLevelUpgradeCheck />}
|
{eurobonusMembership && <SASLevelUpgradeCheck />}
|
||||||
<Surprises />
|
<Surprises />
|
||||||
{memberKey && profilingConsent && !hasConsent ? (
|
{showConsentModal && (
|
||||||
<>
|
<>
|
||||||
<ProfilingConsentModal
|
<ProfilingConsentModal
|
||||||
memberKey={memberKey}
|
memberKey={memberKey}
|
||||||
@@ -62,8 +75,30 @@ export default async function MyPagesLayout({
|
|||||||
pageData={{ domainLanguage: lang, ...ModalTracking }}
|
pageData={{ domainLanguage: lang, ...ModalTracking }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ProfilingConsentAlertProvider>
|
</ProfilingConsentAlertProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function MyPagesLayoutBase({
|
||||||
|
breadcrumbs,
|
||||||
|
children,
|
||||||
|
}: MyPagesLayoutProps) {
|
||||||
|
const profile = await getProfileSafely()
|
||||||
|
const eurobonusMembership = profile?.loyalty
|
||||||
|
? getEurobonusMembership(profile.loyalty)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{breadcrumbs}
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eurobonusMembership && <SASLevelUpgradeCheck />}
|
||||||
|
<Surprises />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { profile } from "@scandic-hotels/common/constants/routes/myPages"
|
||||||
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
import { getProfile } from "@/lib/trpc/memoizedRequests"
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
|
import { ProfilingConsent } from "@/components/Forms/ProfilingConsent"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import styles from "./page.module.css"
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
export default async function ProfilingConsentSlot() {
|
export default async function ProfilingConsentSlot() {
|
||||||
|
const lang = await getLang()
|
||||||
|
|
||||||
|
if (!env.ENABLE_PROFILE_CONSENT) {
|
||||||
|
redirect(profile[lang])
|
||||||
|
}
|
||||||
|
|
||||||
const caller = await serverClient()
|
const caller = await serverClient()
|
||||||
const accountPage = await caller.contentstack.accountPage.get()
|
const accountPage = await caller.contentstack.accountPage.get()
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import SignupForm from "@/components/Forms/Signup"
|
import SignupForm from "@/components/Forms/Signup"
|
||||||
|
|
||||||
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
|
import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
|
||||||
@@ -5,5 +7,10 @@ import type { SignupFormWrapperProps } from "@/types/components/blocks/dynamicCo
|
|||||||
export default async function SignupFormWrapper({
|
export default async function SignupFormWrapper({
|
||||||
dynamic_content,
|
dynamic_content,
|
||||||
}: SignupFormWrapperProps) {
|
}: SignupFormWrapperProps) {
|
||||||
return <SignupForm {...dynamic_content} />
|
return (
|
||||||
|
<SignupForm
|
||||||
|
{...dynamic_content}
|
||||||
|
enableProfileConsent={env.ENABLE_PROFILE_CONSENT}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ import styles from "./form.module.css"
|
|||||||
|
|
||||||
interface SignUpFormProps {
|
interface SignUpFormProps {
|
||||||
title: string
|
title: string
|
||||||
|
enableProfileConsent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignupForm({ title }: SignUpFormProps) {
|
export default function SignupForm({
|
||||||
|
title,
|
||||||
|
// Handled as a prop rather than a client env var due to limits in Netlify env var size.
|
||||||
|
enableProfileConsent = false,
|
||||||
|
}: SignUpFormProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -130,7 +135,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.formWrapper}>
|
<div className={styles.formWrapper}>
|
||||||
<ProfilingConsentModalReadOnly />
|
{enableProfileConsent && <ProfilingConsentModalReadOnly />}
|
||||||
{title ? (
|
{title ? (
|
||||||
<Typography variant="Title/md">
|
<Typography variant="Title/md">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
@@ -278,6 +283,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{enableProfileConsent && (
|
||||||
<section className={styles.personalization}>
|
<section className={styles.personalization}>
|
||||||
<header>
|
<header>
|
||||||
<Typography variant="Title/Subtitle/md">
|
<Typography variant="Title/Subtitle/md">
|
||||||
@@ -313,6 +319,7 @@ export default function SignupForm({ title }: SignUpFormProps) {
|
|||||||
})}
|
})}
|
||||||
</TextLinkButton>
|
</TextLinkButton>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className={styles.terms}>
|
<section className={styles.terms}>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import { Section } from "../Section"
|
import { Section } from "../Section"
|
||||||
@@ -15,7 +17,7 @@ export async function CommunicationSettings() {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<EmailSlot />
|
<EmailSlot />
|
||||||
<PersonalizationSlot />
|
{env.ENABLE_PROFILE_CONSENT && <PersonalizationSlot />}
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
|
import { env } from "@/env/server"
|
||||||
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
|
import { getProfile, getProfilingConsent } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
|
import { GetMainIconByCSIdentifier, userHasConsent } from "../utils"
|
||||||
@@ -8,6 +9,8 @@ import { BannerButton } from "./Button"
|
|||||||
import styles from "./profilingConsentBanner.module.css"
|
import styles from "./profilingConsentBanner.module.css"
|
||||||
|
|
||||||
export async function ProfilingConsentBanner() {
|
export async function ProfilingConsentBanner() {
|
||||||
|
if (!env.ENABLE_PROFILE_CONSENT) return null
|
||||||
|
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
if (!user || userHasConsent(user?.profilingConsent)) return null
|
if (!user || userHasConsent(user?.profilingConsent)) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,115 @@
|
|||||||
# Profiling Consent
|
# Profiling Consent
|
||||||
|
|
||||||
A full-page modal shown when a user first visits a My Pages route. If the modal is dimissed, a banner is shown on the overview page.
|
Profiling consent allows users to opt in/out of personalized experiences. The feature is controlled by the `ENABLE_PROFILE_CONSENT` environment variable.
|
||||||
On `/profile`, the user can navigate to `/profile/consent` to update the consent.
|
|
||||||
On signup, it's also possible to opt in.
|
|
||||||
|
|
||||||
## Usage
|
## User Journey
|
||||||
|
|
||||||
### My Pages Modal
|
1. **Signup**: Optional consent checkbox in registration form
|
||||||
|
2. **My Pages Modal**: Prompt shown on first visit (if no decision made)
|
||||||
Rendered in `app/[lang]/(live)/(protected)/my-pages/layout.tsx` so it is available across all My Pages routes. The layout passes `memberKey` (derived from `membershipNumber` or `profileId`) to enable per-member dismissal tracking.
|
3. **Banner**: Shown on overview page if modal was dismissed without deciding
|
||||||
|
4. **Profile Settings**: Dedicated page at `/profile/consent` to update consent at any time
|
||||||
### Signup Form Integration
|
|
||||||
|
|
||||||
A read-only version (`Modal/ReadOnly.tsx`) is integrated into the signup form (`components/Forms/Signup/index.tsx`) to provide users with information about personalization benefits during registration. This version:
|
|
||||||
|
|
||||||
- Has no action buttons (Accept/Decline)
|
|
||||||
- Can be opened via the "Read more about personalization at Scandic" button in the signup form
|
|
||||||
- Uses the same Contentstack content as the main modal
|
|
||||||
- Does not require a `memberKey` since it's for non-authenticated users
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### My Pages Modal
|
|
||||||
|
|
||||||
- Displays upon landing on any My Pages route (if not previously dismissed)
|
|
||||||
- Shows Scandic logo, title, lead text, benefit cards and an accordion
|
|
||||||
- Close via "X" button only (no overlay click or ESC key)
|
|
||||||
- Dismissal persisted in `localStorage` per member
|
|
||||||
- Includes Accept/Decline action buttons
|
|
||||||
|
|
||||||
### Read-Only Version (Signup)
|
|
||||||
|
|
||||||
- Same visual content as main modal but without action buttons
|
|
||||||
- Accessible during signup process via "Read more" button
|
|
||||||
- Uses Contentstack content fetched via `trpc.contentstack.profilingConsent.get.useQuery`
|
|
||||||
- No localStorage persistence since it's informational only
|
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
- `Modal/` — Main modal shell with header, content, and action buttons
|
### Modal (`Modal/`)
|
||||||
- `Modal/ReadOnly.tsx` — Read-only version without action buttons, used in signup form
|
|
||||||
|
Full-page modal shown when a user first visits a My Pages route.
|
||||||
|
|
||||||
|
- Rendered in `app/[lang]/(live)/(protected)/my-pages/layout.tsx`
|
||||||
|
- Only shows if user hasn't made a consent decision (`profilingConsent` is `undefined`)
|
||||||
|
- Uses `memberKey` (from `membershipNumber` or `profileId`) for dismissal tracking
|
||||||
|
- Close via "X" button only (no overlay click or ESC key)
|
||||||
|
- Accept/Decline buttons update consent via API
|
||||||
|
|
||||||
|
**Sub-components:**
|
||||||
|
|
||||||
- `Modal/BenefitCards/` — Cards showcasing personalization benefits
|
- `Modal/BenefitCards/` — Cards showcasing personalization benefits
|
||||||
- `Accordion/` — Privacy and personalization information
|
- `Modal/ReadOnly.tsx` — Informational version without action buttons (used in signup)
|
||||||
- `Banner/` — A banner shown on the account overview page that can reopen the modal
|
|
||||||
|
|
||||||
## Banner
|
### Banner (`Banner/`)
|
||||||
|
|
||||||
- Purpose: Offer a way to reopen the Profiling Consent modal later when the user is ready to decide.
|
Shown on the account overview page when consent is pending.
|
||||||
- Visibility: Decided server-side in `Banner/index.tsx` (render only when consent status is pending once API is available).
|
|
||||||
- Behavior: The client CTA dispatches a `profiling-consent:open` event to reopen the modal.
|
|
||||||
|
|
||||||
## Local Persistence
|
- Server component that checks `userHasConsent()` before rendering
|
||||||
|
- CTA button dispatches `profiling-consent:open` event to reopen the modal
|
||||||
|
- Added to overview via Contentstack Dynamic Content block
|
||||||
|
|
||||||
|
### Alert (`Alert/`)
|
||||||
|
|
||||||
|
Feedback alert shown after consent actions in My Pages.
|
||||||
|
|
||||||
|
- `ProfilingConsentAlertProvider` — Context provider in My Pages layout
|
||||||
|
- Shows success/error states after accepting/declining consent
|
||||||
|
- Success alert includes link to edit preferences
|
||||||
|
|
||||||
|
### Consent Form (`components/Forms/ProfilingConsent/`)
|
||||||
|
|
||||||
|
Dedicated form on `/profile/consent` page for managing consent.
|
||||||
|
|
||||||
|
- Radio button selection for Accept/Decline
|
||||||
|
- Shows current consent status
|
||||||
|
- Redirects to profile after saving
|
||||||
|
- Includes accordion with privacy information
|
||||||
|
|
||||||
|
### Signup Integration
|
||||||
|
|
||||||
|
The signup form (`components/Forms/Signup/index.tsx`) includes:
|
||||||
|
|
||||||
|
- Checkbox to opt-in during registration
|
||||||
|
- "Read more" link that opens `Modal/ReadOnly.tsx`
|
||||||
|
|
||||||
|
### Profile Settings
|
||||||
|
|
||||||
|
`PersonalizationSlot` in `components/MyPages/Profile/CommunicationSettings/` provides a link to the consent management page.
|
||||||
|
|
||||||
|
### Accordion (`Accordion/`)
|
||||||
|
|
||||||
|
Expandable section with privacy and personalization details. Used in both the modal and consent form.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### tRPC Mutations
|
||||||
|
|
||||||
|
- `trpc.user.profilingConsent.update` — Update user's consent preference
|
||||||
|
- `trpc.user.profilingConsentPromptDate.update` — Record when user was prompted
|
||||||
|
|
||||||
|
### tRPC Queries
|
||||||
|
|
||||||
|
- `trpc.contentstack.profilingConsent.get` — Fetch modal/banner content from CMS
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
- `useUpdateProfilingConsent` (`hooks/useUpdateProfilingConsent.ts`) — Handles consent update mutation with alert feedback via `ProfilingConsentAlertProvider`
|
||||||
|
|
||||||
|
## Local Storage
|
||||||
|
|
||||||
|
Modal dismissal is tracked per-member to control auto-open behavior:
|
||||||
|
|
||||||
- Key: `profiling-consent:dismissed:<memberKey>`
|
- Key: `profiling-consent:dismissed:<memberKey>`
|
||||||
- Set when the modal is closed via the header close button.
|
- Set when modal is closed via header close button
|
||||||
- This flag only controls auto-open behavior; it does not reflect Accept/Decline (those are handled via API and used server-side to decide banner visibility).
|
- Does not reflect Accept/Decline (those are stored via API)
|
||||||
|
|
||||||
## Utilities
|
## Utilities
|
||||||
|
|
||||||
Located at `apps/scandic-web/utils/profilingConsent.ts`:
|
Located at `utils/profilingConsent.ts`:
|
||||||
|
|
||||||
- `storageKey(memberKey)`
|
- `storageKey(memberKey)` — Generate storage key
|
||||||
- `readDismissed(memberKey)`
|
- `readDismissed(memberKey)` — Check if modal was dismissed
|
||||||
- `setDismissed(memberKey)`
|
- `setDismissed(memberKey)` — Mark modal as dismissed
|
||||||
- `clearDismissed(memberKey)`
|
- `clearDismissed(memberKey)` — Clear dismissal flag
|
||||||
- `profilingConsentOpenEvent` — CustomEvent name used to request the modal to open
|
- `profilingConsentOpenEvent` — CustomEvent name for opening modal
|
||||||
- `requestOpen()` — Dispatches the open event
|
- `requestOpen()` — Dispatch event to open modal
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
To re-show the modal after dismissing:
|
Re-show modal after dismissing:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// In the browser console:
|
|
||||||
localStorage.removeItem("profiling-consent:dismissed:<memberKey>")
|
localStorage.removeItem("profiling-consent:dismissed:<memberKey>")
|
||||||
// Then refresh the page
|
// Refresh the page
|
||||||
```
|
```
|
||||||
|
|
||||||
To open the modal without clearing the dismissed flag:
|
Open modal programmatically:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
window.dispatchEvent(new CustomEvent("profiling-consent:open"))
|
window.dispatchEvent(new CustomEvent("profiling-consent:open"))
|
||||||
@@ -85,13 +117,18 @@ window.dispatchEvent(new CustomEvent("profiling-consent:open"))
|
|||||||
|
|
||||||
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
|
Replace `<memberKey>` with the actual `membershipNumber` or `profileId`.
|
||||||
|
|
||||||
## Contentstack
|
## Contentstack Setup
|
||||||
|
|
||||||
Profiling Consent setup in Contentstack:
|
Required content for the feature:
|
||||||
|
|
||||||
- Profiling Consent (config)
|
1. **Profiling Consent (config)**
|
||||||
Config needs to be created and published in respective language.
|
|
||||||
- /consent (account page)
|
- Config needs to be created and published in each language
|
||||||
Page needs to be created and published in respective language.
|
|
||||||
- /overview (account page)
|
2. **/consent (account page)**
|
||||||
Need to add Dynamic content: Profiling Consent Banner to respective language, and re-publish the page.
|
|
||||||
|
- Page needs to be created and published in each language
|
||||||
|
|
||||||
|
3. **/overview (account page)**
|
||||||
|
- Add Dynamic content block: "Profiling Consent Banner"
|
||||||
|
- Re-publish the page for each language
|
||||||
|
|||||||
6
apps/scandic-web/env/server.ts
vendored
6
apps/scandic-web/env/server.ts
vendored
@@ -107,6 +107,11 @@ export const env = createEnv({
|
|||||||
.refine((s) => s === "1" || s === "0")
|
.refine((s) => s === "1" || s === "0")
|
||||||
.transform((s) => s === "1")
|
.transform((s) => s === "1")
|
||||||
.default("0"),
|
.default("0"),
|
||||||
|
ENABLE_PROFILE_CONSENT: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => s === "true" || s === "false")
|
||||||
|
.transform((s) => s === "true")
|
||||||
|
.default("false"),
|
||||||
},
|
},
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
@@ -162,5 +167,6 @@ export const env = createEnv({
|
|||||||
CHATBOT_LIVE_LANGS: process.env.CHATBOT_LIVE_LANGS,
|
CHATBOT_LIVE_LANGS: process.env.CHATBOT_LIVE_LANGS,
|
||||||
NEW_STAYS_ON_MY_PAGES: process.env.NEW_STAYS_ON_MY_PAGES,
|
NEW_STAYS_ON_MY_PAGES: process.env.NEW_STAYS_ON_MY_PAGES,
|
||||||
SEO_INERT: process.env.SEO_INERT,
|
SEO_INERT: process.env.SEO_INERT,
|
||||||
|
ENABLE_PROFILE_CONSENT: process.env.ENABLE_PROFILE_CONSENT,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user