/**
 * @fileoverview Imperative helpers for creating and updating shop item slots.
 */

import { OnoeNum } from "@rbxts/serikanum";
import { TweenService } from "@rbxts/services";
import displayBalanceCurrency from "client/components/balance/displayBalanceCurrency";
import ItemViewport from "shared/item/ItemViewport";
import { getAsset } from "shared/asset/AssetMap";
import { RobotoSlabHeavy } from "shared/asset/GameFonts";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import { CURRENCY_DETAILS } from "shared/currency/CurrencyDetails";
import Item from "shared/item/Item";
import Items from "shared/items/Items";

const BUTTON_STROKE_GRADIENT = new ColorSequence([
    new ColorSequenceKeypoint(0, Color3.fromRGB(255, 255, 255)),
    new ColorSequenceKeypoint(0.3, Color3.fromRGB(255, 255, 255)),
    new ColorSequenceKeypoint(0.5, Color3.fromRGB(118, 118, 118)),
    new ColorSequenceKeypoint(0.8, Color3.fromRGB(255, 255, 255)),
    new ColorSequenceKeypoint(1, Color3.fromRGB(255, 255, 255)),
]);

const BACKGROUND_GRADIENT = new ColorSequence([
    new ColorSequenceKeypoint(0, Color3.fromRGB(72, 72, 72)),
    new ColorSequenceKeypoint(1, Color3.fromRGB(76, 76, 76)),
]);

const PRICE_TEXT_TWEEN_INFO = new TweenInfo(0.3, Enum.EasingStyle.Quart, Enum.EasingDirection.Out);

interface CurrencyOption {
    kind: "currency";
    currency: Currency;
    amount: OnoeNum | number;
}

interface ItemOption {
    kind: "item";
    item: Item;
    amount: number;
}

interface MaxedOption {
    kind: "maxed";
}

type PriceOption = CurrencyOption | ItemOption | MaxedOption;

type PriceOptionSequence = Array<CurrencyOption | ItemOption>;

function clampColorComponent(value: number) {
    return math.clamp(value, 0.1, 0.9);
}

function applyStyling(parent: GuiObject, gridTransparency: number) {
    const gradient = new Instance("UIGradient");
    gradient.Color = BACKGROUND_GRADIENT;
    gradient.Rotation = 90;
    gradient.Parent = parent;

    const vignette = new Instance("ImageLabel");
    vignette.Name = "Vignette";
    vignette.BackgroundTransparency = 1;
    vignette.Image = getAsset("assets/Vignette.png");
    vignette.ImageTransparency = 0.2;
    vignette.Size = new UDim2(1, 0, 1, 0);
    vignette.ZIndex = -4;
    vignette.Parent = parent;

    const grid = new Instance("ImageLabel");
    grid.Name = "Grid";
    grid.AnchorPoint = new Vector2(0.5, 0.5);
    grid.BackgroundTransparency = 1;
    grid.Image = getAsset("assets/Grid.png");
    grid.ImageColor3 = Color3.fromRGB(126, 126, 126);
    grid.ImageTransparency = gridTransparency;
    grid.Position = new UDim2(0.5, 0, 0.5, 0);
    grid.ScaleType = Enum.ScaleType.Tile;
    grid.Size = new UDim2(1, 0, 1, 0);
    grid.TileSize = new UDim2(0, 100, 0, 100);
    grid.ZIndex = -5;
    grid.Parent = parent;
}

function createStroke(parent: GuiObject, color: Color3) {
    const stroke = new Instance("UIStroke");
    stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border;
    stroke.Color = color;
    stroke.Thickness = 2;
    stroke.Parent = parent;

    const strokeGradient = new Instance("UIGradient");
    strokeGradient.Color = BUTTON_STROKE_GRADIENT;
    strokeGradient.Rotation = 35;
    strokeGradient.Parent = stroke;

    return stroke;
}

function tweenPriceColor(handle: ShopSlotHandle, target: Color3, shouldTween: boolean) {
    if (handle.currentTween) {
        handle.currentTween.Cancel();
        handle.currentTween = undefined;
    }

    if (!shouldTween || !handle.root.Visible) {
        handle.priceText.TextColor3 = target;
        return;
    }

    const tween = TweenService.Create(handle.priceText, PRICE_TEXT_TWEEN_INFO, {
        TextColor3: target,
    });
    handle.currentTween = tween;
    tween.Play();
}

function applyPriceOption(handle: ShopSlotHandle, option: PriceOption) {
    if (option.kind === "currency") {
        const details = CURRENCY_DETAILS[option.currency];
        handle.priceImage.Visible = true;
        handle.priceImage.Image = details.image;
        handle.priceViewport.Visible = false;
        handle.currentPriceItemId = undefined;
        handle.priceText.Text = displayBalanceCurrency(option.currency, option.amount);
        const targetColor = new Color3(
            clampColorComponent(details.color.R),
            clampColorComponent(details.color.G),
            clampColorComponent(details.color.B),
        );
        tweenPriceColor(handle, targetColor, true);
        return;
    }

    if (option.kind === "item") {
        handle.priceImage.Visible = option.item.image !== undefined;
        handle.priceViewport.Visible = option.item.image === undefined;
        handle.priceText.Text = tostring(option.amount);

        const itemColor = option.item.difficulty?.color ?? Color3.fromRGB(255, 255, 255);
        const targetColor = new Color3(
            clampColorComponent(itemColor.R),
            clampColorComponent(itemColor.G),
            clampColorComponent(itemColor.B),
        );
        tweenPriceColor(handle, targetColor, true);

        if (option.item.image !== undefined) {
            handle.priceImage.Image = option.item.image;
            handle.currentPriceItemId = undefined;
            return;
        }

        if (handle.currentPriceItemId !== option.item.id) {
            ItemViewport.loadItemIntoViewport(handle.priceViewport, option.item.id);
            handle.currentPriceItemId = option.item.id;
        }
        return;
    }

    handle.priceImage.Visible = false;
    handle.priceViewport.Visible = false;
    handle.priceText.Text = "MAXED";
    tweenPriceColor(handle, Color3.fromRGB(199, 199, 199), false);
    handle.currentPriceItemId = undefined;
}

function buildPriceOptions(item: Item, price: CurrencyBundle | undefined): PriceOptionSequence {
    const options = new Array<CurrencyOption | ItemOption>();

    if (price) {
        for (const [currency] of CurrencyBundle.SORTED_DETAILS) {
            const amount = price.get(currency);
            if (amount === undefined) continue;
            options.push({ kind: "currency", currency, amount });
        }
    }

    const requiredItems = item.requiredItems;
    if (requiredItems.size() > 0) {
        for (const candidate of Items.sortedItems) {
            const requiredAmount = requiredItems.get(candidate.id);
            if (requiredAmount === undefined) continue;
            options.push({ kind: "item", item: candidate, amount: requiredAmount });
        }
    }

    return options;
}

function startPriceRotation(handle: ShopSlotHandle, options: PriceOptionSequence) {
    const token = ++handle.rotationToken;
    let index = 0;
    const rotate = () => {
        if (handle.destroyed || handle.rotationToken !== token || !handle.root.Visible) {
            return;
        }
        index = (index + 1) % options.size();
        const option = options[index];
        applyPriceOption(handle, option);
        task.delay(2, rotate);
    };
    task.delay(2, rotate);
}

export interface CreateShopSlotOptions {
    parent: GuiObject;
    layoutOrder?: number;
    visible?: boolean;
    viewportsEnabled?: boolean;
    onActivated: (item: Item) => void;
}

export interface UpdateShopSlotOptions {
    layoutOrder?: number;
    baseVisible?: boolean;
    hideMaxedItems?: boolean;
    ownedAmount: number;
    onActivated?: (item: Item) => void;
    empireLevel?: number;
}

export type ShopSlotHandle = {
    item: Item;
    root: Frame;
    button: TextButton;
    priceFrame: Frame;
    priceOptionFrame: Frame;
    priceImage: ImageLabel;
    priceViewport: ViewportFrame;
    priceText: TextLabel;
    stroke: UIStroke;
    connections: RBXScriptConnection[];
    viewportLoaded: boolean;
    itemImage?: ImageLabel;
    itemViewport?: ViewportFrame;
    lockOverlay: Frame;
    lockIcon: ImageLabel;
    lockText: TextLabel;
    currentTween?: Tween;
    rotationToken: number;
    onActivated?: (item: Item) => void;
    destroyed?: boolean;
    currentPriceItemId?: string;
    isLocked: boolean;
    destroy(): void;
};

export function createShopSlot(item: Item, options: CreateShopSlotOptions): ShopSlotHandle {
    const { parent, layoutOrder = item.layoutOrder, visible = false, onActivated, viewportsEnabled = true } = options;

    const baseColor = item.difficulty?.color ?? Color3.fromRGB(52, 155, 255);

    const root = new Instance("Frame");
    root.Name = `ShopSlot_${item.id}`;
    root.BackgroundTransparency = 1;
    root.Size = new UDim2(1, 0, 1, 0);
    root.LayoutOrder = layoutOrder;
    root.Visible = visible;
    root.Parent = parent;

    const button = new Instance("TextButton");
    button.Name = "ItemButton";
    button.BackgroundColor3 = baseColor;
    button.BorderColor3 = Color3.fromRGB(0, 0, 0);
    button.BorderSizePixel = 5;
    button.Size = new UDim2(1, 0, 1, -40);
    button.Selectable = false;
    button.Text = "";
    button.AutoButtonColor = true;
    button.Parent = root;

    const padding = new Instance("UIPadding");
    padding.PaddingBottom = new UDim(0, 4);
    padding.PaddingTop = new UDim(0, 4);
    padding.PaddingLeft = new UDim(0, 4);
    padding.PaddingRight = new UDim(0, 4);
    padding.Parent = button;

    const stroke = createStroke(button, baseColor);
    applyStyling(button, 0.85);

    const lockOverlay = new Instance("Frame");
    lockOverlay.Name = "LockOverlay";
    lockOverlay.Active = false;
    lockOverlay.BackgroundColor3 = Color3.fromRGB(0, 0, 0);
    lockOverlay.BackgroundTransparency = 0.35;
    lockOverlay.Visible = false;
    lockOverlay.Size = new UDim2(1, 0, 1, 0);
    lockOverlay.ZIndex = 10;
    lockOverlay.Parent = button;

    getAsset("assets/Lock.png");

    const lockCorner = new Instance("UICorner");
    lockCorner.Parent = lockOverlay;

    const lockIcon = new Instance("ImageLabel");
    lockIcon.Name = "LockIcon";
    lockIcon.AnchorPoint = new Vector2(0.5, 0.5);
    lockIcon.BackgroundTransparency = 1;
    lockIcon.Image = getAsset("assets/Lock.png");
    lockIcon.Position = new UDim2(0.5, 0, 0.45, 0);
    lockIcon.Size = new UDim2(0.35, 0, 0.35, 0);
    lockIcon.SizeConstraint = Enum.SizeConstraint.RelativeYY;
    lockIcon.Visible = false;
    lockIcon.ZIndex = 11;
    lockIcon.Parent = lockOverlay;

    const lockText = new Instance("TextLabel");
    lockText.Name = "LockText";
    lockText.AnchorPoint = new Vector2(0.5, 0.5);
    lockText.BackgroundTransparency = 1;
    lockText.FontFace = RobotoSlabHeavy;
    lockText.Position = new UDim2(0.5, 0, 0.75, 0);
    lockText.Size = new UDim2(0.7, 0, 0.25, 0);
    lockText.Text = "";
    lockText.TextColor3 = Color3.fromRGB(255, 255, 255);
    lockText.TextScaled = true;
    lockText.TextStrokeTransparency = 0.1;
    lockText.TextStrokeColor3 = Color3.fromRGB(0, 0, 0);
    lockText.TextWrapped = true;
    lockText.TextXAlignment = Enum.TextXAlignment.Center;
    lockText.TextYAlignment = Enum.TextYAlignment.Center;
    lockText.LineHeight = 0.9;
    lockText.ZIndex = 11;
    lockText.Parent = lockOverlay;

    let itemImage: ImageLabel | undefined;
    let itemViewport: ViewportFrame | undefined;
    if (item.image !== undefined) {
        itemImage = new Instance("ImageLabel");
        itemImage.Name = "ItemImage";
        itemImage.AnchorPoint = new Vector2(0.5, 0.5);
        itemImage.BackgroundTransparency = 1;
        itemImage.Image = item.image;
        itemImage.Position = new UDim2(0.5, 0, 0.5, 0);
        itemImage.Size = new UDim2(0.7, 0, 0.7, 0);
        itemImage.SizeConstraint = Enum.SizeConstraint.RelativeYY;
        itemImage.Parent = button;
    } else {
        itemViewport = new Instance("ViewportFrame");
        itemViewport.Name = "ItemViewport";
        itemViewport.AnchorPoint = new Vector2(0.5, 0.5);
        itemViewport.BackgroundTransparency = 1;
        itemViewport.Position = new UDim2(0.5, 0, 0.5, 0);
        itemViewport.Size = new UDim2(1, 0, 1, 0);
        itemViewport.Parent = button;
        if (viewportsEnabled) ItemViewport.loadItemIntoViewport(itemViewport, item.id);
    }

    const priceFrame = new Instance("Frame");
    priceFrame.Name = "PriceFrame";
    priceFrame.AnchorPoint = new Vector2(0, 1);
    priceFrame.BackgroundColor3 = baseColor.Lerp(Color3.fromRGB(0, 0, 0), 0.7);
    priceFrame.BorderColor3 = Color3.fromRGB(0, 0, 0);
    priceFrame.BorderSizePixel = 5;
    priceFrame.Position = new UDim2(0, 0, 1, 0);
    priceFrame.Size = new UDim2(1, 0, 0, 30);
    priceFrame.Parent = root;

    const priceOptionFrame = new Instance("Frame");
    priceOptionFrame.Name = "PriceOption";
    priceOptionFrame.AutomaticSize = Enum.AutomaticSize.X;
    priceOptionFrame.BackgroundTransparency = 1;
    priceOptionFrame.Size = new UDim2(1, 0, 0, 28);
    priceOptionFrame.Parent = priceFrame;

    const priceCorner = new Instance("UICorner");
    priceCorner.CornerRadius = new UDim(0, 4);
    priceCorner.Parent = priceOptionFrame;

    const priceList = new Instance("UIListLayout");
    priceList.Padding = new UDim(0, 5);
    priceList.FillDirection = Enum.FillDirection.Horizontal;
    priceList.HorizontalAlignment = Enum.HorizontalAlignment.Center;
    priceList.Parent = priceOptionFrame;

    const priceImage = new Instance("ImageLabel");
    priceImage.Name = "PriceImage";
    priceImage.BackgroundTransparency = 1;
    priceImage.Size = new UDim2(1, 0, 1, 0);
    priceImage.SizeConstraint = Enum.SizeConstraint.RelativeYY;
    priceImage.Visible = false;
    priceImage.Parent = priceOptionFrame;

    const priceViewport = new Instance("ViewportFrame");
    priceViewport.Name = "PriceViewport";
    priceViewport.BackgroundTransparency = 1;
    priceViewport.Size = new UDim2(1, 0, 1, 0);
    priceViewport.SizeConstraint = Enum.SizeConstraint.RelativeYY;
    priceViewport.Visible = false;
    priceViewport.Parent = priceOptionFrame;

    const priceText = new Instance("TextLabel");
    priceText.Name = "PriceText";
    priceText.AutomaticSize = Enum.AutomaticSize.X;
    priceText.BackgroundTransparency = 1;
    priceText.FontFace = RobotoSlabHeavy;
    priceText.Size = new UDim2(0, 0, 1, 0);
    priceText.Text = "";
    priceText.TextScaled = true;
    priceText.Parent = priceOptionFrame;

    const priceStroke = new Instance("UIStroke");
    priceStroke.Color = Color3.fromRGB(0, 0, 0);
    priceStroke.Thickness = 2;
    priceStroke.Parent = priceText;

    applyStyling(priceFrame, 0.9);

    const connections = new Array<RBXScriptConnection>();

    const handle: ShopSlotHandle = {
        item,
        root,
        button,
        priceFrame,
        priceOptionFrame,
        priceImage,
        priceViewport,
        priceText,
        stroke,
        connections,
        itemImage,
        viewportLoaded: true,
        itemViewport,
        lockOverlay,
        lockIcon,
        lockText,
        rotationToken: 0,
        onActivated,
        isLocked: false,
        destroy() {
            if (this.destroyed) return;
            this.destroyed = true;
            for (const connection of this.connections) {
                connection.Disconnect();
            }
            if (this.currentTween) {
                this.currentTween.Cancel();
                this.currentTween = undefined;
            }
            this.root.Destroy();
        },
    };

    connections.push(
        button.Activated.Connect(() => {
            if (handle.isLocked) return;
            handle.onActivated?.(item);
        }),
    );

    return handle;
}

export function updateShopSlot(handle: ShopSlotHandle, options: UpdateShopSlotOptions) {
    if (handle.destroyed) return;

    const { layoutOrder, baseVisible = false, hideMaxedItems = false, ownedAmount, onActivated, empireLevel } = options;

    if (layoutOrder !== undefined) {
        handle.root.LayoutOrder = layoutOrder;
    }

    if (onActivated) {
        handle.onActivated = onActivated;
    }

    const itemColor = handle.item.difficulty?.color ?? Color3.fromRGB(52, 155, 255);
    handle.button.BackgroundColor3 = itemColor;
    handle.stroke.Color = itemColor;
    handle.priceFrame.BackgroundColor3 = itemColor.Lerp(Color3.fromRGB(0, 0, 0), 0.7);

    if (handle.itemViewport && !handle.viewportLoaded) {
        handle.viewportLoaded = true;
        ItemViewport.loadItemIntoViewport(handle.itemViewport, handle.item.id);
    } else if (handle.itemImage) {
        handle.itemImage.Image = handle.item.image ?? "";
    }

    const levelRequirement = handle.item.levelReq;
    const currentLevel = empireLevel ?? 0;
    const isLocked = levelRequirement !== undefined && currentLevel < levelRequirement;
    handle.isLocked = isLocked;
    handle.button.AutoButtonColor = !isLocked;
    handle.button.Active = !isLocked;
    if (handle.itemImage) {
        handle.itemImage.ImageTransparency = isLocked ? 0.4 : 0;
    }
    if (handle.itemViewport) {
        handle.itemViewport.ImageTransparency = isLocked ? 0.4 : 0;
    }

    const price = handle.item.getPrice(ownedAmount + 1);
    const isMaxed = price === undefined;
    const optionsList = isMaxed ? new Array<CurrencyOption | ItemOption>() : buildPriceOptions(handle.item, price);

    const shouldHide = hideMaxedItems && isMaxed;
    const finalVisible = baseVisible && !shouldHide;

    if (levelRequirement !== undefined && isLocked) {
        handle.lockText.Text = `Lv. ${levelRequirement}`;
    } else {
        handle.lockText.Text = "";
    }

    const showLock = finalVisible && isLocked;
    handle.lockOverlay.Visible = showLock;
    handle.lockIcon.Visible = showLock;
    handle.lockText.Visible = showLock && handle.lockText.Text !== "";

    if (!finalVisible) {
        if (handle.root.Visible !== false) {
            handle.root.Visible = false;
        }
        handle.rotationToken++;
        handle.lockIcon.Visible = false;
        handle.lockText.Visible = false;
    } else {
        if (handle.root.Visible !== true) {
            handle.root.Visible = true;
        }
    }

    if (isMaxed || optionsList.size() === 0) {
        applyPriceOption(handle, { kind: "maxed" });
        handle.rotationToken++;
        return;
    }

    const firstOption = optionsList[0];
    applyPriceOption(handle, firstOption);

    if (optionsList.size() > 1 && finalVisible) {
        startPriceRotation(handle, optionsList);
    } else {
        handle.rotationToken++;
    }
}


import React from "@rbxts/react";
import { RobotoMonoBold } from "shared/asset/GameFonts";

export default function PurchaseOption({
    label,
    cost,
    isMaxed,
    onPurchase,
}: {
    label: string;
    cost: string;
    isMaxed: boolean;
    onPurchase: () => void;
}) {
    // Color scheme for button states
    const buttonColors = {
        normal: {
            primary: Color3.fromRGB(85, 170, 255),
            secondary: Color3.fromRGB(120, 200, 255),
            background: Color3.fromRGB(65, 140, 215),
        },
        maxed: {
            primary: Color3.fromRGB(150, 150, 150),
            secondary: Color3.fromRGB(180, 180, 180),
            background: Color3.fromRGB(120, 120, 120),
        },
    };

    const colors = isMaxed ? buttonColors.maxed : buttonColors.normal;

    return (
        <frame Active={true} BackgroundTransparency={1} Size={new UDim2(1, 0, 0.28, 0)}>
            {/* Button shadow */}
            <frame
                BackgroundColor3={Color3.fromRGB(15, 15, 25)}
                BorderSizePixel={0}
                Position={new UDim2(0, 3, 0, 3)}
                Size={new UDim2(0.35, 0, 1, 0)}
                ZIndex={0}
            >
                <uicorner CornerRadius={new UDim(0, 8)} />
            </frame>

            {/* Main button */}
            <textbutton
                BackgroundColor3={colors.background}
                BorderSizePixel={0}
                Size={new UDim2(0.35, 0, 1, 0)}
                Text=""
                TextColor3={Color3.fromRGB(0, 0, 0)}
                TextSize={14}
                Active={!isMaxed}
                ZIndex={1}
                Event={{
                    Activated: onPurchase,
                }}
            >
                <uicorner CornerRadius={new UDim(0, 8)} />

                {/* Button gradient */}
                <uigradient
                    Color={
                        new ColorSequence([
                            new ColorSequenceKeypoint(0, colors.primary),
                            new ColorSequenceKeypoint(1, colors.background),
                        ])
                    }
                    Rotation={90}
                />

                {/* Button border */}
                <uistroke
                    ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                    Color={Color3.fromRGB(255, 255, 255)}
                    Thickness={2}
                >
                    <uigradient
                        Color={
                            new ColorSequence([
                                new ColorSequenceKeypoint(0, Color3.fromRGB(255, 255, 255)),
                                new ColorSequenceKeypoint(0.5, colors.secondary),
                                new ColorSequenceKeypoint(1, Color3.fromRGB(255, 255, 255)),
                            ])
                        }
                        Rotation={35}
                    />
                </uistroke>

                {/* Button label */}
                <textlabel
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundTransparency={1}
                    FontFace={RobotoMonoBold}
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Size={new UDim2(0.9, 0, 0.8, 0)}
                    Text={label}
                    TextColor3={Color3.fromRGB(255, 255, 255)}
                    TextScaled={true}
                    TextWrapped={true}
                >
                    <uistroke Thickness={2} />
                </textlabel>

                {/* Hover effect overlay */}
                {!isMaxed && (
                    <frame
                        BackgroundColor3={Color3.fromRGB(255, 255, 255)}
                        BackgroundTransparency={0.95}
                        BorderSizePixel={0}
                        Size={new UDim2(1, 0, 1, 0)}
                        ZIndex={2}
                        Active={false}
                    >
                        <uicorner CornerRadius={new UDim(0, 8)} />
                    </frame>
                )}
            </textbutton>

            {/* Cost display panel */}
            <frame
                BackgroundColor3={Color3.fromRGB(35, 35, 45)}
                BorderSizePixel={0}
                Position={new UDim2(0.38, 0, 0, 0)}
                Size={new UDim2(0.62, 0, 1, 0)}
                ZIndex={1}
            >
                <uicorner CornerRadius={new UDim(0, 8)} />

                {/* Cost panel gradient */}
                <uigradient
                    Color={
                        new ColorSequence([
                            new ColorSequenceKeypoint(0, Color3.fromRGB(45, 45, 55)),
                            new ColorSequenceKeypoint(1, Color3.fromRGB(25, 25, 35)),
                        ])
                    }
                    Rotation={90}
                />

                {/* Cost panel border */}
                <uistroke
                    ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                    Color={Color3.fromRGB(85, 85, 95)}
                    Thickness={2}
                />

                {/* Cost text */}
                <textlabel
                    BackgroundTransparency={1}
                    FontFace={RobotoMonoBold}
                    Size={new UDim2(1, -10, 1, 0)}
                    Position={new UDim2(0, 10, 0, 0)}
                    Text={isMaxed ? "MAXED" : cost}
                    TextColor3={isMaxed ? Color3.fromRGB(255, 215, 0) : Color3.fromRGB(255, 255, 255)}
                    TextScaled={true}
                    TextSize={14}
                    TextWrapped={true}
                    TextXAlignment={Enum.TextXAlignment.Left}
                >
                    <uistroke Thickness={2}>
                        <uigradient
                            Color={
                                new ColorSequence([
                                    new ColorSequenceKeypoint(0, Color3.fromRGB(25, 25, 35)),
                                    new ColorSequenceKeypoint(1, Color3.fromRGB(85, 170, 255)),
                                ])
                            }
                            Rotation={90}
                        />
                    </uistroke>
                </textlabel>

                {/* Maxed indicator overlay */}
                {isMaxed && (
                    <frame
                        BackgroundColor3={Color3.fromRGB(255, 215, 0)}
                        BackgroundTransparency={0.85}
                        BorderSizePixel={0}
                        Size={new UDim2(1, 0, 1, 0)}
                        ZIndex={2}
                    >
                        <uicorner CornerRadius={new UDim(0, 8)} />
                        <uigradient
                            Color={
                                new ColorSequence([
                                    new ColorSequenceKeypoint(0, Color3.fromRGB(255, 215, 0)),
                                    new ColorSequenceKeypoint(1, Color3.fromRGB(255, 165, 0)),
                                ])
                            }
                            Rotation={45}
                        />
                    </frame>
                )}
            </frame>
        </frame>
    );
}


import React, { Fragment } from "@rbxts/react";
import PurchaseOption from "client/components/item/upgrade/PurchaseOption";
import { RobotoMono, RobotoMonoBold } from "shared/asset/GameFonts";
import { getAsset } from "shared/asset/AssetMap";
import NamedUpgrade from "shared/namedupgrade/NamedUpgrade";

export default function UpgradeActionsPanel({
    selectedUpgrade,
    upgradeAmount,
    costs,
    isMaxed,
    onBuy1,
    onBuyNext,
    onBuyMax,
}: {
    selectedUpgrade?: NamedUpgrade;
    upgradeAmount?: number;
    costs: {
        buy1: string;
        buyNext: string;
        buyMax: string;
    };
    isMaxed: boolean;
    onBuy1: () => void;
    onBuyNext: () => void;
    onBuyMax: () => void;
}) {
    const displayAmount = selectedUpgrade
        ? selectedUpgrade.cap !== undefined
            ? `${upgradeAmount}/${selectedUpgrade.cap}`
            : tostring(upgradeAmount)
        : undefined;

    return (
        <Fragment>
            {/* Background panel */}
            <frame
                BackgroundColor3={Color3.fromRGB(25, 25, 35)}
                BackgroundTransparency={0.1}
                BorderSizePixel={0}
                Size={new UDim2(1, 0, 1, 0)}
                ZIndex={-1}
            >
                <uicorner CornerRadius={new UDim(0, 12)} />
                <uigradient
                    Color={
                        new ColorSequence([
                            new ColorSequenceKeypoint(0, Color3.fromRGB(45, 45, 55)),
                            new ColorSequenceKeypoint(1, Color3.fromRGB(25, 25, 35)),
                        ])
                    }
                    Rotation={135}
                />
                <uistroke
                    ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                    Color={Color3.fromRGB(85, 170, 255)}
                    Thickness={2}
                >
                    <uigradient
                        Color={
                            new ColorSequence([
                                new ColorSequenceKeypoint(0, Color3.fromRGB(255, 255, 255)),
                                new ColorSequenceKeypoint(0.5, Color3.fromRGB(85, 170, 255)),
                                new ColorSequenceKeypoint(1, Color3.fromRGB(255, 255, 255)),
                            ])
                        }
                        Rotation={35}
                    />
                </uistroke>
            </frame>

            {/* Upgrade icon with enhanced styling */}
            <imagelabel
                BackgroundTransparency={1}
                Image={selectedUpgrade ? selectedUpgrade.image : getAsset("assets/NamedUpgrade.png")}
                Position={new UDim2(0.05, 0, 0.05, 0)}
                Size={new UDim2(0.25, 0, 0.4, 0)}
                ScaleType={Enum.ScaleType.Fit}
            />

            {/* Amount display with enhanced styling */}
            <textlabel
                AnchorPoint={new Vector2(0, 0.5)}
                BackgroundTransparency={1}
                FontFace={RobotoMonoBold}
                Position={new UDim2(0.05, 0, 0.4, 0)}
                Size={new UDim2(0.25, 0, 0.125, 0)}
                Text={displayAmount ?? ""}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                TextSize={14}
                TextWrapped={true}
            >
                <uistroke Thickness={2} />
            </textlabel>

            {/* Upgrade name with better styling */}
            <textlabel
                BackgroundTransparency={1}
                FontFace={RobotoMonoBold}
                Position={new UDim2(0.35, 0, 0.05, 0)}
                Size={new UDim2(0.6, 0, 0.15, 0)}
                Text={selectedUpgrade?.name ?? "<no upgrade selected>"}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                TextSize={14}
                TextWrapped={true}
                TextXAlignment={Enum.TextXAlignment.Left}
            >
                <uistroke Thickness={2}>
                    <uigradient
                        Color={
                            new ColorSequence([
                                new ColorSequenceKeypoint(0, Color3.fromRGB(85, 170, 255)),
                                new ColorSequenceKeypoint(1, Color3.fromRGB(25, 25, 35)),
                            ])
                        }
                        Rotation={90}
                    />
                </uistroke>
            </textlabel>

            {/* Description with better formatting */}
            <frame
                BackgroundColor3={Color3.fromRGB(35, 35, 45)}
                BackgroundTransparency={0.3}
                BorderSizePixel={0}
                Position={new UDim2(0.35, 0, 0.2, 0)}
                Size={new UDim2(0.6, 0, 0.25, 0)}
            >
                <uicorner CornerRadius={new UDim(0, 6)} />
                <uistroke Color={Color3.fromRGB(75, 75, 85)} Thickness={1} />

                <textlabel
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundTransparency={1}
                    FontFace={RobotoMono}
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Size={new UDim2(1, -10, 1, -10)}
                    Text={selectedUpgrade?.description ?? "Select an upgrade to get started."}
                    TextColor3={Color3.fromRGB(200, 200, 210)}
                    TextScaled={true}
                    TextWrapped={true}
                    TextXAlignment={Enum.TextXAlignment.Left}
                    TextYAlignment={Enum.TextYAlignment.Top}
                >
                    <uitextsizeconstraint MaxTextSize={25} />
                    <uistroke Thickness={1} />
                </textlabel>
            </frame>

            {/* Purchase options container with improved styling */}
            <frame
                BackgroundColor3={Color3.fromRGB(35, 35, 45)}
                BackgroundTransparency={0.2}
                BorderSizePixel={0}
                Position={new UDim2(0.05, 0, 0.5, 0)}
                Size={new UDim2(0.9, 0, 0.45, 0)}
            >
                <uicorner CornerRadius={new UDim(0, 8)} />
                <uistroke Color={Color3.fromRGB(75, 75, 85)} Thickness={2} />

                <PurchaseOption label="Buy x1" cost={costs.buy1} isMaxed={isMaxed} onPurchase={onBuy1} />
                <PurchaseOption label="Buy NEXT" cost={costs.buyNext} isMaxed={isMaxed} onPurchase={onBuyNext} />
                <PurchaseOption label="Buy MAX" cost={costs.buyMax} isMaxed={isMaxed} onPurchase={onBuyMax} />

                <uilistlayout
                    Padding={new UDim(0, 10)}
                    SortOrder={Enum.SortOrder.LayoutOrder}
                    HorizontalAlignment={Enum.HorizontalAlignment.Center}
                    VerticalAlignment={Enum.VerticalAlignment.Center}
                />
                <uipadding
                    PaddingBottom={new UDim(0, 15)}
                    PaddingLeft={new UDim(0, 15)}
                    PaddingRight={new UDim(0, 15)}
                    PaddingTop={new UDim(0, 15)}
                />
            </frame>

            <uipadding
                PaddingBottom={new UDim(0, 20)}
                PaddingLeft={new UDim(0, 20)}
                PaddingRight={new UDim(0, 20)}
                PaddingTop={new UDim(0, 20)}
            />
        </Fragment>
    );
}


import React, { Fragment, useEffect, useMemo, useState } from "@rbxts/react";
import UpgradeActionsPanel from "client/components/item/upgrade/UpgradeActionsPanel";
import UpgradeOptionsPanel from "client/components/item/upgrade/UpgradeOptionsPanel";

import { showErrorToast } from "client/components/toast/ToastService";
import { playSound } from "shared/asset/GameAssets";
import NamedUpgrade from "shared/namedupgrade/NamedUpgrade";
import Packets from "shared/Packets";

export default function UpgradeBoardGui({
    upgrades,
    model,
    actionsContainer,
    optionsContainer,
}: {
    upgrades: NamedUpgrade[];
    model: Model;
    actionsContainer: Instance;
    optionsContainer: Instance;
}) {
    const [selectedUpgradeId, setSelectedUpgradeId] = useState<string | undefined>(undefined);
    const [upgradeAmounts, setUpgradeAmounts] = useState<Map<string, number>>(new Map());

    // Find selected upgrade object
    const selectedUpgrade = useMemo(() => {
        return selectedUpgradeId ? upgrades.find((u) => u.id === selectedUpgradeId) : undefined;
    }, [selectedUpgradeId, upgrades]);

    // Get upgrade amount for selected upgrade
    const upgradeAmount = selectedUpgradeId ? (upgradeAmounts.get(selectedUpgradeId) ?? 0) : 0;

    // Check if upgrade is maxed
    const isMaxed = selectedUpgrade?.cap !== undefined && upgradeAmount >= selectedUpgrade.cap;

    // Calculate next amounts for different purchase options
    const getNext = (amount: number, step?: number) =>
        step === undefined ? amount + 1 : amount + step - (amount % step);

    // Calculate costs for purchase options
    const costs = useMemo(() => {
        if (!selectedUpgrade) {
            return {
                buy1: "Select an upgrade!",
                buyNext: "Select an upgrade!",
                buyMax: "Select an upgrade!",
            };
        }

        if (isMaxed) {
            return {
                buy1: "MAXED",
                buyNext: "MAXED",
                buyMax: "MAXED",
            };
        }

        const buy1Cost = selectedUpgrade.getPrice(upgradeAmount + 1)?.toString() ?? "Error";
        const nextAmount = getNext(upgradeAmount, selectedUpgrade.step);
        const buyNextCost = selectedUpgrade.getPrice(upgradeAmount + 1, nextAmount)?.toString() ?? "Error";
        const buyMaxCost = selectedUpgrade.getPrice(upgradeAmount + 1, selectedUpgrade.cap)?.toString() ?? "Error";

        return {
            buy1: buy1Cost,
            buyNext: `${buyNextCost} (to ${nextAmount})`,
            buyMax: buyMaxCost,
        };
    }, [selectedUpgrade, upgradeAmount, isMaxed]);

    // Sound feedback function
    const playPurchaseFeedback = (success: boolean) => {
        if (success) {
            playSound("UpgradeBought.mp3");
        } else {
            playSound("Error.mp3");
            showErrorToast("Upgrade purchase failed.");
        }
    };

    // Purchase handlers
    const handleBuy1 = () => {
        if (selectedUpgrade) {
            const success = Packets.buyUpgrade.toServer(selectedUpgrade.id, upgradeAmount + 1);
            playPurchaseFeedback(success);
        }
    };

    const handleBuyNext = () => {
        if (selectedUpgrade) {
            const nextAmount = getNext(upgradeAmount, selectedUpgrade.step);
            const success = Packets.buyUpgrade.toServer(selectedUpgrade.id, nextAmount);
            playPurchaseFeedback(success);
        }
    };

    const handleBuyMax = () => {
        if (selectedUpgrade?.cap !== undefined) {
            const success = Packets.buyUpgrade.toServer(selectedUpgrade.id, selectedUpgrade.cap);
            playPurchaseFeedback(success);
        }
    };

    // Observe upgrade amounts from packets
    useEffect(() => {
        const connection = Packets.upgrades.observe((value) => {
            setUpgradeAmounts(value);
        });

        return () => connection.disconnect();
    }, []);

    return (
        <Fragment>
            {/* Options Panel Surface GUI */}
            <surfacegui
                Adornee={optionsContainer as BasePart}
                ClipsDescendants={true}
                LightInfluence={0.5}
                MaxDistance={200}
                SizingMode={Enum.SurfaceGuiSizingMode.PixelsPerStud}
                ZIndexBehavior={Enum.ZIndexBehavior.Sibling}
                ResetOnSpawn={false}
                Face={Enum.NormalId.Front}
                PixelsPerStud={50}
            >
                <UpgradeOptionsPanel
                    upgrades={upgrades}
                    upgradeAmounts={upgradeAmounts}
                    selectedUpgradeId={selectedUpgradeId}
                    onSelectUpgrade={(upgradeId) => {
                        setSelectedUpgradeId(upgradeId);
                        playSound("MenuClick.mp3");
                    }}
                />
            </surfacegui>

            {/* Actions Panel Surface GUI */}
            <surfacegui
                Adornee={actionsContainer as BasePart}
                ClipsDescendants={false}
                LightInfluence={0.5}
                MaxDistance={200}
                SizingMode={Enum.SurfaceGuiSizingMode.PixelsPerStud}
                ZIndexBehavior={Enum.ZIndexBehavior.Sibling}
                ResetOnSpawn={false}
                Face={Enum.NormalId.Front}
                PixelsPerStud={50}
            >
                <UpgradeActionsPanel
                    selectedUpgrade={selectedUpgrade}
                    upgradeAmount={upgradeAmount}
                    costs={costs}
                    isMaxed={isMaxed}
                    onBuy1={handleBuy1}
                    onBuyNext={handleBuyNext}
                    onBuyMax={handleBuyMax}
                />
            </surfacegui>
        </Fragment>
    );
}


/**
 * @fileoverview UI Labs story for UpgradeBoard components.
 * Provides interactive controls for testing upgrade selection and purchase functionality.
 */

import React, { useEffect } from "@rbxts/react";
import ReactRoblox from "@rbxts/react-roblox";
import { Workspace } from "@rbxts/services";
import { CreateReactStory } from "@rbxts/ui-labs";
import UpgradeBoardRenderer from "client/components/item/upgrade/UpgradeBoardRenderer";
import Item from "shared/item/Item";
import Items from "shared/items/Items";
import NamedUpgrades from "shared/namedupgrade/NamedUpgrades";
import Packets from "shared/Packets";

export = CreateReactStory(
    {
        react: React,
        reactRoblox: ReactRoblox,
        controls: {
            visible: true,
        },
    },
    (props) => {
        useEffect(() => {
            const mockUpgradeAmounts = new Map<string, number>();
            for (const [id, upgrade] of NamedUpgrades.ALL_UPGRADES) {
                mockUpgradeAmounts.set(id, math.random(0, (upgrade.cap ?? 10) - 1));
            }

            Packets.upgrades.set(mockUpgradeAmounts);
        }, []);

        useEffect(() => {
            Packets.buyUpgrade.fromClient((_, upgradeId, to) => {
                if (!to) return false;
                print(`Bought ${upgradeId} up to ${to}`);
                const upgrades = Packets.upgrades.get();
                if (upgrades) {
                    upgrades.set(upgradeId, to);
                    Packets.upgrades.set(table.clone(upgrades));
                }
                return true;
            });
        }, []);

        useEffect(() => {
            const testModels = new Array<Instance>();

            const createTestModel = (item: Item) => {
                const testModel = item.createModel({
                    item: item.id,
                    posX: -testModels.size() * 50,
                    posY: 30,
                    posZ: 0,
                    rotX: 0,
                    rotY: 0,
                    rotZ: 0,
                });
                if (!testModel) return;
                testModel.AddTag("UpgradeBoard");
                testModel.Parent = Workspace;
                testModels.push(testModel);
            };
            for (const upgradeBoard of Items.sortedItems) {
                if (upgradeBoard.findTrait("UpgradeBoard")) {
                    createTestModel(upgradeBoard);
                }
            }

            return () => {
                testModels.forEach((model) => model.Destroy());
            };
        }, []);

        return <UpgradeBoardRenderer />;
    },
);


import React, { Fragment, JSX } from "@rbxts/react";
import UpgradeBoardGui from "client/components/item/upgrade/UpgradeBoardGui";
import useTaggedItemModels from "client/components/world/useTaggedItemModels";

export default function UpgradeBoardRenderer() {
    const models = useTaggedItemModels("UpgradeBoard");

    const upgradeBoardGuis = new Array<JSX.Element>();
    for (const [model, item] of models) {
        const upgradeBoard = item.findTrait("UpgradeBoard");
        if (!upgradeBoard) return;

        const optionsContainer = model.FindFirstChild("UpgradeOptionsPart");
        const actionsContainer = model.FindFirstChild("UpgradeActionsPart");
        if (!optionsContainer || !actionsContainer) return;
        optionsContainer.AddTag("Unhoverable");
        actionsContainer.AddTag("Unhoverable");

        upgradeBoardGuis.push(
            <UpgradeBoardGui
                upgrades={upgradeBoard.upgrades}
                model={model}
                optionsContainer={optionsContainer}
                actionsContainer={actionsContainer}
            />,
        );
    }

    return <Fragment>{upgradeBoardGuis}</Fragment>;
}


import React from "@rbxts/react";
import { RobotoMonoBold } from "shared/asset/GameFonts";
import NamedUpgrade from "shared/namedupgrade/NamedUpgrade";

export default function UpgradeOption({
    upgrade,
    amount,
    isMaxed,
    onSelect,
    isSelected,
}: {
    upgrade: NamedUpgrade;
    amount: number;
    isMaxed: boolean;
    onSelect: () => void;
    isSelected: boolean;
}) {
    // Selection colors
    const selectedColors = {
        primary: Color3.fromRGB(85, 170, 255),
        secondary: Color3.fromRGB(120, 200, 255),
        glow: Color3.fromRGB(170, 220, 255),
    };

    const normalColors = {
        primary: Color3.fromRGB(75, 75, 85),
        secondary: Color3.fromRGB(95, 95, 105),
        glow: Color3.fromRGB(155, 155, 165),
    };

    const colors = isSelected ? selectedColors : normalColors;

    return (
        <frame BackgroundTransparency={1} Size={new UDim2(0, 125, 0, 141)}>
            <frame BackgroundTransparency={1} Size={new UDim2(0, 125, 0, 125)}>
                {/* Outer glow effect */}
                <frame
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundColor3={colors.glow}
                    BackgroundTransparency={isSelected ? 0.3 : 0.8}
                    BorderSizePixel={0}
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Size={new UDim2(1.1, 0, 1.1, 0)}
                    ZIndex={-2}
                >
                    <uicorner CornerRadius={new UDim(0, 12)} />
                    <uigradient
                        Color={
                            new ColorSequence([
                                new ColorSequenceKeypoint(0, colors.glow),
                                new ColorSequenceKeypoint(1, Color3.fromRGB(25, 25, 35)),
                            ])
                        }
                        Rotation={45}
                    />
                </frame>

                {/* Shadow */}
                <frame
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundColor3={Color3.fromRGB(15, 15, 25)}
                    BorderSizePixel={0}
                    Position={new UDim2(0.5, 3, 0.5, 3)}
                    Size={new UDim2(0.95, 0, 0.95, 0)}
                    ZIndex={-1}
                >
                    <uicorner CornerRadius={new UDim(0, 8)} />
                </frame>

                {/* Main button */}
                <textbutton
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundColor3={Color3.fromRGB(255, 255, 255)}
                    BorderSizePixel={0}
                    Text=""
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Selectable={false}
                    Size={new UDim2(0.95, 0, 0.95, 0)}
                    ZIndex={1}
                    Event={{
                        Activated: onSelect,
                    }}
                >
                    <imagelabel
                        AnchorPoint={new Vector2(0.5, 0.5)}
                        BackgroundTransparency={1}
                        Image={upgrade.image}
                        Position={new UDim2(0.5, 0, 0.5, 0)}
                        Size={new UDim2(0.9, 0, 0.9, 0)}
                    />
                    <uicorner CornerRadius={new UDim(0, 8)} />

                    {/* Main gradient */}
                    <uigradient
                        Color={
                            new ColorSequence([
                                new ColorSequenceKeypoint(0, Color3.fromRGB(245, 245, 245)),
                                new ColorSequenceKeypoint(1, Color3.fromRGB(195, 195, 205)),
                            ])
                        }
                        Rotation={90}
                    />

                    {/* Border stroke */}
                    <uistroke
                        ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                        Color={colors.primary}
                        Thickness={isSelected ? 4 : 2}
                    >
                        <uigradient
                            Color={
                                new ColorSequence([
                                    new ColorSequenceKeypoint(0, colors.primary),
                                    new ColorSequenceKeypoint(0.5, Color3.fromRGB(255, 255, 255)),
                                    new ColorSequenceKeypoint(1, colors.secondary),
                                ])
                            }
                            Rotation={35}
                        />
                    </uistroke>

                    {/* Selection indicator overlay */}
                    {isSelected && (
                        <frame
                            BackgroundColor3={selectedColors.primary}
                            BackgroundTransparency={0.85}
                            BorderSizePixel={0}
                            Size={new UDim2(1, 0, 1, 0)}
                            ZIndex={2}
                        >
                            <uicorner CornerRadius={new UDim(0, 8)} />
                            <uigradient
                                Color={
                                    new ColorSequence([
                                        new ColorSequenceKeypoint(0, selectedColors.primary),
                                        new ColorSequenceKeypoint(1, selectedColors.secondary),
                                    ])
                                }
                                Rotation={45}
                            />
                        </frame>
                    )}
                </textbutton>
            </frame>

            {/* Amount/Status label */}
            <frame
                AnchorPoint={new Vector2(0.5, 0.5)}
                BackgroundColor3={isMaxed ? Color3.fromRGB(255, 215, 0) : Color3.fromRGB(85, 170, 255)}
                BorderSizePixel={0}
                Position={new UDim2(0.5, 0, 0, 125)}
                Size={new UDim2(0.8, 0, 0.25, 0)}
                ZIndex={3}
            >
                <uicorner CornerRadius={new UDim(0, 6)} />
                <uigradient
                    Color={
                        isMaxed
                            ? new ColorSequence([
                                  new ColorSequenceKeypoint(0, Color3.fromRGB(255, 215, 0)),
                                  new ColorSequenceKeypoint(1, Color3.fromRGB(255, 165, 0)),
                              ])
                            : new ColorSequence([
                                  new ColorSequenceKeypoint(0, Color3.fromRGB(85, 170, 255)),
                                  new ColorSequenceKeypoint(1, Color3.fromRGB(65, 140, 215)),
                              ])
                    }
                    Rotation={90}
                />
                <uistroke Color={Color3.fromRGB(255, 255, 255)} Thickness={2} />

                <textlabel
                    BackgroundTransparency={1}
                    FontFace={RobotoMonoBold}
                    Size={new UDim2(1, 0, 1, 0)}
                    Text={isMaxed ? "MAXED" : tostring(amount)}
                    TextColor3={Color3.fromRGB(255, 255, 255)}
                    TextScaled={true}
                    TextSize={14}
                    TextWrapped={true}
                    ZIndex={4}
                >
                    <uistroke Thickness={2} />
                </textlabel>
            </frame>
        </frame>
    );
}


import React from "@rbxts/react";
import UpgradeOption from "client/components/item/upgrade/UpgradeOption";
import NamedUpgrade from "shared/namedupgrade/NamedUpgrade";

export default function UpgradeOptionsPanel({
    upgrades,
    upgradeAmounts,
    selectedUpgradeId,
    onSelectUpgrade,
}: {
    upgrades: Array<NamedUpgrade>;
    upgradeAmounts: Map<string, number>;
    selectedUpgradeId?: string;
    onSelectUpgrade: (upgradeId: string) => void;
}) {
    return (
        <frame Size={new UDim2(1, 0, 1, 0)} BackgroundTransparency={1}>
            <uipadding
                PaddingBottom={new UDim(0, 30)}
                PaddingLeft={new UDim(0, 30)}
                PaddingRight={new UDim(0, 30)}
                PaddingTop={new UDim(0, 30)}
            />
            <uilistlayout
                FillDirection={Enum.FillDirection.Horizontal}
                HorizontalAlignment={Enum.HorizontalAlignment.Center}
                Padding={new UDim(0, 5)}
                SortOrder={Enum.SortOrder.LayoutOrder}
                VerticalAlignment={Enum.VerticalAlignment.Center}
            />

            {upgrades.map((upgrade) => {
                const amount = upgradeAmounts.get(upgrade.id) ?? 0;
                const isMaxed = upgrade.cap !== undefined && amount >= upgrade.cap;
                const isSelected = selectedUpgradeId === upgrade.id;

                return (
                    <UpgradeOption
                        key={upgrade.id}
                        upgrade={upgrade}
                        amount={amount}
                        isMaxed={isMaxed}
                        isSelected={isSelected}
                        onSelect={() => onSelectUpgrade(upgrade.id)}
                    />
                );
            })}
        </frame>
    );
}


/**
 * @fileoverview Manager component for level-up notifications.
 *
 * This component handles the triggering and state management of level-up notifications.
 * It listens to level changes and displays the animated notification when appropriate.
 */

import React, { useEffect, useState } from "@rbxts/react";
import LevelUpNotification, { LevelUpData } from "client/components/levelup/LevelUpNotification";
import Packets from "shared/Packets";

export default function LevelUpManager() {
    const [levelUpData, setLevelUpData] = useState<LevelUpData>({
        level: 0,
        visible: false,
    });
    const [previousLevel, setPreviousLevel] = useState<number | undefined>(undefined);

    // Listen to level changes from the server
    useEffect(() => {
        const connection = Packets.level.observe((newLevel) => {
            if (previousLevel !== undefined && newLevel > previousLevel && newLevel !== 1) {
                // Player leveled up! Show notification
                setLevelUpData({
                    level: newLevel,
                    visible: true,
                });
            }
            setPreviousLevel(newLevel);
        });

        return () => connection.disconnect();
    }, [previousLevel]);

    // Handle notification completion
    const handleNotificationComplete = () => {
        setLevelUpData((prev) => ({
            ...prev,
            visible: false,
        }));
    };

    return <LevelUpNotification data={levelUpData} onComplete={handleNotificationComplete} />;
}


/**
 * @fileoverview Storybook for testing the Level Up notification component.
 */

import React, { StrictMode, useState } from "@rbxts/react";
import ReactRoblox from "@rbxts/react-roblox";
import { CreateReactStory } from "@rbxts/ui-labs";
import LevelUpNotification, { LevelUpData } from "client/components/levelup/LevelUpNotification";

export = CreateReactStory(
    {
        react: React,
        reactRoblox: ReactRoblox,
        controls: {
            level: 5,
        },
    },
    (props) => {
        const [levelUpData, setLevelUpData] = useState<LevelUpData>({
            level: props.controls.level,
            visible: false,
        });

        const triggerLevelUp = () => {
            setLevelUpData({
                level: props.controls.level,
                visible: true,
            });
        };

        const handleComplete = () => {
            setLevelUpData((prev) => ({
                ...prev,
                visible: false,
            }));
        };

        return (
            <StrictMode>
                <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 1, 0)}>
                    <LevelUpNotification data={levelUpData} onComplete={handleComplete} />
                    <textbutton
                        BackgroundColor3={Color3.fromRGB(100, 200, 100)}
                        Position={new UDim2(0, 50, 0, 50)}
                        Size={new UDim2(0, 200, 0, 50)}
                        Text="Trigger Level Up!"
                        TextColor3={Color3.fromRGB(255, 255, 255)}
                        TextSize={18}
                        Event={{
                            Activated: triggerLevelUp,
                        }}
                    />
                    <textlabel
                        BackgroundTransparency={1}
                        Position={new UDim2(0, 50, 0, 120)}
                        Size={new UDim2(0, 300, 0, 30)}
                        Text={`Level: ${props.controls.level}`}
                        TextColor3={Color3.fromRGB(255, 255, 255)}
                        TextSize={16}
                    />
                </frame>
            </StrictMode>
        );
    },
);


import React, { useCallback, useEffect, useState } from "@rbxts/react";
import LogEntry from "client/components/logs/LogEntry";
import PaginationControls from "client/components/logs/PaginationControls";
import useSingleDocument from "client/components/sidebar/useSingleDocumentWindow";
import TechWindow from "client/components/window/TechWindow";
import { RobotoMonoBold } from "shared/asset/GameFonts";
import { getAsset } from "shared/asset/AssetMap";
import Packets from "shared/Packets";

const LOGS_PER_PAGE = 20;

/**
 * Main logs window component displaying game logs with pagination
 * Features enhanced visual design with modern styling and smooth interactions
 */
export default function LogsWindow() {
    const { id, visible } = useSingleDocument({ id: "Logs" });

    // State for logs and pagination
    const [logs, setLogs] = useState<Log[]>([]);
    const [pendingLogs, setPendingLogs] = useState<Log[]>([]); // Logs received while window is closed
    const [currentPage, setCurrentPage] = useState(1);

    // Load initial logs and listen for new ones
    useEffect(() => {
        // Load initial logs
        const initialLogs = Packets.getLogs.toServer();
        setLogs([...initialLogs]);

        // Listen for new logs
        const connection = Packets.logsAdded.fromServer((newLogs) => {
            if (visible) {
                setLogs((prevLogs) => [...prevLogs, ...newLogs]);
            } else {
                setPendingLogs((prevPending) => [...prevPending, ...newLogs]);
            }
        });

        return () => connection.Disconnect();
    }, []);

    // When window becomes visible, add any pending logs
    useEffect(() => {
        if (visible && pendingLogs.size() > 0) {
            setLogs((prevLogs) => [...prevLogs, ...pendingLogs]);
            setPendingLogs([]);
        }
    }, [visible, pendingLogs]);

    // Calculate pagination
    const totalPages = math.ceil(logs.size() / LOGS_PER_PAGE);
    const startIndex = logs.size() - currentPage * LOGS_PER_PAGE;
    const endIndex = startIndex + LOGS_PER_PAGE;

    // Get current page logs (roblox-ts doesn't have array.slice, so we use a for loop)
    const currentLogs: Log[] = [];
    const actualStartIndex = math.max(0, startIndex);
    for (let i = actualStartIndex; i < math.min(endIndex, logs.size()); i++) {
        currentLogs.unshift(logs[i]); // unshift to reverse the order
    }

    // Pagination handlers
    const handlePreviousPage = useCallback(() => {
        setCurrentPage((prev) => math.max(1, prev - 1));
    }, []);

    const handleNextPage = useCallback(() => {
        setCurrentPage((prev) => math.min(totalPages, prev + 1));
    }, [totalPages]);

    return (
        <TechWindow visible={visible} icon={getAsset("assets/Settings.png")} id={id}>
            <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 1, 0)}>
                {/* Header with info text */}
                <textlabel
                    AnchorPoint={new Vector2(0.5, 0)}
                    BackgroundTransparency={1}
                    FontFace={RobotoMonoBold}
                    Position={new UDim2(0.5, 0, 0, 5)}
                    Size={new UDim2(0.8, 0, 0, 16)}
                    Text="Logs clear after 7 days"
                    TextColor3={Color3.fromRGB(129, 129, 129)}
                    TextScaled={true}
                    TextSize={14}
                    TextWrapped={true}
                >
                    <uistroke Color={Color3.fromRGB(0, 0, 0)} Thickness={2} />
                </textlabel>

                {/* Scrollable log list */}
                <scrollingframe
                    AnchorPoint={new Vector2(0.5, 0)}
                    AutomaticCanvasSize={Enum.AutomaticSize.Y}
                    BackgroundTransparency={1}
                    CanvasSize={new UDim2(0, 0, 0, 0)}
                    Position={new UDim2(0.5, 0, 0, 30)}
                    ScrollBarThickness={6}
                    Selectable={false}
                    Size={new UDim2(1, -20, 1, -75)}
                    ScrollBarImageColor3={Color3.fromRGB(255, 255, 255)}
                >
                    <uilistlayout
                        HorizontalAlignment={Enum.HorizontalAlignment.Center}
                        Padding={new UDim(0, 8)}
                        SortOrder={Enum.SortOrder.LayoutOrder}
                    />
                    <uipadding
                        PaddingBottom={new UDim(0, 10)}
                        PaddingLeft={new UDim(0, 15)}
                        PaddingRight={new UDim(0, 15)}
                        PaddingTop={new UDim(0, 10)}
                    />

                    {/* Render log entries */}
                    {currentLogs.map((log: Log, index: number) => (
                        <LogEntry key={actualStartIndex + index} log={log} layoutOrder={index} />
                    ))}
                </scrollingframe>

                {/* Pagination controls */}
                <PaginationControls
                    currentPage={currentPage}
                    totalPages={totalPages}
                    onPreviousPage={handlePreviousPage}
                    onNextPage={handleNextPage}
                />
            </frame>
        </TechWindow>
    );
}


import React from "@rbxts/react";
import { RobotoMonoBold } from "shared/asset/GameFonts";
import { getAsset } from "shared/asset/AssetMap";

interface PaginationControlsProps {
    currentPage: number;
    totalPages: number;
    onPreviousPage: () => void;
    onNextPage: () => void;
}

/**
 * Pagination controls component for log navigation
 */
export default function PaginationControls({
    currentPage,
    totalPages,
    onPreviousPage,
    onNextPage,
}: PaginationControlsProps) {
    const canGoBack = currentPage > 1;
    const canGoForward = currentPage < totalPages;

    return (
        <frame
            AnchorPoint={new Vector2(0.5, 1)}
            AutomaticSize={Enum.AutomaticSize.X}
            BackgroundTransparency={1}
            Position={new UDim2(0.5, 0, 1, -5)}
            Size={new UDim2(0, 0, 0, 20)}
        >
            <uilistlayout
                FillDirection={Enum.FillDirection.Horizontal}
                HorizontalAlignment={Enum.HorizontalAlignment.Center}
                Padding={new UDim(0, 25)}
                SortOrder={Enum.SortOrder.LayoutOrder}
                VerticalAlignment={Enum.VerticalAlignment.Center}
            />

            {/* Previous page button */}
            <frame
                BackgroundTransparency={1}
                LayoutOrder={0}
                Selectable={canGoBack}
                Size={new UDim2(0, 30, 1, 0)}
                SizeConstraint={Enum.SizeConstraint.RelativeYY}
            >
                <imagebutton
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundTransparency={1}
                    Image={getAsset("assets/Dropdown.png")}
                    ImageTransparency={canGoBack ? 0 : 0.5}
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Rotation={90}
                    ScaleType={Enum.ScaleType.Fit}
                    Selectable={false}
                    Size={new UDim2(1, 0, 1, 0)}
                    Event={{
                        Activated: canGoBack ? onPreviousPage : undefined,
                    }}
                />
            </frame>

            {/* Page label */}
            <textlabel
                AutomaticSize={Enum.AutomaticSize.X}
                BackgroundTransparency={1}
                FontFace={RobotoMonoBold}
                LayoutOrder={1}
                Size={new UDim2(0, 0, 1, 0)}
                Text={`Page ${currentPage}${totalPages > 0 ? ` of ${totalPages}` : ""}`}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                TextSize={16}
                TextWrapped={true}
            >
                <uistroke Color={Color3.fromRGB(0, 0, 0)} Thickness={2} />
            </textlabel>

            {/* Next page button */}
            <frame
                BackgroundTransparency={1}
                LayoutOrder={2}
                Selectable={canGoForward}
                Size={new UDim2(0, 30, 1, 0)}
                SizeConstraint={Enum.SizeConstraint.RelativeYY}
            >
                <imagebutton
                    AnchorPoint={new Vector2(0.5, 0.5)}
                    BackgroundTransparency={1}
                    Image={getAsset("assets/Dropdown.png")}
                    ImageTransparency={canGoForward ? 0 : 0.5}
                    Position={new UDim2(0.5, 0, 0.5, 0)}
                    Rotation={270}
                    ScaleType={Enum.ScaleType.Fit}
                    Selectable={false}
                    Size={new UDim2(1, 0, 1, 0)}
                    Event={{
                        Activated: canGoForward ? onNextPage : undefined,
                    }}
                />
            </frame>
        </frame>
    );
}


import React, { JSX, useEffect, useMemo, useRef, useState } from "@rbxts/react";
import {
    createInventorySlot,
    updateInventorySlot,
    type InventorySlotHandle,
} from "client/components/item/inventory/InventorySlot";
import { ActionButton } from "client/components/marketplace/ListingCard";
import { showErrorToast } from "client/components/toast/ToastService";
import useProperty from "client/hooks/useProperty";
import { getAsset } from "shared/asset/AssetMap";
import { playSound } from "shared/asset/GameAssets";
import { RobotoMono, RobotoMonoBold } from "shared/asset/GameFonts";
import Items from "shared/items/Items";
import Packets from "shared/Packets";

export default function CreateListingForm({ onSubmit }: { onSubmit: (uuid: string, price: number) => boolean }) {
    const [selectedUuid, setSelectedUuid] = useState("");
    const [price, setPrice] = useState("");
    const availableInstances = useProperty(Packets.uniqueInstances);
    const slotHandlesRef = useRef(new Map<string, InventorySlotHandle>());

    useEffect(() => {
        // Find and destroy handles for items that are no longer available
        const toRemove = new Array<string>();
        for (const [uuid, handle] of slotHandlesRef.current) {
            if (!availableInstances.has(uuid)) {
                handle.destroy();
                toRemove.push(uuid);
            }
        }
    }, [availableInstances]);

    const handleSubmit = () => {
        if (selectedUuid === "") {
            showErrorToast("Please select an item to list.");
            playSound("Error.mp3");
            return;
        }

        if (price === "") {
            showErrorToast("Please enter a price.");
            playSound("Error.mp3");
            return;
        }

        const priceNum = tonumber(price);
        if (!priceNum || priceNum <= 0) {
            showErrorToast("Please enter a valid price.");
            playSound("Error.mp3");
            return;
        }

        const success = onSubmit(selectedUuid, priceNum);
        if (success) {
            playSound("Success.mp3");
        } else {
            playSound("Error.mp3");
        }

        // Reset form
        setSelectedUuid("");
        setPrice("");
    };

    // Memoize sorted instances by total pot value (descending)
    const sortedInstances = useMemo(() => {
        const arr = new Array<[string, UniqueItemInstance]>();
        for (const [uuid, uniqueInstance] of availableInstances) {
            arr.push([uuid, uniqueInstance]);
        }
        arr.sort((a, b) => {
            let aPotTotal = 0;
            for (const [, val] of a[1].pots) {
                aPotTotal += val;
            }
            let bPotTotal = 0;
            for (const [, val] of b[1].pots) {
                bPotTotal += val;
            }
            return bPotTotal < aPotTotal;
        });
        return arr;
    }, [availableInstances]);

    const availableInstanceElements = new Array<JSX.Element>();
    for (const [uuid, uniqueInstance] of sortedInstances) {
        const item = Items.getItem(uniqueInstance.baseItemId);
        if (!item) continue;

        const isSelected = selectedUuid === uuid;

        availableInstanceElements.push(
            <frame key={uuid} BackgroundTransparency={1} Size={new UDim2(0, 68, 0, 68)}>
                <frame
                    BackgroundTransparency={1}
                    Size={new UDim2(1, 0, 1, 0)}
                    ref={(rbx: Frame | undefined) => {
                        if (!rbx || slotHandlesRef.current.has(uuid)) return;

                        const handle = createInventorySlot(item, {
                            parent: rbx,
                            size: new UDim2(1, 0, 1, 0),
                            visible: true,
                            tooltip: true,
                            onActivated: () => {
                                playSound("Click.mp3");
                                setSelectedUuid(uuid);
                            },
                        });
                        // Hide the amount label for marketplace slots
                        handle.amountLabel.Visible = false;
                        updateInventorySlot(handle, {
                            amount: 1,
                            uuid: uuid,
                        });

                        slotHandlesRef.current.set(uuid, handle);
                    }}
                />
                {isSelected && (
                    <frame
                        key={`selection-indicator-${uuid}`}
                        BackgroundTransparency={1}
                        Size={new UDim2(1, 0, 1, 0)}
                        ZIndex={10}
                    >
                        <uistroke Thickness={2} Color={Color3.fromRGB(55, 189, 255)} Transparency={0} />
                    </frame>
                )}
            </frame>,
        );
    }

    return (
        <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 1, 0)}>
            <uistroke Thickness={1} Color={Color3.fromRGB(58, 86, 142)} Transparency={0} />
            <uilistlayout
                FillDirection={Enum.FillDirection.Vertical}
                HorizontalAlignment={Enum.HorizontalAlignment.Center}
                VerticalAlignment={Enum.VerticalAlignment.Top}
                VerticalFlex={Enum.UIFlexAlignment.Fill}
                Padding={new UDim(0, 6)}
            />

            {/* Helper to render card wrapper */}
            <uigradient
                Rotation={0}
                Color={
                    new ColorSequence([
                        new ColorSequenceKeypoint(0, Color3.fromRGB(20, 26, 39)),
                        new ColorSequenceKeypoint(1, Color3.fromRGB(12, 15, 24)),
                    ])
                }
            />

            <uipadding
                PaddingTop={new UDim(0, 16)}
                PaddingBottom={new UDim(0, 16)}
                PaddingLeft={new UDim(0, 18)}
                PaddingRight={new UDim(0, 18)}
            />

            <textlabel
                BackgroundTransparency={1}
                FontFace={RobotoMonoBold}
                Size={new UDim2(1, 0, 0, 26)}
                Text="Select Item to List"
                TextColor3={Color3.fromRGB(204, 222, 255)}
                TextScaled={true}
                TextXAlignment={Enum.TextXAlignment.Left}
            >
                <uiflexitem FlexMode={Enum.UIFlexMode.None} />
            </textlabel>

            <scrollingframe
                BackgroundColor3={Color3.fromRGB(24, 32, 48)}
                BorderSizePixel={0}
                AutomaticCanvasSize={Enum.AutomaticSize.Y}
                CanvasSize={new UDim2(0, 0, 0, 0)}
                ScrollBarThickness={6}
                ScrollBarImageColor3={Color3.fromRGB(74, 140, 255)}
                ScrollingDirection={Enum.ScrollingDirection.Y}
                Size={new UDim2(1, 0.5, 0, 0)}
            >
                <uistroke Thickness={1} Color={Color3.fromRGB(58, 86, 142)} Transparency={0.35} />
                <uipadding
                    PaddingTop={new UDim(0, 11)}
                    PaddingBottom={new UDim(0, 11)}
                    PaddingLeft={new UDim(0, 11)}
                    PaddingRight={new UDim(0, 11)}
                />

                {availableInstances.isEmpty() ? (
                    <textlabel
                        BackgroundTransparency={1}
                        FontFace={RobotoMono}
                        Size={new UDim2(1, 0, 1, 0)}
                        Text="No eligible items"
                        TextColor3={Color3.fromRGB(142, 168, 189)}
                        TextScaled={true}
                        TextXAlignment={Enum.TextXAlignment.Center}
                    />
                ) : (
                    <uigridlayout
                        FillDirection={Enum.FillDirection.Horizontal}
                        HorizontalAlignment={Enum.HorizontalAlignment.Left}
                        CellSize={new UDim2(0, 60, 0, 60)}
                        CellPadding={new UDim2(0, 16, 0, 16)}
                        SortOrder={Enum.SortOrder.LayoutOrder}
                    />
                )}

                {availableInstanceElements}
            </scrollingframe>

            {/* Price Input */}
            <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 0, 40)}>
                <uipadding PaddingTop={new UDim(0, 14)} />
                <uiflexitem FlexMode={Enum.UIFlexMode.None} />
                <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} />
                <textlabel
                    AutomaticSize={Enum.AutomaticSize.X}
                    BackgroundTransparency={1}
                    FontFace={RobotoMonoBold}
                    Size={new UDim2(0, 0, 1, 0)}
                    Text="Your Price"
                    TextColor3={Color3.fromRGB(204, 222, 255)}
                    TextScaled={true}
                    TextXAlignment={Enum.TextXAlignment.Left}
                />
                <imagelabel
                    BackgroundTransparency={1}
                    Size={new UDim2(1, 0, 1, 0)}
                    SizeConstraint={Enum.SizeConstraint.RelativeYY}
                    Image={getAsset("assets/Diamond.png")}
                />
            </frame>

            <textbox
                BackgroundColor3={Color3.fromRGB(24, 32, 48)}
                BorderColor3={Color3.fromRGB(100, 100, 100)}
                BorderSizePixel={1}
                Size={new UDim2(1, 0, 0, 26)}
                PlaceholderText="Enter price in Diamonds"
                PlaceholderColor3={Color3.fromRGB(120, 140, 175)}
                TextColor3={Color3.fromRGB(226, 238, 255)}
                Text={price}
                TextScaled={true}
                TextXAlignment={Enum.TextXAlignment.Left}
                FontFace={RobotoMono}
                ClearTextOnFocus={false}
                Event={{
                    FocusLost: (textBox) => setPrice(textBox.Text),
                }}
            >
                <uiflexitem FlexMode={Enum.UIFlexMode.None} />
                <uipadding
                    PaddingTop={new UDim(0, 4)}
                    PaddingBottom={new UDim(0, 4)}
                    PaddingLeft={new UDim(0, 8)}
                    PaddingRight={new UDim(0, 8)}
                />
            </textbox>

            {/* Submit Button */}
            <frame BackgroundTransparency={1} Size={new UDim2(0.7, 0, 0, 64)}>
                <uipadding PaddingTop={new UDim(0, 14)} />
                <uiflexitem FlexMode={Enum.UIFlexMode.None} />
                <ActionButton
                    text="Create Listing"
                    backgroundColor={Color3.fromRGB(55, 189, 255)}
                    onClick={handleSubmit}
                />
            </frame>
        </frame>
    );
}


import React, { useState } from "@rbxts/react";
import { RobotoSlab } from "shared/asset/GameFonts";
import CurrencyBundle from "shared/currency/CurrencyBundle";

interface CurrencyInputProps {
    label: string;
    placeholder?: string;
    onAmountChange: (amount: CurrencyBundle) => void;
    minValue?: number;
    maxValue?: number;
    size?: UDim2;
}

/**
 * Component for inputting currency amounts with validation and formatting.
 */
export default function CurrencyInput({
    label,
    placeholder = "Enter amount...",
    onAmountChange,
    minValue = 1,
    maxValue = 1e12,
    size = new UDim2(1, 0, 0, 80),
}: CurrencyInputProps) {
    const [inputText, setInputText] = useState("");
    const [isValid, setIsValid] = useState(true);
    const [errorMessage, setErrorMessage] = useState("");

    const validateInput = (text: string): boolean => {
        if (text === "") {
            setErrorMessage("");
            setIsValid(true);
            return false;
        }

        const amount = tonumber(text);
        if (!amount) {
            setErrorMessage("Please enter a valid number");
            setIsValid(false);
            return false;
        }

        if (amount < minValue) {
            setErrorMessage(`Minimum amount is ${minValue}`);
            setIsValid(false);
            return false;
        }

        if (amount > maxValue) {
            setErrorMessage(`Maximum amount is ${maxValue}`);
            setIsValid(false);
            return false;
        }

        setErrorMessage("");
        setIsValid(true);
        return true;
    };

    const handleTextChange = (text: string) => {
        setInputText(text);

        if (validateInput(text)) {
            const amount = tonumber(text)!;
            const currencyBundle = new CurrencyBundle();
            // Note: You'd need to implement the proper way to set currency in CurrencyBundle
            // This is a simplified example
            onAmountChange(currencyBundle);
        }
    };

    const formatNumber = (input: string): string => {
        // Remove any non-numeric characters except decimal points
        const cleaned = input.gsub("[^%d%.]", "")[0];
        const parts = string.split(cleaned, ".");

        if (parts.size() > 2) {
            // Only allow one decimal point
            return parts[0] + "." + parts[1];
        }

        return cleaned;
    };

    return (
        <frame BackgroundColor3={Color3.fromRGB(50, 50, 50)} BorderSizePixel={0} Size={size}>
            <uicorner CornerRadius={new UDim(0, 8)} />
            <uistroke Thickness={1} Color={isValid ? Color3.fromRGB(100, 100, 100) : Color3.fromRGB(200, 100, 100)} />

            {/* Label */}
            <textlabel
                BackgroundTransparency={1}
                FontFace={RobotoSlab}
                Size={new UDim2(1, 0, 0, 20)}
                Position={new UDim2(0, 10, 0, 5)}
                Text={label}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                TextXAlignment={Enum.TextXAlignment.Left}
            >
                <uistroke Thickness={1} />
            </textlabel>

            {/* Input Field */}
            <textbox
                BackgroundColor3={Color3.fromRGB(60, 60, 60)}
                BorderSizePixel={0}
                Size={new UDim2(1, -20, 0, 35)}
                Position={new UDim2(0, 10, 0, 25)}
                Text={inputText}
                PlaceholderText={placeholder}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                FontFace={RobotoSlab}
                ClearTextOnFocus={false}
                Event={{
                    FocusLost: (textBox) => {
                        const formatted = formatNumber(textBox.Text);
                        textBox.Text = formatted;
                        handleTextChange(formatted);
                    },
                }}
            >
                <uicorner CornerRadius={new UDim(0, 4)} />
                <uistroke
                    Thickness={1}
                    Color={isValid ? Color3.fromRGB(100, 100, 100) : Color3.fromRGB(200, 100, 100)}
                />
            </textbox>

            {/* Error Message */}
            {!isValid && errorMessage !== "" && (
                <textlabel
                    BackgroundTransparency={1}
                    FontFace={RobotoSlab}
                    Size={new UDim2(1, -20, 0, 15)}
                    Position={new UDim2(0, 10, 0, 65)}
                    Text={errorMessage}
                    TextColor3={Color3.fromRGB(255, 100, 100)}
                    TextScaled={true}
                    TextXAlignment={Enum.TextXAlignment.Left}
                />
            )}
        </frame>
    );
}


import React, { memo, useMemo } from "@rbxts/react";
import { TweenService } from "@rbxts/services";
import getDifficultyDisplayColors from "client/components/tooltip/getDifficultyDisplayColors";
import { TooltipManager } from "client/components/tooltip/TooltipWindow";
import useHover from "client/hooks/useHover";
import { getAsset } from "shared/asset/AssetMap";
import { RobotoMono, RobotoMonoBold } from "shared/asset/GameFonts";
import ItemViewport from "shared/item/ItemViewport";
import Items from "shared/items/Items";
import MARKETPLACE_CONFIG from "shared/marketplace/MarketplaceListing";

const UNIQUE_MIN_POT_COLOR = Color3.fromRGB(255, 0, 0);
const UNIQUE_MAX_POT_COLOR = Color3.fromRGB(0, 255, 255);

interface ListingPotDisplay {
    key: string;
    label: string;
    value: string;
    color: Color3;
}

interface UniqueListingDetails {
    itemName: string;
    potEntries: ListingPotDisplay[];
}

function colorToHex(color: Color3): string {
    const r = math.clamp(math.floor(color.R * 255 + 0.5), 0, 255);
    const g = math.clamp(math.floor(color.G * 255 + 0.5), 0, 255);
    const b = math.clamp(math.floor(color.B * 255 + 0.5), 0, 255);
    return string.format("%02X%02X%02X", r, g, b);
}

function formatPotLabel(potName: string): string {
    let [label] = potName.gsub("_", " ");
    [label] = label.gsub("([A-Z])", " %1");
    [label] = label.gsub("%s+", " ");
    [label] = label.gsub("^%s+", "");
    [label] = label.gsub("%s+$", "");

    if (label === "") {
        return potName;
    }

    const words = label.split(" ");
    const formatted = new Array<string>();
    for (const word of words) {
        if (word === "") continue;
        const lower = word.lower();
        const first = lower.sub(1, 1);
        const rest = lower.sub(2);
        if (first === "") {
            formatted.push(rest);
        } else {
            formatted.push(`${first.upper()}${rest}`);
        }
    }
    return formatted.join(" ");
}

function formatPotValue(value: number, integer?: boolean): string {
    if (integer) {
        return tostring(math.floor(value + 0.5));
    }

    const magnitude = math.abs(value);
    if (magnitude >= 1000) {
        return string.format("%.0f", value);
    }
    if (magnitude >= 10) {
        return string.format("%.1f", value);
    }
    return string.format("%.2f", value);
}

export function ActionButton({
    text,
    price,
    backgroundColor,
    layoutOrder,
    onClick,
}: {
    text: string;
    price?: number;
    backgroundColor: Color3;
    layoutOrder?: number;
    onClick?: () => void;
}) {
    const { hovering, events } = useHover({});
    return (
        <textbutton
            AutoButtonColor={false}
            BackgroundColor3={hovering ? backgroundColor.Lerp(Color3.fromRGB(255, 255, 255), 0.15) : backgroundColor}
            BorderSizePixel={0}
            LayoutOrder={layoutOrder}
            Size={new UDim2(1, 0, 1, 0)}
            Text={""}
            TextColor3={Color3.fromRGB(0, 0, 0)}
            TextSize={14}
            Event={{
                Activated: (rbx) => {
                    const tweenInfo = new TweenInfo(0.15, Enum.EasingStyle.Back, Enum.EasingDirection.Out, 0, false, 0);

                    // Scale down first
                    const scaleDown = TweenService.Create(rbx, new TweenInfo(0.1), {
                        Size: new UDim2(1, -5, 1, -5),
                    });
                    scaleDown.Play();
                    scaleDown.Completed.Once(() => {
                        // Then scale back up
                        const scaleUp = TweenService.Create(rbx, tweenInfo, {
                            Size: new UDim2(1, 0, 1, 0),
                        });
                        scaleUp.Play();
                    });
                    onClick?.();
                },
                ...events,
            }}
        >
            <uistroke
                ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                Thickness={2}
                Color={hovering ? Color3.fromRGB(255, 255, 255) : Color3.fromRGB(0, 0, 0)}
                Transparency={0}
            />
            <uilistlayout
                FillDirection={Enum.FillDirection.Vertical}
                HorizontalAlignment={Enum.HorizontalAlignment.Center}
                VerticalAlignment={Enum.VerticalAlignment.Center}
                SortOrder={Enum.SortOrder.LayoutOrder}
            />
            <textlabel
                BackgroundTransparency={1}
                FontFace={RobotoMonoBold}
                Size={new UDim2(0.9, 0, 0.4, 0)}
                Text={text}
                TextColor3={Color3.fromRGB(255, 255, 255)}
                TextScaled={true}
                TextSize={14}
                TextWrapped={true}
            >
                <uistroke Thickness={1.5} />
            </textlabel>
            {price !== undefined && (
                <frame BackgroundTransparency={1} Size={new UDim2(0.9, 0, 0.3, 0)}>
                    <uilistlayout
                        FillDirection={Enum.FillDirection.Horizontal}
                        HorizontalAlignment={Enum.HorizontalAlignment.Center}
                        VerticalAlignment={Enum.VerticalAlignment.Center}
                        SortOrder={Enum.SortOrder.LayoutOrder}
                        Padding={new UDim(0, 2)}
                    />
                    <imagelabel
                        BackgroundTransparency={1}
                        Image={getAsset("assets/Diamond.png")}
                        Size={new UDim2(1, 0, 1, 0)}
                        SizeConstraint={Enum.SizeConstraint.RelativeYY}
                        ScaleType={Enum.ScaleType.Fit}
                    />
                    <textlabel
                        AutomaticSize={Enum.AutomaticSize.X}
                        BackgroundTransparency={1}
                        FontFace={RobotoMonoBold}
                        Size={new UDim2(0, 0, 1, 0)}
                        Text={tostring(price)}
                        TextColor3={Color3.fromRGB(255, 255, 255)}
                        TextScaled={true}
                        TextXAlignment={Enum.TextXAlignment.Left}
                    >
                        <uistroke Thickness={1.5} Color={Color3.fromRGB(255, 255, 255)}>
                            <uigradient
                                Color={new ColorSequence(Color3.fromRGB(0, 51, 77), Color3.fromRGB(0, 31, 59))}
                            />
                        </uistroke>
                        <uigradient
                            Color={new ColorSequence(Color3.fromRGB(199, 222, 255), Color3.fromRGB(189, 255, 245))}
                            Rotation={90}
                        />
                    </textlabel>
                </frame>
            )}
        </textbutton>
    );
}

function ListingCard({
    listing,
    onBuy,
    onCancel,
    isOwner = false,
}: {
    listing: MarketplaceListing;
    onBuy?: (uuid: string) => void;
    onCancel?: (uuid: string) => void;
    isOwner?: boolean;
}) {
    const uniqueItemInstance = listing.uniqueItem;
    const item = Items.getItem(uniqueItemInstance.baseItemId);
    if (item === undefined) {
        throw `Failed to find item for listing: ${uniqueItemInstance.baseItemId}`;
    }

    const uniqueDetails = useMemo<UniqueListingDetails>(() => {
        const uniqueTrait = item.findTrait("Unique");
        if (uniqueTrait === undefined) {
            return {
                itemName: item.name,
                potEntries: [],
            };
        }

        const scaledPots = uniqueTrait.getScaledPots(uniqueItemInstance);
        const potConfigs = uniqueTrait.getPotConfigs();
        const potEntries = new Array<ListingPotDisplay>();

        for (const [potName, config] of potConfigs) {
            const scaledValue = scaledPots.get(potName);
            if (scaledValue === undefined) continue;

            const label = formatPotLabel(potName);
            const value = formatPotValue(scaledValue, config.integer);
            const range = config.max - config.min;
            const alpha = range > 0 ? math.clamp((scaledValue - config.min) / range, 0, 1) : 1;
            const color = UNIQUE_MIN_POT_COLOR.Lerp(UNIQUE_MAX_POT_COLOR, alpha);

            potEntries.push({
                key: potName,
                label,
                value,
                color,
            });
        }

        return {
            itemName: item.name,
            potEntries,
        };
    }, [listing.uniqueItem, listing.uuid]);

    const itemDisplayName = uniqueDetails.itemName;
    const potEntries = uniqueDetails.potEntries;

    const handleBuy = () => {
        if (onBuy) onBuy(listing.uuid);
    };

    const handleCancel = () => {
        if (onCancel) onCancel(listing.uuid);
    };

    const formatTimeRemaining = (expires?: number): string => {
        if (!expires) return "No expiry";
        const now = os.time();
        const remaining = expires - now;
        if (remaining <= 0) return "Expired";

        const days = math.floor(remaining / (24 * 60 * 60));
        const hours = math.floor((remaining % (24 * 60 * 60)) / (60 * 60));

        if (days > 0) return `${days}d ${hours}h`;
        return `${hours}h`;
    };

    const { background: backgroundColor, text: textColor } = getDifficultyDisplayColors(item.difficulty);

    return (
        <imagelabel
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={backgroundColor}
            BackgroundTransparency={0.2}
            BorderColor3={Color3.fromRGB(0, 0, 0)}
            BorderSizePixel={3}
            Image={getAsset("assets/Grid.png")}
            ImageColor3={Color3.fromRGB(126, 126, 126)}
            ImageTransparency={0.6}
            ScaleType={Enum.ScaleType.Tile}
            Size={new UDim2(1, 0, 0, 0)}
            TileSize={new UDim2(0, 100, 0, 100)}
            Event={{
                MouseEnter: () => {
                    const item = Items.getItem(listing.uniqueItem.baseItemId);
                    if (item === undefined) return;
                    TooltipManager.showTooltip({ item, uniqueInstance: listing.uniqueItem });
                },
                MouseLeave: () => {
                    TooltipManager.hideTooltip();
                },
            }}
        >
            <uistroke
                ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
                Color={backgroundColor}
                Thickness={2}
                Transparency={0.2}
            >
                <uigradient
                    Color={
                        new ColorSequence([
                            new ColorSequenceKeypoint(0, Color3.fromRGB(236, 236, 236)),
                            new ColorSequenceKeypoint(0.299, Color3.fromRGB(255, 255, 255)),
                            new ColorSequenceKeypoint(0.51, Color3.fromRGB(118, 118, 118)),
                            new ColorSequenceKeypoint(0.822, Color3.fromRGB(255, 255, 255)),
                            new ColorSequenceKeypoint(1, Color3.fromRGB(220, 220, 220)),
                        ])
                    }
                    Rotation={35}
                />
            </uistroke>
            <uigradient
                Color={
                    new ColorSequence([
                        new ColorSequenceKeypoint(0, Color3.fromRGB(39, 39, 39)),
                        new ColorSequenceKeypoint(1, Color3.fromRGB(58, 58, 58)),
                    ])
                }
                Rotation={270}
            />
            <uilistlayout
                FillDirection={Enum.FillDirection.Horizontal}
                HorizontalAlignment={Enum.HorizontalAlignment.Center}
                HorizontalFlex={Enum.UIFlexAlignment.Fill}
                VerticalAlignment={Enum.VerticalAlignment.Center}
                Padding={new UDim(0.04, 0)}
            />
            <uipadding
                PaddingTop={new UDim(0, 4)}
                PaddingBottom={new UDim(0, 4)}
                PaddingLeft={new UDim(0, 12)}
                PaddingRight={new UDim(0, 12)}
            />

            <viewportframe
                ref={(rbx) => {
                    if (!rbx) return;
                    ItemViewport.loadItemIntoViewport(rbx, listing.uniqueItem.baseItemId);
                }}
                BackgroundTransparency={1}
                Size={new UDim2(0, 50, 0, 50)}
            />

            <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={new UDim2(0.45, 0, 0, 0)}>
                <uilistlayout
                    FillDirection={Enum.FillDirection.Vertical}
                    HorizontalAlignment={Enum.HorizontalAlignment.Left}
                    VerticalAlignment={Enum.VerticalAlignment.Top}
                />

                {/** Item Name */}
                <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 0, 30)}>
                    <uilistlayout
                        FillDirection={Enum.FillDirection.Horizontal}
                        HorizontalAlignment={Enum.HorizontalAlignment.Left}
                        VerticalAlignment={Enum.VerticalAlignment.Center}
                        Padding={new UDim(0, 10)}
                    />

                    <textlabel
                        AutomaticSize={Enum.AutomaticSize.X}
                        BackgroundTransparency={1}
                        FontFace={RobotoMonoBold}
                        Size={new UDim2(0, 0, 1, 0)}
                        Text={itemDisplayName}
                        TextColor3={Color3.fromRGB(255, 255, 255)}
                        TextScaled={true}
                        TextXAlignment={Enum.TextXAlignment.Left}
                    >
                        <uigradient Color={new ColorSequence(Color3.fromRGB(255, 255, 255), textColor)} Rotation={90} />
                        <uistroke Thickness={2} />
                    </textlabel>
                </frame>

                {/** Item Description */}
                {potEntries.size() > 0 && (
                    <frame BackgroundTransparency={1} Size={new UDim2(1, 0, 0, 0)} AutomaticSize={Enum.AutomaticSize.Y}>
                        <uilistlayout
                            FillDirection={Enum.FillDirection.Vertical}
                            HorizontalAlignment={Enum.HorizontalAlignment.Left}
                            VerticalAlignment={Enum.VerticalAlignment.Top}
                            Padding={new UDim(0, 6)}
                        />
                        {potEntries.map((entry) => (
                            <textlabel
                                key={entry.key}
                                AutomaticSize={Enum.AutomaticSize.Y}
                                BackgroundTransparency={1}
                                FontFace={RobotoMono}
                                Size={new UDim2(1, 0, 0, 0)}
                                Text={`<font color="#8EA8BD">${entry.label}:</font> <font color="#${colorToHex(entry.color)}">${entry.value}</font>`}
                                RichText={true}
                                TextColor3={Color3.fromRGB(255, 255, 255)}
                                TextSize={24}
                                TextXAlignment={Enum.TextXAlignment.Left}
                            >
                                <uistroke Thickness={2} />
                            </textlabel>
                        ))}
                    </frame>
                )}

                <textlabel
                    BackgroundTransparency={1}
                    FontFace={RobotoMono}
                    Size={new UDim2(1, 0, 0, 16)}
                    Text={`Expires in ${formatTimeRemaining(listing.created + MARKETPLACE_CONFIG.LISTING_DURATION)}`}
                    TextColor3={Color3.fromRGB(179, 179, 179)}
                    TextScaled={true}
                    TextXAlignment={Enum.TextXAlignment.Left}
                >
                    <uistroke Thickness={1} />
                </textlabel>
            </frame>

            <frame BackgroundTransparency={1} Size={new UDim2(0.25, 0, 0, 50)}>
                <uiflexitem FlexMode={Enum.UIFlexMode.None} />
                <uilistlayout
                    FillDirection={Enum.FillDirection.Vertical}
                    HorizontalAlignment={Enum.HorizontalAlignment.Right}
                    VerticalAlignment={Enum.VerticalAlignment.Center}
                    Padding={new UDim(0, 12)}
                />

                {isOwner ? (
                    <ActionButton text="Cancel" backgroundColor={Color3.fromRGB(204, 67, 88)} onClick={handleCancel} />
                ) : (
                    <ActionButton
                        text="Buy Now"
                        price={listing.price}
                        backgroundColor={Color3.fromRGB(55, 189, 255)}
                        onClick={handleBuy}
                    />
                )}
            </frame>
        </imagelabel>
    );
}

export default memo(ListingCard);


import React from "@rbxts/react";
import ReactRoblox from "@rbxts/react-roblox";
import { CreateReactStory } from "@rbxts/ui-labs";
import MarketplaceWindow from "client/components/marketplace/MarketplaceWindow";
import StoryMocking from "client/components/StoryMocking";
import { useSingleDocumentVisibility } from "client/hooks/useVisibility";
import Items from "shared/items/Items";
import Packets from "shared/Packets";

export = CreateReactStory(
    {
        react: React,
        reactRoblox: ReactRoblox,
        controls: {
            visible: true,
        },
    },
    (props) => {
        StoryMocking.mockData();

        const listingUuid = "example-item-uuid";
        const sampleItem = Items.getItem("BulkyDropperBooster");
        let listingUniqueItem: UniqueItemInstance | undefined;

        if (sampleItem !== undefined) {
            const uniqueTrait = sampleItem.findTrait("Unique");
            if (uniqueTrait !== undefined) {
                listingUniqueItem = uniqueTrait.generateInstance(0.75);
                listingUniqueItem.created = os.time();
            }
        }

        if (listingUniqueItem === undefined) {
            const fallbackPots = new Map<string, number>();
            fallbackPots.set("dropRateMultiplier", 75);
            listingUniqueItem = {
                baseItemId: sampleItem?.id ?? "BulkyDropperBooster",
                pots: fallbackPots,
                created: os.time(),
            };
        }

        const uniqueInstances = Packets.uniqueInstances.get() ?? new Map<string, UniqueItemInstance>();
        uniqueInstances.set(listingUuid, listingUniqueItem);
        Packets.uniqueInstances.set(uniqueInstances);

        Packets.searchListings.fromClient(() => {
            return [
                {
                    bought: true,
                    uuid: listingUuid,
                    sellerId: 123456,
                    sellerEmpireId: "example-empire-id",
                    price: 1000,
                    listingType: "buyout",
                    created: os.time(),
                    expires: os.time() + 7 * 24 * 60 * 60, // Expires in one week
                    uniqueItem: listingUniqueItem,
                },
            ];
        });

        useSingleDocumentVisibility("Marketplace", props.controls.visible);

        return <MarketplaceWindow />;
    },
);