Improve shop UX, starting balance, and top bar branding.
Give new users 100 free points, allow shop previews regardless of balance, rename clothing items with descriptive labels, and show only the HaBit logo in the top-left.
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { HabitBrand } from "@/components/layout/habit-brand";
|
||||
import { HomeLogoLink } from "@/components/layout/home-logo-link";
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -26,12 +25,8 @@ export function AppTopBar({ className }: AppTopBarProps) {
|
||||
paddingTop: "env(safe-area-inset-top, 0px)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center justify-between px-4">
|
||||
<HomeLogoLink
|
||||
className="mt-0"
|
||||
aria-current={isHome ? "page" : undefined}
|
||||
/>
|
||||
<HabitBrand />
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center px-4">
|
||||
<HabitBrand aria-current={isHome ? "page" : undefined} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { routes } from "@/lib/routes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HABIT_LOGO_SRC = "/habit-logo.png";
|
||||
|
||||
type HabitBrandProps = {
|
||||
className?: string;
|
||||
href?: string;
|
||||
"aria-current"?: "page" | undefined;
|
||||
};
|
||||
|
||||
export function HabitBrand({ className }: HabitBrandProps) {
|
||||
export function HabitBrand({
|
||||
className,
|
||||
href = routes.avatar,
|
||||
"aria-current": ariaCurrent,
|
||||
}: HabitBrandProps) {
|
||||
const [useText, setUseText] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
href={href}
|
||||
aria-label="Go to home"
|
||||
aria-current={ariaCurrent}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)]",
|
||||
"group flex shrink-0 items-center justify-center border-2 border-border bg-card/80 px-3 py-1.5 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
className,
|
||||
)}
|
||||
aria-label="HaBit"
|
||||
>
|
||||
{!useText ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -37,6 +47,6 @@ export function HabitBrand({ className }: HabitBrandProps) {
|
||||
HaBit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from "@/components/ui/toast-provider";
|
||||
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
||||
import { getAvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { defaultAvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { DEFAULT_GRAY_COLOR } from "@/lib/character/presets";
|
||||
import { getRoomBackground, normalizeRoomBackgroundId } from "@/lib/room-backgrounds";
|
||||
import {
|
||||
@@ -145,26 +146,24 @@ export function ShopContent() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingItemId, setPendingItemId] = useState<string | null>(null);
|
||||
const [baseCustomization, setBaseCustomization] =
|
||||
useState<AvatarCustomization | null>(null);
|
||||
useState<AvatarCustomization>(defaultAvatarCustomization);
|
||||
const [previewItem, setPreviewItem] = useState<ShopItemRecord | null>(null);
|
||||
|
||||
const refreshShop = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
let nextEquippedItems: string[] = [];
|
||||
let nextEquippedRoomBackground = "room-day";
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const [inventory, customization] = await Promise.all([
|
||||
getShopInventory(),
|
||||
getAvatarCustomization(),
|
||||
]);
|
||||
const inventory = await getShopInventory();
|
||||
setItems(inventory.items);
|
||||
setOwnedItemIds(inventory.ownedItemIds);
|
||||
setEquippedItems(inventory.equippedItems);
|
||||
setEquippedRoomBackground(inventory.equippedRoomBackground);
|
||||
setCoins(inventory.coins);
|
||||
setBaseCustomization({
|
||||
...customization,
|
||||
equippedItems: inventory.equippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(inventory.equippedRoomBackground),
|
||||
});
|
||||
nextEquippedItems = inventory.equippedItems;
|
||||
nextEquippedRoomBackground = inventory.equippedRoomBackground;
|
||||
} catch (refreshError) {
|
||||
setError(
|
||||
refreshError instanceof Error
|
||||
@@ -173,6 +172,21 @@ export function ShopContent() {
|
||||
);
|
||||
setCoins(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const customization = await getAvatarCustomization();
|
||||
setBaseCustomization({
|
||||
...customization,
|
||||
equippedItems: nextEquippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
|
||||
});
|
||||
} catch {
|
||||
setBaseCustomization((current) => ({
|
||||
...current,
|
||||
equippedItems: nextEquippedItems,
|
||||
roomBackground: normalizeRoomBackgroundId(nextEquippedRoomBackground),
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -242,12 +256,39 @@ export function ShopContent() {
|
||||
|
||||
const itemGridClassName = "grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3";
|
||||
|
||||
const previewOwned = previewItem
|
||||
? ownedItemIds.includes(previewItem.id)
|
||||
: false;
|
||||
const previewEquipped = previewItem
|
||||
? previewItem.type === "room"
|
||||
? equippedRoomBackground === previewItem.id
|
||||
: equippedItems.includes(previewItem.id)
|
||||
: false;
|
||||
const previewCanAfford = previewItem
|
||||
? coins !== null && coins >= previewItem.price
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShopItemPreviewModal
|
||||
item={previewItem}
|
||||
baseCustomization={baseCustomization}
|
||||
coins={coins}
|
||||
owned={previewOwned}
|
||||
equipped={previewEquipped}
|
||||
canAfford={previewCanAfford}
|
||||
isPending={previewItem !== null && pendingItemId === previewItem.id}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
onPurchase={() => {
|
||||
if (previewItem) {
|
||||
void handlePurchase(previewItem.id);
|
||||
}
|
||||
}}
|
||||
onEquipToggle={() => {
|
||||
if (previewItem) {
|
||||
void handleEquipToggle(previewItem);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-3 border-2 border-border bg-muted/40 px-4 py-3 shadow-[var(--retro-shadow-sm)]">
|
||||
|
||||
@@ -16,14 +16,28 @@ import {
|
||||
|
||||
type ShopItemPreviewModalProps = {
|
||||
item: ShopItemRecord | null;
|
||||
baseCustomization: AvatarCustomization | null;
|
||||
baseCustomization: AvatarCustomization;
|
||||
coins: number | null;
|
||||
owned: boolean;
|
||||
equipped: boolean;
|
||||
canAfford: boolean;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onPurchase: () => void;
|
||||
onEquipToggle: () => void;
|
||||
};
|
||||
|
||||
export function ShopItemPreviewModal({
|
||||
item,
|
||||
baseCustomization,
|
||||
coins,
|
||||
owned,
|
||||
equipped,
|
||||
canAfford,
|
||||
isPending,
|
||||
onClose,
|
||||
onPurchase,
|
||||
onEquipToggle,
|
||||
}: ShopItemPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
if (!item) {
|
||||
@@ -42,7 +56,7 @@ export function ShopItemPreviewModal({
|
||||
};
|
||||
}, [item, onClose]);
|
||||
|
||||
if (!item || !baseCustomization) {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,8 +118,54 @@ export function ShopItemPreviewModal({
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
||||
</div>
|
||||
<p className="mt-3 text-center text-[10px] text-muted-foreground">
|
||||
This preview does not equip or purchase the item.
|
||||
Preview only — nothing is purchased or equipped until you confirm.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-2 border-t-2 border-border pt-4">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span>{item.price} points</span>
|
||||
</div>
|
||||
|
||||
{owned ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={equipped ? "default" : "outline"}
|
||||
disabled={
|
||||
isPending || (item.type !== "room" && equipped)
|
||||
}
|
||||
onClick={onEquipToggle}
|
||||
>
|
||||
{isPending
|
||||
? "Updating..."
|
||||
: equipped
|
||||
? "Equipped"
|
||||
: "Equip"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!canAfford && coins !== null ? (
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
You need {item.price - coins} more points to buy this item.
|
||||
Keep completing habits to earn more.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={isPending || !canAfford}
|
||||
onClick={onPurchase}
|
||||
>
|
||||
{isPending
|
||||
? "Processing..."
|
||||
: canAfford
|
||||
? `Buy for ${item.price} points`
|
||||
: "Need more points"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_STARTING_COINS } from "@/lib/coins";
|
||||
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
|
||||
import {
|
||||
hexToHsl,
|
||||
@@ -204,6 +205,7 @@ export async function getAvatarCustomization(): Promise<AvatarCustomization> {
|
||||
if (!data) {
|
||||
const { error: insertError } = await supabase.from("avatar_state").insert({
|
||||
user_id: userId,
|
||||
coins: DEFAULT_STARTING_COINS,
|
||||
avatar_name: DEFAULT_AVATAR_NAME,
|
||||
skin_color: DEFAULT_HEX,
|
||||
pants_style: NONE_VARIANT_ID,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_STARTING_COINS } from "@/lib/coins";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
async function getAuthenticatedUserId() {
|
||||
@@ -35,13 +36,13 @@ export async function getCoins(): Promise<number> {
|
||||
if (!data) {
|
||||
const { error: insertError } = await supabase
|
||||
.from("avatar_state")
|
||||
.insert({ user_id: userId, coins: 0 });
|
||||
.insert({ user_id: userId, coins: DEFAULT_STARTING_COINS });
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(insertError.message);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return DEFAULT_STARTING_COINS;
|
||||
}
|
||||
|
||||
return data.coins ?? 0;
|
||||
|
||||
@@ -59,6 +59,33 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset {
|
||||
return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
}
|
||||
|
||||
export const VARIANT_DISPLAY_NAMES: Record<string, string> = {
|
||||
"skin-1": "Default Form",
|
||||
"pants-1": "Slim Fit Jeans",
|
||||
"pants-2": "Rolled Cuff Shorts",
|
||||
"pants-3": "Utility Cargo Pants",
|
||||
"shoes-1": "Canvas Sneakers",
|
||||
"shoes-2": "Chunky High-Tops",
|
||||
"shoes-3": "Neon Glow Runners",
|
||||
"torso-1": "Crew Neck Tee",
|
||||
"torso-2": "Zip-Up Hoodie",
|
||||
"torso-3": "Cropped Tank Top",
|
||||
"torso-4": "Crimson Plate Armor",
|
||||
"eyes-1": "Round Gentle Eyes",
|
||||
"eyes-2": "Starry Spark Eyes",
|
||||
"head-1": "Spiked Mohawk",
|
||||
"head-2": "Cozy Knit Beanie",
|
||||
"head-3": "Retro Snapback Cap",
|
||||
"head-4": "Fiery Side Mohawk",
|
||||
"head-5": "Crested Warrior Helm",
|
||||
"head-6": "Flowing Side Locks",
|
||||
"head-7": "Messy Shag Cut",
|
||||
};
|
||||
|
||||
export function getVariantDisplayName(variantId: string, fallbackLabel = variantId) {
|
||||
return VARIANT_DISPLAY_NAMES[variantId] ?? fallbackLabel;
|
||||
}
|
||||
|
||||
function layerVariants(
|
||||
layerId: Exclude<CharacterLayerId, "skin">,
|
||||
label: string,
|
||||
@@ -66,9 +93,10 @@ function layerVariants(
|
||||
): LayerVariant[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const number = index + 1;
|
||||
const id = `${layerId}-${number}`;
|
||||
return {
|
||||
id: `${layerId}-${number}`,
|
||||
label: `${label} ${number}`,
|
||||
id,
|
||||
label: getVariantDisplayName(id, `${label} ${number}`),
|
||||
src: `/character/${layerId}/${layerId}-${number}.png`,
|
||||
};
|
||||
});
|
||||
@@ -117,7 +145,11 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
|
||||
label: "Skin",
|
||||
allowVariants: false,
|
||||
variants: [
|
||||
{ id: "skin-1", label: "Skin 1", src: "/character/skin/skin-1.png" },
|
||||
{
|
||||
id: "skin-1",
|
||||
label: getVariantDisplayName("skin-1", "Skin 1"),
|
||||
src: "/character/skin/skin-1.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const DEFAULT_STARTING_COINS = 100;
|
||||
|
||||
export const COINS_PER_TASK_COMPLETE = 50;
|
||||
export const COINS_PER_TASK_UNCHECK = -50;
|
||||
|
||||
|
||||
@@ -24,15 +24,15 @@ where id in (
|
||||
|
||||
insert into public.shop_items (id, name, type, price, image_path)
|
||||
values
|
||||
('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'),
|
||||
('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||
('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'),
|
||||
('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'),
|
||||
('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'),
|
||||
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'),
|
||||
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),
|
||||
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'),
|
||||
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'),
|
||||
('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'),
|
||||
('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||
('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'),
|
||||
('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'),
|
||||
('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'),
|
||||
('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'),
|
||||
('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'),
|
||||
('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
|
||||
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
|
||||
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
|
||||
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
|
||||
on conflict (id) do update
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Replace generic shop item names with descriptive labels.
|
||||
update public.shop_items set name = 'Utility Cargo Pants' where id = 'pants-3';
|
||||
update public.shop_items set name = 'Neon Glow Runners' where id = 'shoes-3';
|
||||
update public.shop_items set name = 'Cropped Tank Top' where id = 'torso-3';
|
||||
update public.shop_items set name = 'Crimson Plate Armor' where id = 'torso-4';
|
||||
update public.shop_items set name = 'Retro Snapback Cap' where id = 'head-3';
|
||||
update public.shop_items set name = 'Fiery Side Mohawk' where id = 'head-4';
|
||||
update public.shop_items set name = 'Crested Warrior Helm' where id = 'head-5';
|
||||
update public.shop_items set name = 'Flowing Side Locks' where id = 'head-6';
|
||||
update public.shop_items set name = 'Messy Shag Cut' where id = 'head-7';
|
||||
@@ -0,0 +1,84 @@
|
||||
-- New users start with 100 free shop points.
|
||||
alter table public.avatar_state
|
||||
alter column coins set default 100;
|
||||
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
insert into public.profiles (id, display_name)
|
||||
values (
|
||||
new.id,
|
||||
coalesce(new.raw_user_meta_data ->> 'display_name', split_part(new.email, '@', 1))
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
insert into public.avatar_state (user_id, coins)
|
||||
values (new.id, 100)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.purchase_shop_item(p_item_id text)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_user_id uuid := auth.uid();
|
||||
v_item public.shop_items%rowtype;
|
||||
v_coins int;
|
||||
begin
|
||||
if v_user_id is null then
|
||||
raise exception 'You must be signed in to purchase items.';
|
||||
end if;
|
||||
|
||||
select * into v_item from public.shop_items where id = p_item_id;
|
||||
if not found then
|
||||
raise exception 'That shop item is no longer available.';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1 from public.user_items
|
||||
where user_id = v_user_id and item_id = p_item_id
|
||||
) then
|
||||
raise exception 'You already own this item.';
|
||||
end if;
|
||||
|
||||
select coins into v_coins
|
||||
from public.avatar_state
|
||||
where user_id = v_user_id
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
insert into public.avatar_state (user_id, coins) values (v_user_id, 100);
|
||||
v_coins := 100;
|
||||
end if;
|
||||
|
||||
if v_coins < v_item.price then
|
||||
raise exception 'Not enough points for this purchase.';
|
||||
end if;
|
||||
|
||||
update public.avatar_state
|
||||
set coins = v_coins - v_item.price, updated_at = now()
|
||||
where user_id = v_user_id;
|
||||
|
||||
insert into public.user_items (user_id, item_id)
|
||||
values (v_user_id, p_item_id);
|
||||
|
||||
return jsonb_build_object(
|
||||
'item_id', v_item.id,
|
||||
'name', v_item.name,
|
||||
'type', v_item.type,
|
||||
'price', v_item.price,
|
||||
'image_path', v_item.image_path,
|
||||
'coins', v_coins - v_item.price
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
@@ -24,15 +24,15 @@ where id in (
|
||||
|
||||
insert into public.shop_items (id, name, type, price, image_path)
|
||||
values
|
||||
('pants-3', 'Pants 3', 'pants', 30, '/character/pants/pants-3.png'),
|
||||
('shoes-3', 'Shoes 3', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||
('torso-3', 'Torso 3', 'torso', 35, '/character/torso/torso-3.png'),
|
||||
('torso-4', 'Torso 4', 'torso', 40, '/character/torso/torso-4.png'),
|
||||
('head-3', 'Head 3', 'head', 40, '/character/head/head-3.png'),
|
||||
('head-4', 'Head 4', 'head', 45, '/character/head/head-4.png'),
|
||||
('head-5', 'Head 5', 'head', 50, '/character/head/head-5.png'),
|
||||
('head-6', 'Head 6', 'head', 55, '/character/head/head-6.png'),
|
||||
('head-7', 'Head 7', 'head', 60, '/character/head/head-7.png'),
|
||||
('pants-3', 'Utility Cargo Pants', 'pants', 30, '/character/pants/pants-3.png'),
|
||||
('shoes-3', 'Neon Glow Runners', 'shoes', 30, '/character/shoes/shoes-3.png'),
|
||||
('torso-3', 'Cropped Tank Top', 'torso', 35, '/character/torso/torso-3.png'),
|
||||
('torso-4', 'Crimson Plate Armor', 'torso', 40, '/character/torso/torso-4.png'),
|
||||
('head-3', 'Retro Snapback Cap', 'head', 40, '/character/head/head-3.png'),
|
||||
('head-4', 'Fiery Side Mohawk', 'head', 45, '/character/head/head-4.png'),
|
||||
('head-5', 'Crested Warrior Helm', 'head', 50, '/character/head/head-5.png'),
|
||||
('head-6', 'Flowing Side Locks', 'head', 55, '/character/head/head-6.png'),
|
||||
('head-7', 'Messy Shag Cut', 'head', 60, '/character/head/head-7.png'),
|
||||
('room-night', 'Starlit Night', 'room', 40, '/shop/room-night.svg'),
|
||||
('room-forest', 'Mystic Forest', 'room', 50, '/shop/room-forest.svg')
|
||||
on conflict (id) do update
|
||||
|
||||
Reference in New Issue
Block a user