--[[ m-banking — client/main.lua Bank teller access (ox_target or ox_lib text UI), NUI lifecycle and the NUI → server callback bridge. The client never decides anything about money; it only asks and renders what comes back. ]] local ESX = exports.es_extended:getSharedObject() Banking = Banking or {} local isOpen = false local currentKind = nil local zones = {} local points = {} local blips = {} local textUiOpen = false -- ───────────────────────────────────────────────────────────────────────────── -- NUI lifecycle -- ───────────────────────────────────────────────────────────────────────────── --- @param kind string 'bank' | 'atm' --- @param payload table data from the server function Banking.Open(kind, payload) if isOpen then return end isOpen = true currentKind = kind Anim.Play(kind) Bank.PlaySound(kind == 'atm' and 'beep' or 'card') SetNuiFocus(true, true) SendNUIMessage({ action = 'open', data = payload }) end function Banking.Close() if not isOpen then return end isOpen = false currentKind = nil SetNuiFocus(false, false) SendNUIMessage({ action = 'close' }) Anim.Stop() TriggerServerEvent('m-banking:server:close') end RegisterNUICallback('close', function(_, cb) Banking.Close() cb({ ok = true }) end) -- Escape / resource stop safety. AddEventHandler('onResourceStop', function(resource) if resource ~= GetCurrentResourceName() then return end if isOpen then SetNuiFocus(false, false) end end) -- ───────────────────────────────────────────────────────────────────────────── -- Opening the teller -- ───────────────────────────────────────────────────────────────────────────── function Banking.OpenTeller() if isOpen then return end local payload = lib.callback.await('m-banking:server:openBank', false) if not payload then return end Banking.Open('bank', payload) end RegisterNetEvent('m-banking:client:openBank', Banking.OpenTeller) --- Pushed by the server when someone transfers money to this player while the --- UI happens to be open. RegisterNetEvent('m-banking:client:refresh', function() if not isOpen then return end local transactions = lib.callback.await('m-banking:server:history', false) SendNUIMessage({ action = 'refreshHistory', data = transactions }) end) -- ───────────────────────────────────────────────────────────────────────────── -- NUI → server bridge -- ───────────────────────────────────────────────────────────────────────────── --- Wrap a server callback so every NUI action follows the same shape: --- { ok = boolean, message = string|nil, data = table|nil } --- @param event string --- @param builder fun(payload: table): ... arguments passed to the server local function bridge(name, event, builder) RegisterNUICallback(name, function(payload, cb) if not isOpen then return cb({ ok = false }) end local result = lib.callback.await(event, false, builder and builder(payload)) if type(result) ~= 'table' then cb({ ok = false, message = L('notify.bank_closed') }) return end if result.message and result.message ~= '' then Bank.Notify(nil, result.message, result.ok and 'success' or 'error') end Bank.PlaySound(result.ok and 'cash' or 'error') cb(result) end) end bridge('deposit', 'm-banking:server:deposit', function(p) return p.amount end) bridge('withdraw', 'm-banking:server:withdraw', function(p) return p.amount end) bridge('changeIban', 'm-banking:server:changeIban', nil) bridge('requestCard','m-banking:server:requestCard',nil) bridge('reportCard', 'm-banking:server:reportCard', nil) RegisterNUICallback('transfer', function(payload, cb) if not isOpen then return cb({ ok = false }) end local result = lib.callback.await('m-banking:server:transfer', false, payload.iban, payload.amount, payload.note) if type(result) ~= 'table' then return cb({ ok = false, message = L('notify.bank_closed') }) end if result.message and result.message ~= '' then Bank.Notify(nil, result.message, result.ok and 'success' or 'error') end Bank.PlaySound(result.ok and 'cash' or 'error') cb(result) end) RegisterNUICallback('history', function(_, cb) if not isOpen then return cb({}) end cb(lib.callback.await('m-banking:server:history', false)) end) --- PIN changes use ox_lib input dialogs rather than NUI fields so the PIN never --- sits in the browser context. RegisterNUICallback('changePin', function(payload, cb) cb({ ok = true }) SetNuiFocus(false, false) -- A PIN already on file has to be proven before it can be replaced. At the -- ATM it was already proven on entry, so only the teller asks for it. local requireCurrent = payload and payload.hasPin and payload.kind ~= 'atm' local fields = {} if requireCurrent then fields[#fields + 1] = { type = 'input', label = LocaleUI().card_current_pin, password = true, required = true, min = Config.PinLength, max = Config.PinLength } end fields[#fields + 1] = { type = 'input', label = LocaleUI().card_new_pin, password = true, required = true, min = Config.PinLength, max = Config.PinLength } fields[#fields + 1] = { type = 'input', label = LocaleUI().card_repeat_pin, password = true, required = true, min = Config.PinLength, max = Config.PinLength } local input = lib.inputDialog(L('card.enter_pin'), fields) if isOpen then SetNuiFocus(true, true) end if not input then return end local currentPin = requireCurrent and input[1] or nil local newPin = requireCurrent and input[2] or input[1] local repeatPin = requireCurrent and input[3] or input[2] if newPin ~= repeatPin then Bank.Notify(nil, L('card.pin_mismatch'), 'error') return end local result = lib.callback.await('m-banking:server:setPin', false, newPin, currentPin) if type(result) == 'table' and result.message then Bank.Notify(nil, result.message, result.ok and 'success' or 'error') if result.ok then SendNUIMessage({ action = 'pinUpdated' }) end end end) -- ───────────────────────────────────────────────────────────────────────────── -- World setup: coordinate zones + blips -- ───────────────────────────────────────────────────────────────────────────── --- Merge a location's overrides with Config.ZoneDefaults. --- @param location table entry from Config.BankLocations --- @return vector3 size, number rotation, number distance, boolean debug local function zoneShape(location) local defaults = Config.ZoneDefaults return location.size or defaults.size, location.rotation or defaults.rotation, location.distance or defaults.distance, location.debug or Config.Debug or false end local function createBlip(location) if location.blip == false then return end local coords = location.coords local blip = AddBlipForCoord(coords.x, coords.y, coords.z) SetBlipSprite(blip, Config.Blip.sprite) SetBlipColour(blip, Config.Blip.colour) SetBlipScale(blip, Config.Blip.scale) SetBlipAsShortRange(blip, true) BeginTextCommandSetBlipName('STRING') AddTextComponentSubstringPlayerName(location.label or L('blip.bank')) EndTextCommandSetBlipName(blip) blips[#blips + 1] = blip end --- ox_target box zone straight on the configured coordinates. local function setupTargets() for index, location in ipairs(Config.BankLocations) do local size, rotation, distance, debug = zoneShape(location) zones[#zones + 1] = exports.ox_target:addBoxZone({ coords = location.coords, size = size, rotation = rotation, debug = debug, options = { { name = 'm-banking:teller:' .. index, icon = 'fa-solid fa-building-columns', label = location.label or L('target.bank'), distance = distance, onSelect = Banking.OpenTeller } } }) end end --- Text UI fallback when Config.UseTarget = false: an ox_lib point on the same --- coordinates, so both modes use exactly the same config entries. local function setupTextUI() for _, location in ipairs(Config.BankLocations) do local _, _, distance = zoneShape(location) local point = lib.points.new({ coords = location.coords, distance = distance, label = location.label }) function point:onEnter() if isOpen then return end textUiOpen = true lib.showTextUI(L('textui.bank'), { position = 'left-center' }) end function point:onExit() textUiOpen = false lib.hideTextUI() end function point:nearby() if isOpen or not textUiOpen then return end if IsControlJustReleased(0, Config.InteractKey) then lib.hideTextUI() textUiOpen = false Banking.OpenTeller() end end points[#points + 1] = point end end CreateThread(function() while not ESX.IsPlayerLoaded() do Wait(200) end if Config.ShowBlips then for _, location in ipairs(Config.BankLocations) do createBlip(location) end end if Config.UseTarget then setupTargets() else setupTextUI() end end) AddEventHandler('onResourceStop', function(resource) if resource ~= GetCurrentResourceName() then return end for _, zone in ipairs(zones) do exports.ox_target:removeZone(zone) end for _, point in ipairs(points) do point:remove() end for _, blip in ipairs(blips) do RemoveBlip(blip) end lib.hideTextUI() end)