{"id":199625,"date":"2025-10-04T16:06:10","date_gmt":"2025-10-04T14:06:10","guid":{"rendered":"https:\/\/fivemx.com\/?p=199625"},"modified":"2025-12-23T16:43:22","modified_gmt":"2025-12-23T15:43:22","slug":"crie-um-aplicativo-de-telefone-personalizado","status":"publish","type":"post","link":"https:\/\/fivemx.com\/pt\/build-a-custom-phone-app\/","title":{"rendered":"Crie um aplicativo personalizado para celular (NUI + React) para QBCore\/ESX\u2026"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Goal<\/strong><br>Create a production\u2011ready in\u2011game smartphone for FiveM using NUI + React. You will scaffold a resource, wire QBCore\/ESX events, persist data in MySQL, and ship a smooth UI that respects performance budgets.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"https:\/\/fivemx.com\/how-to-create-a-fivem-server\/\">A running FiveM server<\/a> with txAdmin and MySQL (<a href=\"https:\/\/fivemx.com\/mysql-async-to-oxmysql\/\" data-type=\"post\" data-id=\"193033\">oxmysql or mysql-async<\/a>).<\/li>\n\n\n\n<li>Node.js 18+ and pnpm or npm on your dev PC.<\/li>\n\n\n\n<li>One framework installed: <strong>QBCore<\/strong> or <strong>ESX<\/strong>.<\/li>\n\n\n\n<li>Recommended libs: <strong>ox_lib<\/strong> (callbacks, notifications), <strong>ox_inventory<\/strong> (optional for phone item), <strong>ox_target<\/strong> (optional for world interactions).<\/li>\n\n\n\n<li>Basic React knowledge.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Docs<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Cfx.re NUI overview \u2013 <a href=\"https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/<\/a><\/li>\n\n\n\n<li>NUI callbacks \u2013 <a href=\"https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/nui-callbacks\/\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/nui-callbacks\/<\/a><\/li>\n\n\n\n<li>SendNUIMessage \u2013 <a href=\"https:\/\/docs.fivem.net\/docs\/scripting-reference\/runtimes\/lua\/functions\/SendNUIMessage\/\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.fivem.net\/docs\/scripting-reference\/runtimes\/lua\/functions\/SendNUIMessage\/<\/a><\/li>\n\n\n\n<li>Debug NUI devtools \u2013 <a href=\"https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/full-screen-nui\/\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.fivem.net\/docs\/scripting-manual\/nui-development\/full-screen-nui\/<\/a><\/li>\n\n\n\n<li>QBCore server functions \u2013 <a href=\"https:\/\/docs.qbcore.org\/qbcore-documentation\/qb-core\/server-function-reference\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.qbcore.org\/qbcore-documentation\/qb-core\/server-function-reference<\/a><\/li>\n\n\n\n<li>ESX <code>RegisterUsableItem<\/code> \u2013 <a href=\"https:\/\/docs.esx-framework.org\/en\/esx_core\/es_extended\/server\/functions\" target=\"_blank\" rel=\"noopener\">https:\/\/docs.esx-framework.org\/en\/esx_core\/es_extended\/server\/functions<\/a><\/li>\n\n\n\n<li>ox_lib callbacks \u2013 <a href=\"https:\/\/overextended.dev\/ox_lib\" target=\"_blank\" rel=\"noopener\">https:\/\/overextended.dev\/ox_lib<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Internal reading (FiveMX)<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Resmon &amp; performance \u2013 <a href=\"https:\/\/fivemx.com\/how-to-use-resmon-in-fivem-optimize-resources\/\">https:\/\/fivemx.com\/how-to-use-resmon-in-fivem-optimize-resources\/<\/a><\/li>\n\n\n\n<li>Performance hub \u2013 <a href=\"https:\/\/fivemx.com\/performance\">https:\/\/fivemx.com\/performance<\/a><\/li>\n\n\n\n<li>Phone scripts market overview \u2013 <a href=\"https:\/\/fivemx.com\/phone-scripts\">https:\/\/fivemx.com\/phone-scripts<\/a><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Resource<\/strong> <code>my_phone<\/code> with <code>fxmanifest.lua<\/code>, <code>client<\/code>, <code>server<\/code>, and <code>ui<\/code> bundle.<\/li>\n\n\n\n<li><strong>UI<\/strong>: React app built with Vite into <code>\/ui\/dist<\/code>. NUI talks to Lua via <code>postMessage<\/code> + <code>RegisterNUICallback<\/code>.<\/li>\n\n\n\n<li><strong>Data<\/strong>: MySQL tables for <code>phone_contacts<\/code>, <code>phone_messages<\/code>, <code>phone_calls<\/code>.<\/li>\n\n\n\n<li><strong>Framework glue<\/strong>: QBCore <strong>or<\/strong> ESX item usable handler toggles the phone, and server callbacks load\/save data.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Event flow<\/strong><\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Player presses key or uses the phone item \u2192 2) <code>SetNuiFocus(true, true)<\/code> and <code>SendNUIMessage({ action = 'open' })<\/code> \u2192 3) React shows UI \u2192 4) UI requests data via <code>fetch('https:\/\/my_phone\/xyz')<\/code> (NUI) \u2192 5) <code>RegisterNUICallback('xyz', ...)<\/code> runs on client\/server \u2192 6) Server reads\/writes DB \u2192 7) Response returns to UI \u2192 8) Close phone and release focus.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1 \u2014 Scaffold the resource<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Folder layout<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">resources\/\n  [local]\/\n    my_phone\/\n      fxmanifest.lua\n      client\/\n        main.lua\n      server\/\n        main.lua\n      ui\/\n        index.html\n        src\/\n          main.tsx\n          App.tsx\n          api.ts\n          styles.css\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>fxmanifest.lua<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fx_version 'cerulean'\ngame 'gta5'\n\nui_page 'ui\/dist\/index.html'\n\nfiles { 'ui\/dist\/**' }\n\nclient_scripts { 'client\/main.lua' }\nserver_scripts {\n  '@oxmysql\/lib\/MySQL.lua',\n  'server\/main.lua'\n}\n\nlua54 'yes'\n<\/pre>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/fivemx.com\/setting-up-fxmanifest-lua-fivem\/\">More info about: fxmanifest.lua<\/a><\/div>\n<\/div>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2 \u2014 Create the React NUI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Initialize a Vite React app inside <code>my_phone\/ui<\/code> and build to <code>ui\/dist<\/code>.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">cd my_phone\/ui\npnpm create vite@latest . --template react-ts\npnpm i\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Vite config<\/strong> (ensure assets land in <code>dist<\/code>)<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ vite.config.ts\nimport { defineConfig } from 'vite'\nimport react from '@vitejs\/plugin-react'\nexport default defineConfig({\n  plugins: [react()],\n  build: { outDir: 'dist', emptyOutDir: true },\n  base: ''\n})\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>NUI bridge<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ src\/api.ts\nexport async function nui&lt;T&gt;(event: string, data?: unknown): Promise&lt;T&gt; {\n  const res = await fetch(`https:\/\/my_phone\/${event}`, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/json' },\n    body: JSON.stringify(data ?? {})\n  })\n  return await res.json()\n}\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mount React + message listener<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ src\/main.tsx\nimport React from 'react'\nimport ReactDOM from 'react-dom\/client'\nimport App from '.\/App'\n\nconst root = ReactDOM.createRoot(document.getElementById('root')!)\nroot.render(&lt;App \/&gt;)\n\nwindow.addEventListener('message', (e) =&gt; {\n  if (e.data?.action === 'open') document.body.classList.add('open')\n  if (e.data?.action === 'close') document.body.classList.remove('open')\n})\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Basic UI<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ src\/App.tsx\nimport { useEffect, useState } from 'react'\nimport { nui } from '.\/api'\n\ntype Contact = { id: number; name: string; number: string }\n\nexport default function App() {\n  const [contacts, setContacts] = useState&lt;Contact[]&gt;([])\n  const [visible, setVisible] = useState(false)\n\n  useEffect(() =&gt; {\n    const handler = (e: MessageEvent) =&gt; {\n      if (e.data?.action === 'open') {\n        setVisible(true)\n        nui&lt;Contact[]&gt;('contacts:list').then(setContacts)\n      }\n      if (e.data?.action === 'close') setVisible(false)\n    }\n    window.addEventListener('message', handler)\n    return () =&gt; window.removeEventListener('message', handler)\n  }, [])\n\n  if (!visible) return null\n  return (\n    &lt;div className=\"phone\"&gt;\n      &lt;header&gt;Phone&lt;\/header&gt;\n      &lt;section&gt;\n        {contacts.map(c =&gt; (\n          &lt;div key={c.id} className=\"row\"&gt;\n            &lt;div&gt;{c.name}&lt;\/div&gt;\n            &lt;div&gt;{c.number}&lt;\/div&gt;\n          &lt;\/div&gt;\n        ))}\n      &lt;\/section&gt;\n      &lt;footer&gt;\n        &lt;button onClick={() =&gt; nui('ui:close')}&gt;Close&lt;\/button&gt;\n      &lt;\/footer&gt;\n    &lt;\/div&gt;\n  )\n}\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>index.html<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">&lt;!doctype html&gt;\n&lt;html&gt;\n  &lt;head&gt;\n    &lt;meta charset=\"utf-8\" \/&gt;\n    &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \/&gt;\n    &lt;title&gt;my_phone&lt;\/title&gt;\n    &lt;link rel=\"stylesheet\" href=\"\/src\/styles.css\" \/&gt;\n  &lt;\/head&gt;\n  &lt;body&gt;\n    &lt;div id=\"root\"&gt;&lt;\/div&gt;\n    &lt;script type=\"module\" src=\"\/src\/main.tsx\"&gt;&lt;\/script&gt;\n  &lt;\/body&gt;\n&lt;\/html&gt;\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Build the UI:<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">pnpm build\n<\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3 \u2014 Client: open\/close, NUI focus, callbacks<\/h2>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- client\/main.lua\nlocal open = false\n\nlocal function openPhone()\n    if open then return end\n    open = true\n    SetNuiFocus(true, true)\n    SendNUIMessage({ action = 'open' })\nend\n\nlocal function closePhone()\n    if not open then return end\n    open = false\n    SetNuiFocus(false, false)\n    SendNUIMessage({ action = 'close' })\nend\n\n-- Keybind (F1 example)\nRegisterCommand('myphone', function()\n    if open then closePhone() else openPhone() end\nend)\nRegisterKeyMapping('myphone', 'Toggle Phone', 'keyboard', 'F1')\n\n-- NUI \u2192 game callbacks\nRegisterNUICallback('ui:close', function(_, cb)\n    closePhone()\n    cb({ ok = true })\nend)\n\n-- list contacts asks the server\nRegisterNUICallback('contacts:list', function(_, cb)\n    lib.callback('my_phone:server:getContacts', false, function(rows)\n        cb(rows)\n    end)\nend)\n<\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Tip: enable NUI devtools in game console with <code>nui_devTools<\/code>. Open <code>http:\/\/localhost:13172<\/code> in your Chromium browser to inspect the UI.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4 \u2014 Server: DB schema + callbacks<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SQL<\/strong> (MySQL)<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">CREATE TABLE IF NOT EXISTS phone_contacts (\n  id INT AUTO_INCREMENT PRIMARY KEY,\n  citizenid VARCHAR(64) NOT NULL,\n  name VARCHAR(64) NOT NULL,\n  number VARCHAR(32) NOT NULL,\n  INDEX(citizenid)\n);\n\nCREATE TABLE IF NOT EXISTS phone_messages (\n  id BIGINT AUTO_INCREMENT PRIMARY KEY,\n  owner VARCHAR(64) NOT NULL,\n  peer VARCHAR(64) NOT NULL,\n  body TEXT NOT NULL,\n  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n  INDEX(owner), INDEX(peer)\n);\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Server with oxmysql + ox_lib<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- server\/main.lua\nlocal QBCore = exports['qb-core'] and exports['qb-core']:GetCoreObject()\nESX = ESX or nil\n\nif not QBCore then\n    TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end)\nend\n\n-- Load contacts for the logged-in character\nlib.callback.register('my_phone:server:getContacts', function(source)\n    local citizenid\n    if QBCore then\n        local Player = QBCore.Functions.GetPlayer(source)\n        citizenid = Player and Player.PlayerData.citizenid\n    else\n        local xPlayer = ESX.GetPlayerFromId(source)\n        citizenid = xPlayer and xPlayer.identifier\n    end\n    if not citizenid then return {} end\n\n    local rows = MySQL.query.await('SELECT id, name, number FROM phone_contacts WHERE citizenid = ?', { citizenid })\n    return rows or {}\nend)\n\n-- Save a contact\nlib.callback.register('my_phone:server:addContact', function(source, contact)\n    if type(contact) ~= 'table' then return { ok = false } end\n    local name, number = contact.name, contact.number\n    if not name or not number then return { ok = false } end\n\n    local citizenid\n    if QBCore then\n        local Player = QBCore.Functions.GetPlayer(source)\n        citizenid = Player and Player.PlayerData.citizenid\n    else\n        local xPlayer = ESX.GetPlayerFromId(source)\n        citizenid = xPlayer and xPlayer.identifier\n    end\n    if not citizenid then return { ok = false } end\n\n    MySQL.insert.await('INSERT INTO phone_contacts (citizenid, name, number) VALUES (?, ?, ?)', { citizenid, name, number })\n    return { ok = true }\nend)\n<\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5 \u2014 Framework integration (item + permissions)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">QBCore<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add a usable phone item and toggle the UI when used.<\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- server\/main.lua (QBCore only)\nif QBCore then\n  QBCore.Functions.CreateUseableItem('phone', function(src, item)\n      TriggerClientEvent('my_phone:client:toggle', src)\n  end)\nend\n<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- client\/main.lua\nRegisterNetEvent('my_phone:client:toggle', function()\n  if IsPauseMenuActive() then return end\n  if IsPedInAnyVehicle(PlayerPedId(), false) then -- optional rule\n    -- show a notification via ox_lib\n    lib.notify({ title = 'Phone', description = 'No phone while driving.', type = 'error' })\n    return\n  end\n  if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end\nend)\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">ESX<\/h3>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- server\/main.lua (ESX only)\nif ESX and not QBCore then\n  ESX.RegisterUsableItem('phone', function(playerId)\n    TriggerClientEvent('my_phone:client:toggle', playerId)\n  end)\nend\n<\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">If you use <strong>ox_inventory<\/strong>, create the item there and rely on its usable handlers. You can still trigger the same client event.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 6 \u2014 Core features<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Implement small slices and ship incrementally.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Contacts<\/h3>\n\n\n\n<ol class=\"wp-block-list\">\n<li>UI calls <code>contacts:list<\/code> \u2192 server returns rows.<\/li>\n\n\n\n<li>Add \u201cAdd contact\u201d form \u2192 call <code>addContact<\/code>.<\/li>\n\n\n\n<li>Add \u201cRemove contact\u201d \u2192 server deletes by <code>id<\/code> with citizen ownership check.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">Messages (SMS)<\/h3>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Table <code>phone_messages<\/code> stores owner, peer, body.<\/li>\n\n\n\n<li>UI opens a chat, calls <code>messages:list<\/code> and <code>messages:send<\/code>.<\/li>\n\n\n\n<li>Server inserts message, optionally emits client event to peer if online.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Server sketch<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">lib.callback.register('my_phone:server:messages:list', function(source, peer)\n  local cid = GetCitizenId(source)\n  return MySQL.query.await('SELECT * FROM phone_messages WHERE owner=? AND peer=? ORDER BY id DESC LIMIT 200', { cid, peer }) or {}\nend)\n\nRegisterNetEvent('my_phone:server:messages:send', function(peer, body)\n  local src = source\n  local cid = GetCitizenId(src)\n  if type(body) ~= 'string' or #body == 0 or #body &gt; 500 then return end\n  MySQL.insert.await('INSERT INTO phone_messages (owner, peer, body) VALUES (?, ?, ?)', { cid, peer, body })\n  TriggerClientEvent('my_phone:client:messages:push', src, peer, body)\n  -- optional: find target player by phone number and push live event\nend)\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Client receive<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">RegisterNetEvent('my_phone:client:messages:push', function(peer, body)\n  SendNUIMessage({ action = 'message:new', peer = peer, body = body })\nend)\n<\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Calls (optional MVP)<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Store call logs only. Real audio uses your voice plugin (pma-voice, mumble, SaltyChat) and is outside this MVP.<\/li>\n\n\n\n<li>Add UI keypad \u2192 on dial, log an outgoing call; on answer, log incoming. You can integrate later with a voice plugin\u2019s API.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 7 \u2014 Security, UX, performance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Security<\/strong><\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Never trust NUI input. Validate types and length on server.<\/li>\n\n\n\n<li>Check ownership on every query with <code>citizenid<\/code> or <code>identifier<\/code>.<\/li>\n\n\n\n<li>Avoid exposing identifiers to other clients. Use server relays.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>UX<\/strong><\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Cancel phone while downed, cuffed, or driving, if your server rules require it.<\/li>\n\n\n\n<li>Keep UI snappy. Use optimistic updates and reconcile on server ack.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Performance<\/strong><\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Keep NUI idle. Avoid setInterval loops in React. Use effects and events.<\/li>\n\n\n\n<li>Keep bundles small. Lazy\u2011load heavy screens. Ship compressed assets.<\/li>\n\n\n\n<li>Use Resmon to budget under 0.01\u20130.02 ms on average. See FiveMX guide linked above.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 8 \u2014 Testing &amp; debugging<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Start resource in <code>server.cfg<\/code> before dependent scripts.<\/li>\n<\/ol>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">ensure my_phone\n<\/pre>\n\n\n\n<ol start=\"2\" class=\"wp-block-list\">\n<li>In game, press F8 \u2192 run <code>nui_devTools<\/code> \u2192 open <code>http:\/\/localhost:13172<\/code> and pick your NUI page.<\/li>\n\n\n\n<li>Inspect network tab. Every NUI \u2192 Lua call hits <code>https:\/\/my_phone\/&lt;name&gt;<\/code> endpoints.<\/li>\n\n\n\n<li>Use <code>\/myphone<\/code> command and confirm focus toggles.<\/li>\n\n\n\n<li>Run Resmon and verify CPU stays low while the phone is open and closed.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 9 \u2014 Packaging &amp; updates<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Commit <code>ui\/<\/code> source and <code>ui\/dist\/<\/code> build.<\/li>\n\n\n\n<li>In CI, run <code>pnpm --filter ui build<\/code> and ship only <code>dist<\/code> in releases.<\/li>\n\n\n\n<li>Version your SQL migrations. Never drop user data without backups.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Step 10 \u2014 Extensions you can add next<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Banking<\/strong>: link to your server\u2019s banking resource; expose balance and transfers.<\/li>\n\n\n\n<li><strong>Tweets\/Ads<\/strong>: global feed with rate limits and moderation.<\/li>\n\n\n\n<li><strong>Marketplace<\/strong>: listings with escrow.<\/li>\n\n\n\n<li><strong>Job apps<\/strong>: police\/EMS MDT hooks.<\/li>\n\n\n\n<li><strong>Photos<\/strong>: screenshot integration via server endpoint, not data URLs.<\/li>\n\n\n\n<li><strong>Settings<\/strong>: dynamic themes, ringtones, backgrounds.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Phone opens behind pause menu<\/strong><br>Disable during pause checks and reopen when active returns.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>NUI callback not firing<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Ensure <code>RegisterNUICallback('event', ...)<\/code> names match the UI fetch path.<\/li>\n\n\n\n<li>Confirm <code>fx_version<\/code> is <code>cerulean<\/code> and <code>ui_page<\/code> points to <code>ui\/dist\/index.html<\/code>.<\/li>\n\n\n\n<li>Check F8 console for CORS or JSON errors.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Items not usable<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>QBCore: confirm <code>QBCore.Functions.CreateUseableItem<\/code> runs and <code>phone<\/code> exists in your item list.<\/li>\n\n\n\n<li>ESX: confirm <code>ESX.RegisterUsableItem('phone', ...)<\/code> registers after inventory loads.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Database errors<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Ensure oxmysql started before this resource.<\/li>\n\n\n\n<li>Check column sizes and encodings for Unicode names.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Reference snippets (copy\u2011paste)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Helper<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">function GetCitizenId(source)\n  if QBCore then\n    local P = QBCore.Functions.GetPlayer(source)\n    return P and P.PlayerData.citizenid\n  else\n    local xP = ESX.GetPlayerFromId(source)\n    return xP and xP.identifier\n  end\nend\n<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Add contact from UI<\/strong><\/p>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ UI\nasync function addContact(name: string, number: string) {\n  const res = await nui&lt;{ ok: boolean }&gt;('contacts:add', { name, number })\n  if (res.ok) {\n    const next = await nui&lt;any[]&gt;('contacts:list')\n    \/\/ update state\n  }\n}\n<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">-- client\nRegisterNUICallback('contacts:add', function(data, cb)\n  lib.callback('my_phone:server:addContact', false, function(resp)\n    cb(resp)\n  end, data)\nend)\n<\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">What you built<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A focused phone MVP with contacts and messages.<\/li>\n\n\n\n<li>A clean NUI bridge that works on both frameworks.<\/li>\n\n\n\n<li>A DB layer you can expand safely.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Ship it, measure performance, and iterate.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Further reading<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>FiveMX Resmon &amp; optimization: <a href=\"https:\/\/fivemx.com\/how-to-use-resmon-in-fivem-optimize-resources\/\">https:\/\/fivemx.com\/how-to-use-resmon-in-fivem-optimize-resources\/<\/a><\/li>\n\n\n\n<li>Voice comparison (choose your stack): <a href=\"https:\/\/fivemx.com\/fivem-voice-mumble-saltychat-pma-voice-guide\">https:\/\/fivemx.com\/fivem-voice-mumble-saltychat-pma-voice-guide<\/a><\/li>\n\n\n\n<li>Existing phones for inspiration:\n<ul class=\"wp-block-list\">\n<li>lb\u2011phone v2: <a href=\"https:\/\/fivemx.com\/lb-phone-v2\">https:\/\/fivemx.com\/lb-phone-v2<\/a><\/li>\n\n\n\n<li>Quasar Smartphone: <a href=\"https:\/\/fivemx.com\/quasar-smartphone\">https:\/\/fivemx.com\/quasar-smartphone<\/a><\/li>\n\n\n\n<li>GCPhone: <a href=\"https:\/\/fivemx.com\/gcphone\">https:\/\/fivemx.com\/gcphone<\/a><\/li>\n\n\n\n<li>Z\u2011Phone: <a href=\"https:\/\/fivemx.com\/z-phone\">https:\/\/fivemx.com\/z-phone<\/a><\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\" \/>\n\n\n\n<h2 class=\"wp-block-heading\">Meta<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Target resmon: \u22640.02 ms average while idle.<\/li>\n\n\n\n<li>Bundle budget: \u2264250 KB gzipped for MVP.<\/li>\n\n\n\n<li>UI FPS: 60.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>GoalCreate a production\u2011ready in\u2011game smartphone for FiveM using NUI + React. You will scaffold a resource, wire QBCore\/ESX events, persist data in MySQL, and ship a smooth UI that respects performance budgets. Prerequisites Docs Internal reading (FiveMX) Architecture Event flow Step 1 \u2014 Scaffold the resource Folder layout fxmanifest.lua Step 2 \u2014 Create the React [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":199627,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2340,1899],"tags":[],"class_list":["post-199625","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-lua-scripting","category-tutorials"],"blocksy_meta":[],"_links":{"self":[{"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/posts\/199625","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/comments?post=199625"}],"version-history":[{"count":0,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/posts\/199625\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/media\/199627"}],"wp:attachment":[{"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/media?parent=199625"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/categories?post=199625"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/fivemx.com\/pt\/wp-json\/wp\/v2\/tags?post=199625"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}