good better best prising plus add ons
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* AddonToggles — optional upgrades as simple toggles (spec §8).
|
||||||
|
* Plain-language value statements, never fear-based. "Recommended" badge when the
|
||||||
|
* customer's answers or selected package trigger it. Price/total recompute instantly.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Plus, Check, Sparkles } from 'lucide-react';
|
||||||
|
import { formatUSD, isAddonRecommended } from '../engine/pricing';
|
||||||
|
|
||||||
|
const AddonToggles = ({ addons, selectedIds, onToggle, packageType, triggers }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{addons.map((addon) => {
|
||||||
|
const on = selectedIds.includes(addon.id);
|
||||||
|
const recommended = isAddonRecommended(addon, packageType, triggers);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={addon.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(addon.id)}
|
||||||
|
aria-pressed={on}
|
||||||
|
className={`w-full text-left rounded-2xl border p-4 flex items-start gap-3 transition-all
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-amber-500
|
||||||
|
${on
|
||||||
|
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10'
|
||||||
|
: 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 hover:border-amber-400'}`}
|
||||||
|
>
|
||||||
|
<span className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center border
|
||||||
|
${on ? 'bg-amber-500 border-amber-500 text-white' : 'border-zinc-300 dark:border-white/20 text-transparent'}`}>
|
||||||
|
{on ? <Check size={15} /> : <Plus size={15} className="text-zinc-400" />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-zinc-900 dark:text-white">{addon.name}</span>
|
||||||
|
{recommended && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/15 px-1.5 py-0.5 rounded-full">
|
||||||
|
<Sparkles size={10} /> Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{addon.value}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 shrink-0">
|
||||||
|
+{formatUSD(addon.price)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddonToggles;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* GoodBetterBestCards — the three-option presentation (spec §7).
|
||||||
|
* Rules honored:
|
||||||
|
* - ALWAYS show all three options (never hide alternatives).
|
||||||
|
* - Manufacturer-neutral copy (generic categories only).
|
||||||
|
* - A `highlightedType` may be passed (Phase 4 recommendation) to badge one card,
|
||||||
|
* but every option stays visible and selectable.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Check, Shield, Star } from 'lucide-react';
|
||||||
|
import { pricePackage, formatUSD } from '../engine/pricing';
|
||||||
|
|
||||||
|
const ACCENT = {
|
||||||
|
zinc: { ring: 'ring-zinc-300 dark:ring-zinc-600', text: 'text-zinc-600 dark:text-zinc-300', chip: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300' },
|
||||||
|
blue: { ring: 'ring-blue-400', text: 'text-blue-600 dark:text-blue-400', chip: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400' },
|
||||||
|
amber: { ring: 'ring-amber-400', text: 'text-amber-600 dark:text-amber-400', chip: 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
|
||||||
|
|
||||||
|
const GoodBetterBestCards = ({ packages, pricing, selectedType, onSelect, highlightedType }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{packages.map((pkg) => {
|
||||||
|
const a = ACCENT[pkg.accent] || ACCENT.zinc;
|
||||||
|
const price = pricePackage(pkg, pricing);
|
||||||
|
const selected = selectedType === pkg.type;
|
||||||
|
const highlighted = highlightedType === pkg.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pkg.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(pkg.type)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`relative text-left rounded-2xl border bg-white dark:bg-zinc-900 p-5 transition-all duration-200
|
||||||
|
focus:outline-none flex flex-col
|
||||||
|
${selected
|
||||||
|
? `border-transparent ring-2 ${a.ring} shadow-lg`
|
||||||
|
: 'border-zinc-200 dark:border-white/10 hover:-translate-y-0.5 hover:shadow-md'}`}
|
||||||
|
>
|
||||||
|
{highlighted && (
|
||||||
|
<span className="absolute -top-2.5 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-[11px] font-bold px-2.5 py-0.5 shadow">
|
||||||
|
<Star size={11} /> Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-xs font-bold uppercase tracking-wider ${a.text}`}>{TIER_LABEL[pkg.type]}</span>
|
||||||
|
{selected && <Check size={18} className={a.text} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-zinc-900 dark:text-white mt-1">{pkg.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{pkg.category}</p>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||||
|
{formatUSD(price.low)}–{formatUSD(price.high)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-400">preliminary range · verified at inspection</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
|
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${a.chip}`}>{pkg.warrantyLabel}</span>
|
||||||
|
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 inline-flex items-center gap-1">
|
||||||
|
<Shield size={10} /> {pkg.hailResistance} hail resistance
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-1.5 flex-1">
|
||||||
|
{pkg.scope.map((s) => (
|
||||||
|
<li key={s} className="flex items-start gap-1.5 text-xs text-zinc-600 dark:text-zinc-300">
|
||||||
|
<Check size={13} className={`mt-0.5 shrink-0 ${a.text}`} /> {s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-zinc-400 mt-3 italic">{pkg.position}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoodBetterBestCards;
|
||||||
@@ -16,6 +16,8 @@ import StartStep from './steps/StartStep';
|
|||||||
import AddressStep from './steps/AddressStep';
|
import AddressStep from './steps/AddressStep';
|
||||||
import PremiumStep from './steps/PremiumStep';
|
import PremiumStep from './steps/PremiumStep';
|
||||||
import HailRiskStep from './steps/HailRiskStep';
|
import HailRiskStep from './steps/HailRiskStep';
|
||||||
|
import OptionsStep from './steps/OptionsStep';
|
||||||
|
import AddonsStep from './steps/AddonsStep';
|
||||||
import PlaceholderStep from './steps/PlaceholderStep';
|
import PlaceholderStep from './steps/PlaceholderStep';
|
||||||
|
|
||||||
// Step registry — later phases swap PlaceholderStep entries for real components.
|
// Step registry — later phases swap PlaceholderStep entries for real components.
|
||||||
@@ -24,16 +26,17 @@ const STEP_COMPONENTS = {
|
|||||||
address: AddressStep,
|
address: AddressStep,
|
||||||
premium: PremiumStep,
|
premium: PremiumStep,
|
||||||
hailRisk: HailRiskStep,
|
hailRisk: HailRiskStep,
|
||||||
options: (props) => <PlaceholderStep stepId="options" {...props} />,
|
options: OptionsStep,
|
||||||
addons: (props) => <PlaceholderStep stepId="addons" {...props} />,
|
addons: AddonsStep,
|
||||||
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
recommendation: (props) => <PlaceholderStep stepId="recommendation" {...props} />,
|
||||||
report: (props) => <PlaceholderStep stepId="report" {...props} />,
|
report: (props) => <PlaceholderStep stepId="report" {...props} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validation per step: returns true when the user may advance.
|
// Validation per step: returns true when the user may advance.
|
||||||
function canAdvance(step, inputs) {
|
function canAdvance(step, inputs, session) {
|
||||||
if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim();
|
if (step.id === 'start') return inputs.consent && inputs.name?.trim() && inputs.address?.trim();
|
||||||
if (step.id === 'address') return !!inputs.address?.trim();
|
if (step.id === 'address') return !!inputs.address?.trim();
|
||||||
|
if (step.id === 'options') return !!session.selectedPackage;
|
||||||
if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null;
|
if (step.type === 'cards' && step.required) return inputs[step.inputKey] != null;
|
||||||
if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null;
|
if (step.type === 'slider' && step.required) return inputs[step.inputKey] != null;
|
||||||
return true;
|
return true;
|
||||||
@@ -75,7 +78,7 @@ const WizardShell = () => {
|
|||||||
} = useEstimateWizard();
|
} = useEstimateWizard();
|
||||||
|
|
||||||
const isLast = stepIndex === totalSteps - 1;
|
const isLast = stepIndex === totalSteps - 1;
|
||||||
const advance = canAdvance(step, inputs);
|
const advance = canAdvance(step, inputs, session);
|
||||||
|
|
||||||
const renderBody = () => {
|
const renderBody = () => {
|
||||||
if (step.type === 'cards') {
|
if (step.type === 'cards') {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* AddonsStep — Screen 7b "Add-ons" (spec §8).
|
||||||
|
* Add-ons pre-select from the customer's answers + chosen package (once), then the
|
||||||
|
* customer can toggle freely. Running total recomputes instantly.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||||
|
import AddonToggles from '../AddonToggles';
|
||||||
|
import {
|
||||||
|
pricePackage, addonsTotal, deriveAddonTriggers, defaultSelectedAddons, formatUSD,
|
||||||
|
} from '../../engine/pricing';
|
||||||
|
|
||||||
|
const AddonsStep = () => {
|
||||||
|
const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||||
|
const { addons, packages, pricing } = tenantConfig;
|
||||||
|
|
||||||
|
const packageType = session.selectedPackage || 'BETTER';
|
||||||
|
const triggers = useMemo(() => deriveAddonTriggers(inputs, session.hailSnapshot), [inputs, session.hailSnapshot]);
|
||||||
|
|
||||||
|
// Pre-select recommended add-ons once (spec §8 default-selected rules).
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.addonsInitialized) return;
|
||||||
|
patchSession({
|
||||||
|
selectedAddons: defaultSelectedAddons(addons, packageType, triggers),
|
||||||
|
addonsInitialized: true,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session.addonsInitialized]);
|
||||||
|
|
||||||
|
const selectedIds = session.selectedAddons || [];
|
||||||
|
|
||||||
|
const handleToggle = (id) => {
|
||||||
|
const next = selectedIds.includes(id)
|
||||||
|
? selectedIds.filter((x) => x !== id)
|
||||||
|
: [...selectedIds, id];
|
||||||
|
patchSession({ selectedAddons: next });
|
||||||
|
logEvent('addon_toggled', { id, on: !selectedIds.includes(id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkg = packages.find((p) => p.type === packageType);
|
||||||
|
const base = pkg ? pricePackage(pkg, pricing).base : 0;
|
||||||
|
const addonsSum = addonsTotal(addons, selectedIds);
|
||||||
|
const total = base + addonsSum;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AddonToggles
|
||||||
|
addons={addons}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
packageType={packageType}
|
||||||
|
triggers={triggers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Running total for the selected package */}
|
||||||
|
<div className="sticky bottom-0 rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span>{pkg?.name} (base estimate)</span>
|
||||||
|
<span>{formatUSD(base)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
<span>Selected add-ons ({selectedIds.length})</span>
|
||||||
|
<span>+{formatUSD(addonsSum)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-zinc-200 dark:bg-white/10 my-2.5" />
|
||||||
|
<div className="flex items-center justify-between font-bold text-zinc-900 dark:text-white">
|
||||||
|
<span>Estimated total</span>
|
||||||
|
<span>~{formatUSD(total)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-zinc-400 mt-1.5">
|
||||||
|
Preliminary — verified at inspection. Add-ons can be revised with your rep.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddonsStep;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* OptionsStep — Screen 7 "Build options" (spec §7).
|
||||||
|
* Shows all three Good/Better/Best options; customer selects one. The AI highlight
|
||||||
|
* (Phase 4) will badge a recommended option without hiding the others.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { useEstimateWizard } from '../../EstimateWizardContext';
|
||||||
|
import GoodBetterBestCards from '../GoodBetterBestCards';
|
||||||
|
|
||||||
|
const OptionsStep = () => {
|
||||||
|
const { session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
|
||||||
|
|
||||||
|
const handleSelect = (type) => {
|
||||||
|
patchSession({ selectedPackage: type });
|
||||||
|
logEvent('package_selected', { package: type });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GoodBetterBestCards
|
||||||
|
packages={tenantConfig.packages}
|
||||||
|
pricing={tenantConfig.pricing}
|
||||||
|
selectedType={session.selectedPackage}
|
||||||
|
highlightedType={session.recommendation?.selectedPackage}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3.5 flex items-start gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Info size={14} className="mt-0.5 shrink-0" />
|
||||||
|
These are preliminary ranges based on an assumed roof size. Final pricing requires a roof
|
||||||
|
measurement and inspection — your rep will confirm everything. This is not a binding quote.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OptionsStep;
|
||||||
@@ -143,6 +143,10 @@ export const DEFAULT_PRICING = {
|
|||||||
pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin
|
pitchFactor: 1.0, // multiplied per pitch/stories/complexity in admin
|
||||||
taxPlaceholderPct: 0,
|
taxPlaceholderPct: 0,
|
||||||
permitPlaceholder: 0,
|
permitPlaceholder: 0,
|
||||||
|
// No measurement source in the wizard (spec §3) → assume an average roof size
|
||||||
|
// so the customer sees a PRELIMINARY range. Verified during inspection.
|
||||||
|
assumedSquares: 25, // ~2,500 sq ft of roof surface
|
||||||
|
rangeSpread: 0.08, // ±8% band shown to the customer (low/base/high)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Insurance discount scenarios (spec §10/§14) — low/base/high ───────────────
|
// ── Insurance discount scenarios (spec §10/§14) — low/base/high ───────────────
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* pricing.js — preliminary package + add-on pricing (spec §7, §8, §10, §14).
|
||||||
|
*
|
||||||
|
* Pure functions. All inputs come from tenantConfig (nothing hard-coded per §14).
|
||||||
|
* Because the wizard has no verified roof measurement (spec §3), every price is a
|
||||||
|
* PRELIMINARY RANGE (low/base/high) and must be labeled as such — final pricing
|
||||||
|
* requires inspection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const round = (n, to = 50) => Math.round(n / to) * to;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price a single package for an assumed roof size.
|
||||||
|
* @returns {{ low:number, base:number, high:number }}
|
||||||
|
*/
|
||||||
|
export function pricePackage(pkg, pricing) {
|
||||||
|
const squares = pricing.assumedSquares ?? 25;
|
||||||
|
const waste = pricing.wasteFactor ?? 1.05;
|
||||||
|
const pitch = pricing.pitchFactor ?? 1.0;
|
||||||
|
|
||||||
|
const raw = pkg.pricePerSquare * squares * waste * pitch;
|
||||||
|
const base = Math.max(pricing.minJobPrice ?? 0, raw);
|
||||||
|
const spread = pricing.rangeSpread ?? 0.08;
|
||||||
|
|
||||||
|
return {
|
||||||
|
low: round(base * (1 - spread)),
|
||||||
|
base: round(base),
|
||||||
|
high: round(base * (1 + spread)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price every package (keyed by type). */
|
||||||
|
export function priceAllPackages(packages, pricing) {
|
||||||
|
const out = {};
|
||||||
|
packages.forEach((p) => { out[p.type] = pricePackage(p, pricing); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total price of the selected add-ons (flat, demo). */
|
||||||
|
export function addonsTotal(addons, selectedIds) {
|
||||||
|
return addons
|
||||||
|
.filter((a) => selectedIds.includes(a.id))
|
||||||
|
.reduce((sum, a) => sum + (a.price || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive add-on trigger keys from the customer's wizard answers + hail snapshot.
|
||||||
|
* Drives default-selected add-ons and AI "recommend when…" hints (spec §8).
|
||||||
|
*/
|
||||||
|
export function deriveAddonTriggers(inputs, hailSnapshot) {
|
||||||
|
const t = new Set();
|
||||||
|
if (inputs.issueType === 'leak') t.add('leak');
|
||||||
|
if (inputs.issueType === 'aging' || (inputs.roofAgeYears ?? 0) >= 15) t.add('old_roof');
|
||||||
|
if (inputs.atticConcern === 'attic_hot') t.add('attic_hot');
|
||||||
|
if (inputs.atticConcern === 'energy_bills') t.add('energy_bills');
|
||||||
|
if (inputs.pestConcern === 'yes') t.add('pests');
|
||||||
|
if (inputs.budgetPreference === 'longterm') t.add('aesthetics');
|
||||||
|
if (hailSnapshot?.exposureTier === 'high') t.add('complex');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which add-ons should be pre-selected for a given package + answers (spec §8).
|
||||||
|
* An add-on defaults ON if its triggers fire, or it's recommended on this package.
|
||||||
|
*/
|
||||||
|
export function defaultSelectedAddons(addons, packageType, triggers) {
|
||||||
|
return addons
|
||||||
|
.filter((a) =>
|
||||||
|
(a.recommendedOn || []).includes(packageType) ||
|
||||||
|
(a.triggerKeys || []).some((k) => triggers.has(k))
|
||||||
|
)
|
||||||
|
.map((a) => a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is an add-on "recommended" for display badge purposes? */
|
||||||
|
export function isAddonRecommended(addon, packageType, triggers) {
|
||||||
|
return (addon.recommendedOn || []).includes(packageType) ||
|
||||||
|
(addon.triggerKeys || []).some((k) => triggers.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUSD(n) {
|
||||||
|
return `$${Math.round(n).toLocaleString('en-US')}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user