> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rinne.com.br/llms.txt
> Use this file to discover all available pages before exploring further.

# Tap on Phone

> Turn a merchant's own Android phone into a contactless card terminal with Rinne's certified checkout screens and PIN pad. One token from your backend, one call per sale, one result.

export const TapOnPhoneSimulator = ({variant = 'full'}) => {
  const SIMULATOR_PATH = '/tap-on-phone/android/simulator';
  const DEFAULT_CONFIG = {
    primaryColor: '#7A4CFF',
    headerTextColor: '#FFFFFF',
    iconColor: '#15181E',
    backgroundType: 'FULL',
    merchantName: 'Padaria Bela Vista',
    tapTitle: '',
    tapSubtitle: '',
    pinPadType: 'SHIFTED'
  };
  const DEFAULT_REQUEST = {
    amountCents: 4990,
    type: 'PURCHASE',
    paymentMethod: 'CREDIT',
    installments: 1
  };
  const DEFAULT_SCENARIO = {
    outcome: 'APPROVED',
    failureCode: 'NETWORK',
    askPin: true,
    wrongPinFirst: false,
    locationDeniedFirst: false,
    hint: ''
  };
  const PRESETS = [{
    id: 'Rinne',
    primaryColor: '#7A4CFF',
    headerTextColor: '#FFFFFF',
    iconColor: '#15181E',
    backgroundType: 'FULL'
  }, {
    id: 'Teal',
    primaryColor: '#0E7C86',
    headerTextColor: '#FFFFFF',
    iconColor: '#0B2E31',
    backgroundType: 'FULL'
  }, {
    id: 'Sunset',
    primaryColor: '#F25C24',
    headerTextColor: '#3A1E10',
    iconColor: '#3A1E10',
    backgroundType: 'TRANSPARENT'
  }, {
    id: 'Mono',
    primaryColor: '#15181E',
    headerTextColor: '#FFFFFF',
    iconColor: '#15181E',
    backgroundType: 'FULL'
  }];
  const INK = '#15181E';
  const SUCCESS = '#16A75E';
  const DANGER = '#FF5C5C';
  const ERROR_CODES = ['TERMINAL_NOT_READY', 'ATTESTATION_FAILED', 'CARD_READ_TIMEOUT', 'PIN_FAILED', 'DECLINED_BY_ISSUER', 'NETWORK', 'CANCELLED', 'UNKNOWN', 'LOCATION_UNAVAILABLE', 'SECURE_ENTRY_BLOCKED', 'ACCESS_TOKEN_REJECTED', 'TAP_ON_PHONE_NOT_ENABLED', 'EMV_CONFIG_UNAVAILABLE'];
  const RETRIABLE = {
    TERMINAL_NOT_READY: true,
    ATTESTATION_FAILED: false,
    CARD_READ_TIMEOUT: true,
    PIN_FAILED: true,
    DECLINED_BY_ISSUER: false,
    NETWORK: true,
    CANCELLED: true,
    UNKNOWN: true,
    LOCATION_UNAVAILABLE: true,
    SECURE_ENTRY_BLOCKED: true,
    ACCESS_TOKEN_REJECTED: true,
    TAP_ON_PHONE_NOT_ENABLED: false,
    EMV_CONFIG_UNAVAILABLE: true
  };
  const SETUP_CODES = ['TERMINAL_NOT_READY', 'ATTESTATION_FAILED', 'ACCESS_TOKEN_REJECTED', 'TAP_ON_PHONE_NOT_ENABLED', 'EMV_CONFIG_UNAVAILABLE'];
  const FAILURE_CODES = ERROR_CODES.filter(c => c !== 'DECLINED_BY_ISSUER' && c !== 'CANCELLED');
  const ERROR_RES = {
    TERMINAL_NOT_READY: 'rinne_error_terminal_not_ready',
    ATTESTATION_FAILED: 'rinne_error_attestation_failed',
    CARD_READ_TIMEOUT: 'rinne_error_card_read_timeout',
    PIN_FAILED: 'rinne_error_pin_failed',
    DECLINED_BY_ISSUER: 'rinne_error_declined_by_issuer',
    NETWORK: 'rinne_error_network',
    CANCELLED: 'rinne_error_cancelled',
    UNKNOWN: 'rinne_error_unknown',
    LOCATION_UNAVAILABLE: 'rinne_error_location_unavailable',
    SECURE_ENTRY_BLOCKED: 'rinne_error_secure_entry_blocked',
    ACCESS_TOKEN_REJECTED: 'rinne_error_access_token_rejected',
    TAP_ON_PHONE_NOT_ENABLED: 'rinne_error_tap_on_phone_not_enabled',
    EMV_CONFIG_UNAVAILABLE: 'rinne_error_emv_config_unavailable'
  };
  const HINTS = [{
    id: 'AGAIN',
    res: 'rinne_tap_again'
  }, {
    id: 'HOLD_STILL',
    res: 'rinne_tap_hold_still'
  }, {
    id: 'TRY_ANOTHER_CARD',
    res: 'rinne_tap_try_another_card'
  }, {
    id: 'SEE_PHONE',
    res: 'rinne_tap_see_phone'
  }, {
    id: 'CONTACTLESS_DISABLED',
    res: 'rinne_tap_contactless_disabled'
  }];
  const SANDBOX_RULES = {
    '51': {
      outcome: 'DECLINED',
      label: 'declined · 51 insufficient funds'
    },
    '05': {
      outcome: 'DECLINED',
      label: 'declined · 05 do not honor'
    },
    '54': {
      outcome: 'DECLINED',
      label: 'declined · 54 expired card'
    },
    '14': {
      outcome: 'DECLINED',
      label: 'declined · 14 invalid card'
    },
    '96': {
      outcome: 'FAILED',
      code: 'UNKNOWN',
      label: 'failed · 96 provider error'
    }
  };
  const COPY = {
    'pt-BR': {
      rinne_tap_title: 'Aproxime para pagar',
      rinne_tap_subtitle: 'Encoste o cartão, celular ou relógio na parte de trás deste aparelho',
      rinne_tap_again: 'Aproxime o cartão novamente',
      rinne_tap_hold_still: 'Mantenha o cartão encostado',
      rinne_tap_try_another_card: 'Tente outro cartão',
      rinne_tap_see_phone: 'Peça para o cliente verificar o celular',
      rinne_tap_contactless_disabled: 'A aproximação está desativada neste aparelho',
      rinne_preparing_title: 'Preparando',
      rinne_preparing_subtitle: 'Preparando a leitora de cartões…',
      rinne_processing_title: 'Processando pagamento',
      rinne_processing_card_read: 'Cartão lido',
      rinne_processing_working: 'Processando…',
      rinne_processing_authorising: 'Autorizando…',
      rinne_pin_title: 'Aguardando a senha',
      rinne_pin_subtitle: 'Entregue o aparelho ao cliente',
      rinne_pin_retry_subtitle: 'A senha não foi aceita — peça para o cliente tentar novamente',
      rinne_approved_title: 'Pagamento aprovado',
      rinne_approved_subtitle: 'Autorizado',
      rinne_declined_title: 'Pagamento recusado',
      rinne_failed_title: 'Não foi possível concluir o pagamento',
      rinne_cancelled_title: 'Pagamento cancelado',
      rinne_tap_title_refund: 'Aproxime para estornar',
      rinne_processing_title_refund: 'Processando estorno',
      rinne_approved_title_refund: 'Estorno aprovado',
      rinne_declined_title_refund: 'Estorno recusado',
      rinne_failed_title_refund: 'Não foi possível concluir o estorno',
      rinne_cancelled_title_refund: 'Estorno cancelado',
      rinne_action_done: 'Concluir',
      rinne_action_retry: 'Tentar novamente',
      rinne_action_cancel: 'Cancelar',
      rinne_action_close: 'Fechar',
      rinne_marks_label: 'Bandeiras e carteiras aceitas',
      rinne_location_title: 'Localização necessária',
      rinne_location_subtitle: 'Pagamentos com cartão neste aparelho exigem acesso à localização. Ela é usada para autorizar a transação e nunca para rastrear ninguém.',
      rinne_location_grant: 'Permitir localização',
      rinne_a11y_contactless_symbol: 'Símbolo de pagamento por aproximação',
      rinne_a11y_marks: 'Bandeiras e carteiras aceitas',
      rinne_a11y_approved: 'Aprovado',
      rinne_a11y_declined: 'Recusado',
      rinne_a11y_failed: 'Falhou',
      rinne_method_debit: 'Débito',
      rinne_method_credit: 'Crédito · à vista',
      rinne_method_credit_installments: 'Crédito · %1$dx de %2$s',
      rinne_descriptor_refund: 'Estorno · %1$s',
      rinne_error_terminal_not_ready: 'A maquininha ainda não está pronta. Aguarde a configuração terminar e tente de novo.',
      rinne_error_attestation_failed: 'Este aparelho não pode aceitar pagamentos. Verifique se ele está atualizado e sem modificações. Se continuar acontecendo, este aparelho pode não ser compatível.',
      rinne_error_card_read_timeout: 'O cartão não foi lido. Encoste o cartão na parte de trás do celular até ouvir o bipe.',
      rinne_error_pin_failed: 'A senha não foi aceita. Peça para o cliente tentar de novo ou usar outro cartão.',
      rinne_error_declined_by_issuer: 'O pagamento foi recusado. Peça outro cartão ou outra forma de pagamento ao cliente.',
      rinne_error_network: 'Sem conexão com a rede de pagamentos. Verifique a internet e tente de novo.',
      rinne_error_cancelled: 'O pagamento foi cancelado. Inicie um novo pagamento quando quiser.',
      rinne_error_unknown: 'Não foi possível concluir o pagamento. Tente de novo. Se continuar acontecendo, fale com o suporte.',
      rinne_error_location_unavailable: 'A localização é necessária para aceitar pagamentos. Ative a localização, permita o acesso para este app e tente de novo.',
      rinne_error_secure_entry_blocked: 'Não é possível digitar a senha com segurança agora. Feche o que estiver gravando a tela ou usando a câmera ou o microfone, e qualquer app sobreposto a este, e tente de novo.',
      rinne_error_access_token_rejected: 'O app não conseguiu se autenticar para aceitar pagamentos. Tente de novo. Se continuar acontecendo, saia e entre novamente na conta ou fale com o suporte.',
      rinne_error_tap_on_phone_not_enabled: 'Esta conta não está habilitada para aceitar pagamentos com cartão pelo celular. Fale com o suporte para habilitar.',
      rinne_error_emv_config_unavailable: 'Esta versão do app ainda não está pronta para aceitar pagamentos. Tente de novo mais tarde ou verifique se há atualização do app.',
      rinne_pinpad_title: 'Digite a senha',
      rinne_pinpad_cancel: 'Cancelar',
      rinne_pinpad_clear: 'Limpar',
      rinne_pinpad_enter: 'Confirmar',
      rinne_pinpad_refund: 'Estorno',
      host_eyebrow: 'Detalhes da venda',
      host_debit: 'Débito',
      host_credit: 'Crédito',
      host_single: 'À vista',
      host_single_sub: '1x sem juros',
      host_installments: 'Parcelado',
      host_installments_sub: 'até 12x',
      host_refund: 'Estorno',
      host_refund_sub: 'Devolução integral',
      host_total: 'Total',
      host_total_sub: 'Valor total',
      host_cta: 'Vender com Tap on Phone',
      host_cta_refund: 'Estornar com Tap on Phone'
    },
    en: {
      rinne_tap_title: 'Tap to pay',
      rinne_tap_subtitle: 'Hold the card, phone or watch against the back of this device',
      rinne_tap_again: 'Present the card again',
      rinne_tap_hold_still: 'Hold the card still',
      rinne_tap_try_another_card: 'Try another card',
      rinne_tap_see_phone: 'Ask the customer to check their phone',
      rinne_tap_contactless_disabled: 'Contactless is switched off on this device',
      rinne_preparing_title: 'Getting ready',
      rinne_preparing_subtitle: 'Preparing the card reader…',
      rinne_processing_title: 'Processing payment',
      rinne_processing_card_read: 'Card read',
      rinne_processing_working: 'Working…',
      rinne_processing_authorising: 'Authorising…',
      rinne_pin_title: 'Waiting for the PIN',
      rinne_pin_subtitle: 'Hand the device to the customer',
      rinne_pin_retry_subtitle: 'The PIN was not accepted — ask the customer to try again',
      rinne_approved_title: 'Payment approved',
      rinne_approved_subtitle: 'Authorised',
      rinne_declined_title: 'Payment declined',
      rinne_failed_title: 'Payment could not be completed',
      rinne_cancelled_title: 'Payment cancelled',
      rinne_tap_title_refund: 'Tap to refund',
      rinne_processing_title_refund: 'Processing refund',
      rinne_approved_title_refund: 'Refund approved',
      rinne_declined_title_refund: 'Refund declined',
      rinne_failed_title_refund: 'Refund could not be completed',
      rinne_cancelled_title_refund: 'Refund cancelled',
      rinne_action_done: 'Done',
      rinne_action_retry: 'Try again',
      rinne_action_cancel: 'Cancel',
      rinne_action_close: 'Close',
      rinne_marks_label: 'Accepted cards and wallets',
      rinne_location_title: 'Location needed',
      rinne_location_subtitle: 'Card payments on this device require location access. It is used to authorise the transaction and is never used to track anyone.',
      rinne_location_grant: 'Allow location',
      rinne_a11y_contactless_symbol: 'Contactless payment symbol',
      rinne_a11y_marks: 'Accepted card brands and wallets',
      rinne_a11y_approved: 'Approved',
      rinne_a11y_declined: 'Declined',
      rinne_a11y_failed: 'Failed',
      rinne_method_debit: 'Debit',
      rinne_method_credit: 'Credit · single payment',
      rinne_method_credit_installments: 'Credit · %1$dx of %2$s',
      rinne_descriptor_refund: 'Refund · %1$s',
      rinne_error_terminal_not_ready: 'The card reader is not ready yet. Wait for setup to finish, then try again.',
      rinne_error_attestation_failed: 'This device cannot accept payments. Check that the device is up to date and unmodified. If it keeps happening, this device may not be supported.',
      rinne_error_card_read_timeout: 'The card was not read. Hold the card flat against the back of the phone until it beeps.',
      rinne_error_pin_failed: 'The PIN was not accepted. Ask the customer to try again, or use another card.',
      rinne_error_declined_by_issuer: 'The payment was declined. Ask the customer for another card or payment method.',
      rinne_error_network: 'No connection to the payment network. Check the internet connection and try again.',
      rinne_error_cancelled: 'The payment was cancelled. Start a new payment when you are ready.',
      rinne_error_unknown: 'The payment could not be completed. Try again. If it keeps happening, contact support.',
      rinne_error_location_unavailable: 'Location is required to accept payments. Turn on location and allow it for this app, then try again.',
      rinne_error_secure_entry_blocked: 'The PIN cannot be entered safely right now. Close anything recording the screen or using the camera or microphone, and any app drawing over this one, then try again.',
      rinne_error_access_token_rejected: 'The app could not sign in to accept payments. Try again. If it keeps happening, sign out and back in, or contact support.',
      rinne_error_tap_on_phone_not_enabled: 'This account is not enabled to accept card payments on a phone. Contact support to enable it.',
      rinne_error_emv_config_unavailable: 'This version of the app is not ready to accept payments yet. Try again later, or check for an app update.',
      rinne_pinpad_title: 'Enter PIN',
      rinne_pinpad_cancel: 'Cancel',
      rinne_pinpad_clear: 'Clear',
      rinne_pinpad_enter: 'Confirm',
      rinne_pinpad_refund: 'Refund',
      host_eyebrow: 'Sale details',
      host_debit: 'Debit',
      host_credit: 'Credit',
      host_single: 'Single payment',
      host_single_sub: '1x, no interest',
      host_installments: 'Installments',
      host_installments_sub: 'up to 12x',
      host_refund: 'Refund',
      host_refund_sub: 'Full amount',
      host_total: 'Total',
      host_total_sub: 'Amount due',
      host_cta: 'Sell with Tap on Phone',
      host_cta_refund: 'Refund with Tap on Phone'
    }
  };
  const SCREENS = [{
    id: 'preparing',
    label: 'Preparing'
  }, {
    id: 'present',
    label: 'Tap'
  }, {
    id: 'processing',
    label: 'Processing'
  }, {
    id: 'pin',
    label: 'PIN'
  }, {
    id: 'approved',
    label: 'Approved'
  }, {
    id: 'declined',
    label: 'Declined'
  }, {
    id: 'failed',
    label: 'Failed'
  }, {
    id: 'cancelled',
    label: 'Cancelled'
  }];
  const SECTIONS = [{
    id: 'brand',
    label: 'Brand'
  }, {
    id: 'copy',
    label: 'Copy'
  }, {
    id: 'pad',
    label: 'PIN pad'
  }, {
    id: 'sale',
    label: 'Sale'
  }, {
    id: 'scenario',
    label: 'Scenario'
  }];
  const MARKS = [{
    name: 'Visa',
    w: 67,
    h: 33,
    paths: [['#1633CA', 'M29.5499 24.2445H25.7021L28.1088 9.72412H31.9563L29.5499 24.2445Z'], ['#1633CA', 'M43.4972 10.0791C42.7382 9.78533 41.5345 9.46094 40.0461 9.46094C36.2463 9.46094 33.5706 11.4381 33.5542 14.2648C33.5226 16.3503 35.4699 17.5087 36.9264 18.2041C38.415 18.9147 38.921 19.3785 38.921 20.0119C38.9059 20.9846 37.7181 21.433 36.6103 21.433C35.0742 21.433 34.251 21.2018 33.0002 20.6606L32.4935 20.4286L31.9551 23.6881C32.8575 24.0893 34.5201 24.4453 36.2463 24.4609C40.2836 24.4609 42.912 22.5144 42.9432 19.5021C42.9585 17.8491 41.9303 16.5825 39.7134 15.5476C38.3677 14.8832 37.5435 14.4352 37.5435 13.7554C37.5593 13.1374 38.2406 12.5045 39.7597 12.5045C41.0106 12.4735 41.9296 12.7669 42.6259 13.0604L42.9739 13.2146L43.4972 10.0791Z'], ['#1633CA', 'M48.6122 19.1005C48.9291 18.2663 50.1484 15.0378 50.1484 15.0378C50.1324 15.0688 50.4646 14.1882 50.6546 13.6476L50.9235 14.8988C50.9235 14.8988 51.6522 18.3745 51.8104 19.1005C51.2091 19.1005 49.3722 19.1005 48.6122 19.1005ZM53.3617 9.72412H50.3855C49.4677 9.72412 48.7704 9.98651 48.3745 10.9289L42.6592 24.2443H46.6965C46.6965 24.2443 47.3611 22.4522 47.504 22.0662C47.9469 22.0662 51.8744 22.0662 52.4441 22.0662C52.5546 22.576 52.9033 24.2443 52.9033 24.2443H56.4659L53.3617 9.72412Z'], ['#1633CA', 'M22.4879 9.72412L18.7198 19.6256L18.308 17.6175C17.6113 15.3004 15.4265 12.7828 12.9883 11.5311L16.4398 24.2291H20.5086L26.5566 9.72412H22.4879Z'], ['#1633CA', 'M15.2207 9.72607H9.03013L8.9668 10.0195C13.7958 11.2244 16.994 14.129 18.308 17.6201L16.9623 10.9466C16.7407 10.0193 16.0598 9.75667 15.2207 9.72607Z']]
  }, {
    name: 'Mastercard',
    w: 49,
    h: 32,
    paths: [['#FF5D11', 'M28.7441 8.13818H19.9951V23.8614H28.7441V8.13818Z'], ['#EC0021', 'M20.5506 16.0017C20.549 14.4872 20.892 12.9922 21.5536 11.63C22.2153 10.2677 23.1783 9.07378 24.3696 8.13872C22.8945 6.97947 21.1231 6.2586 19.2577 6.05851C17.3923 5.85841 15.5082 6.18715 13.8209 7.00716C12.1335 7.82718 10.7108 9.10538 9.71551 10.6957C8.7202 12.286 8.19238 14.1242 8.19238 16.0003C8.19238 17.8764 8.7202 19.7146 9.71551 21.3049C10.7108 22.8952 12.1335 24.1734 13.8209 24.9935C15.5082 25.8135 17.3923 26.1422 19.2577 25.9421C21.1231 25.742 22.8945 25.0211 24.3696 23.8619C23.1786 22.9271 22.2159 21.7337 21.5542 20.3719C20.8926 19.0101 20.5494 17.5157 20.5506 16.0017Z'], ['#F89E26', 'M40.5488 16.0014C40.5487 17.8776 40.0208 19.716 39.0253 21.3063C38.0298 22.8967 36.607 24.1749 34.9194 24.9948C33.2318 25.8147 31.3475 26.1432 29.4821 25.9428C27.6166 25.7424 25.8451 25.0212 24.3701 23.8616C25.5606 22.926 26.5231 21.7322 27.1849 20.3704C27.8467 19.0085 28.1906 17.5141 28.1906 16C28.1906 14.4859 27.8467 12.9915 27.1849 11.6296C26.5231 10.2678 25.5606 9.07402 24.3701 8.13841C25.8451 6.9788 27.6166 6.25758 29.4821 6.0572C31.3475 5.85681 33.2318 6.18534 34.9194 7.00524C36.607 7.82514 38.0298 9.10332 39.0253 10.6937C40.0208 12.284 40.5487 14.1224 40.5488 15.9986V16.0014Z']]
  }, {
    name: 'Elo',
    w: 67,
    h: 33,
    paths: [['#003933', 'M6.02148 14.9601H10.079C10.8111 11.0942 14.1707 7.98779 18.4539 7.98779C22.7372 7.98779 26.7399 11.4568 26.7399 16.6912C26.7399 17.176 26.6852 17.697 26.6305 18.0939H13.5275C14.1433 20.475 16.1002 21.8708 18.4539 21.8708C20.8077 21.8708 21.9845 21.0839 23.1819 19.7155L25.5904 21.9255C23.9962 23.7456 21.6151 25.0046 18.4539 25.0046C15.2928 25.0046 10.8111 21.9529 10.0858 18.087H6.02148V14.9532V14.9601ZM23.2641 14.9601C23.093 13.9816 22.6483 13.0853 22.0051 12.4421C21.1635 11.6005 19.9319 11.0737 18.4471 11.0737C16.9623 11.0737 15.7581 11.58 14.8617 12.4763C13.9654 13.3727 13.7396 13.9337 13.4933 14.9669H23.2641V14.9601ZM29.1758 8.09727H32.6996V21.5355H39.0834V24.8951H32.7543C30.4279 24.8951 29.169 23.6361 29.169 21.3097V8.09727H29.1758ZM39.754 16.4996C39.754 11.9358 43.4762 7.98779 48.4026 7.98779C53.3291 7.98779 56.1823 11.0668 56.9144 14.9601H60.9719V18.0939H56.9144C56.1618 21.9598 52.7406 25.0114 48.4026 25.0114C43.4488 25.0114 39.754 21.0908 39.754 16.4996ZM48.4095 21.7066C51.3517 21.7066 53.5344 19.4692 53.5344 16.527C53.5344 13.5848 51.2969 11.2926 48.4095 11.2926C45.4673 11.2926 43.2846 13.53 43.2846 16.4996C43.2846 19.4692 45.522 21.7066 48.4095 21.7066Z']]
  }, {
    name: 'Amex',
    w: 33,
    h: 33,
    paths: [['#016FD0', 'M3.41406 3.41309H29.5865V29.5855H3.41406V3.41309Z'], ['#FFFFFF', 'M12.895 25.4439V16.0654L29.5858 16.08V18.6711L27.6563 20.7329L29.5858 22.8136V25.4585H26.5061L24.8689 23.6526L23.2433 25.4658L12.895 25.4439Z'], ['#016FD0', 'M14.0083 24.4148V17.0967H20.2141V18.7819H16.0178V19.9262H20.1137V21.5838H16.0178V22.7077H20.2141V24.4148H14.0083Z'], ['#016FD0', 'M20.1826 24.4148L23.617 20.7507L20.1826 17.0967H22.8406L24.9388 19.4159L27.0426 17.0967H29.5858V17.1549L26.2255 20.7507L29.5858 24.31V24.4148H27.0166L24.882 22.0723L22.7679 24.4148H20.1826Z'], ['#FFFFFF', 'M13.6737 7.74219H17.6971L19.1104 10.9513V7.74219H24.0773L24.9338 10.1472L25.793 7.74219H29.5867V17.1206H9.5415L13.6737 7.74219Z'], ['#016FD0', 'M14.4353 8.76172L11.1885 16.0739H13.4161L14.0283 14.6097H17.3477L17.9599 16.0739H20.2428L17.0074 8.76172H14.4353ZM14.7145 12.9681L15.6888 10.6402L16.6614 12.9681H14.7145Z'], ['#016FD0', 'M20.2114 16.0729V8.76074L23.342 8.77238L24.953 13.2668L26.5743 8.76074H29.5869V16.0729L27.6488 16.0905V11.0668L25.8181 16.0729H24.0487L22.1802 11.0508V16.0729H20.2114Z']]
  }, {
    name: 'Apple Pay',
    w: 66,
    h: 34,
    paths: [['#000000', 'M17.0628 9.5785C16.4898 10.254 15.5731 10.7869 14.6563 10.7108C14.5417 9.79734 14.9905 8.82683 15.5158 8.2274C16.0887 7.53283 17.0915 7.03806 17.9032 7C17.9987 7.95147 17.6262 8.88392 17.0628 9.5785ZM17.8936 10.8915C16.5662 10.8154 15.4298 11.6432 14.7995 11.6432C14.1597 11.6432 13.1952 10.9296 12.1447 10.9486C10.7791 10.9677 9.50899 11.7383 8.81186 12.9657C7.3794 15.4206 8.43942 19.0552 9.82413 21.0533C10.5022 22.0428 11.3139 23.1275 12.3834 23.0894C13.3957 23.0514 13.7968 22.4329 15.0192 22.4329C16.2511 22.4329 16.6044 23.0894 17.674 23.0704C18.7818 23.0514 19.4789 22.0809 20.1569 21.0913C20.9304 19.9686 21.2456 18.8744 21.2647 18.8173C21.2456 18.7983 19.1255 17.9895 19.1064 15.5538C19.0873 13.5176 20.7776 12.5471 20.854 12.49C19.8991 11.0818 18.4093 10.9296 17.8936 10.8915ZM25.562 8.13226V22.9657H27.8731V17.8944H31.0722C33.9944 17.8944 36.0476 15.8963 36.0476 13.0038C36.0476 10.1113 34.0326 8.13226 31.1486 8.13226H25.562ZM27.8731 10.0733H30.5374C32.5429 10.0733 33.6888 11.1389 33.6888 13.0133C33.6888 14.8877 32.5429 15.9629 30.5279 15.9629H27.8731V10.0733ZM40.2686 23.0799C41.7201 23.0799 43.0667 22.3473 43.6778 21.1865H43.7256V22.9657H45.8647V15.5823C45.8647 13.4415 44.1458 12.0618 41.5005 12.0618C39.0462 12.0618 37.2318 13.4605 37.1649 15.3825H39.2468C39.4187 14.4691 40.2686 13.8696 41.4337 13.8696C42.847 13.8696 43.6396 14.5262 43.6396 15.7345V16.5528L40.7556 16.7241C38.0722 16.8858 36.6206 17.98 36.6206 19.883C36.6206 21.8049 38.1199 23.0799 40.2686 23.0799ZM40.8893 21.3197C39.6574 21.3197 38.8743 20.7298 38.8743 19.8259C38.8743 18.8934 39.6288 18.3511 41.0708 18.2655L43.6396 18.1037V18.941C43.6396 20.3302 42.4555 21.3197 40.8893 21.3197ZM48.7201 27C50.9738 27 52.0338 26.1437 52.9601 23.5461L57.0188 12.2046H54.6695L51.9479 20.9677H51.9001L49.1785 12.2046H46.7624L50.6778 23.0038L50.4677 23.6603C50.1143 24.7736 49.5414 25.2017 48.5195 25.2017C48.3381 25.2017 47.9847 25.1827 47.8415 25.1637V26.9429C47.9752 26.981 48.5482 27 48.7201 27Z']]
  }, {
    name: 'Google Pay',
    w: 62,
    h: 34,
    paths: [['#000000', 'M30.0369 17.3683V22.6413H28.3359V9.60059H32.7584C33.8357 9.60059 34.8563 9.99748 35.6501 10.7346C36.4438 11.4149 36.8407 12.4355 36.8407 13.5128C36.8407 14.5901 36.4438 15.5539 35.6501 16.291C34.8563 17.0281 33.8924 17.425 32.7584 17.425L30.0369 17.3683ZM30.0369 11.1881V15.724H32.8718C33.4955 15.724 34.1192 15.4972 34.5161 15.0437C35.4233 14.1932 35.4233 12.7757 34.5728 11.9252L34.5161 11.8685C34.0625 11.4149 33.4955 11.1314 32.8718 11.1881H30.0369Z'], ['#000000', 'M40.7521 13.4565C41.9994 13.4565 42.9633 13.7967 43.7004 14.4771C44.4375 15.1575 44.7777 16.0647 44.7777 17.1987V22.6417H43.1901V21.3944H43.1334C42.453 22.4149 41.4891 22.9252 40.3552 22.9252C39.3913 22.9252 38.5408 22.6417 37.8604 22.0747C37.2367 21.5078 36.8398 20.714 36.8398 19.8635C36.8398 18.9563 37.18 18.2192 37.8604 17.6522C38.5408 17.0853 39.5047 16.8585 40.6387 16.8585C41.6592 16.8585 42.453 17.0286 43.0767 17.4254V17.0286C43.0767 16.4616 42.8499 15.8946 42.3963 15.5544C41.9427 15.1575 41.3757 14.9307 40.7521 14.9307C39.7882 14.9307 39.0511 15.3276 38.5408 16.1214L37.0666 15.2142C37.9738 14.0235 39.1645 13.4565 40.7521 13.4565ZM38.5975 19.9202C38.5975 20.3738 38.8243 20.7707 39.1645 20.9975C39.5614 21.281 40.015 21.4511 40.4686 21.4511C41.1489 21.4511 41.8293 21.1676 42.3396 20.6573C42.9066 20.147 43.1901 19.5233 43.1901 18.8429C42.6798 18.446 41.9427 18.2192 40.9788 18.2192C40.2985 18.2192 39.7315 18.3893 39.2779 18.7295C38.8243 19.013 38.5975 19.4099 38.5975 19.9202Z'], ['#000000', 'M53.9613 13.7397L48.3481 26.6103H46.6471L48.745 22.1311L45.0596 13.7964H46.8739L49.5388 20.2034H49.5955L52.2036 13.7964H53.9613V13.7397Z'], ['#4285F4', 'M23.2906 16.2345C23.2906 15.7242 23.2339 15.2139 23.1772 14.7036H16.0898V17.5952H20.1154C19.9454 18.5024 19.4351 19.3529 18.6413 19.8632V21.7342H21.0793C22.4968 20.4302 23.2906 18.5024 23.2906 16.2345Z'], ['#34A853', 'M16.0879 23.5487C18.1291 23.5487 19.83 22.8683 21.0774 21.7343L18.6393 19.8633C17.959 20.3169 17.1085 20.6004 16.0879 20.6004C14.1601 20.6004 12.4592 19.2963 11.8922 17.4819H9.39746V19.4097C10.7015 21.9611 13.253 23.5487 16.0879 23.5487Z'], ['#FABB05', 'M11.8906 17.4815C11.5504 16.5743 11.5504 15.5537 11.8906 14.5899V12.6621H9.39585C8.31857 14.76 8.31857 17.2547 9.39585 19.4092L11.8906 17.4815Z'], ['#E94235', 'M16.0888 11.5285C17.1661 11.5285 18.1867 11.9254 18.9804 12.6625L21.135 10.508C19.7742 9.26058 17.9599 8.5235 16.1455 8.5802C13.3106 8.5802 10.7024 10.1678 9.45508 12.7192L11.9498 14.647C12.4601 12.8326 14.1611 11.5285 16.0888 11.5285Z']]
  }];
  const GLYPH = {
    check: 'M9,16.17 L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z',
    alert: 'M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z',
    close: 'M19,6.41 L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z',
    back: 'M15.41,7.41 L14,6 8,12 14,18 15.41,16.59 10.83,12z',
    location: 'M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z'
  };
  const UI_ICON = {
    play: 'M8 5v14l11-7z',
    pause: 'M6 5h4v14H6zM14 5h4v14h-4z'
  };
  const normalizeHex = value => {
    const m = (/^#?([0-9a-f]{6})$/i).exec((value || '').trim());
    return m ? '#' + m[1].toUpperCase() : null;
  };
  const rgb = hex => {
    const n = parseInt(hex.slice(1), 16);
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  };
  const toHex = (r, g, b) => '#' + [r, g, b].map(c => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('').toUpperCase();
  const lighten = (hex, f) => toHex(...rgb(hex).map(c => c + Math.trunc((255 - c) * f)));
  const withAlpha = (hex, a) => `rgba(${rgb(hex).join(', ')}, ${a})`;
  const srgb = c => {
    const v = c / 255;
    return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  };
  const relLuminance = hex => {
    const [r, g, b] = rgb(hex);
    return 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
  };
  const contrastRatio = (a, b) => {
    const la = relLuminance(a);
    const lb = relLuminance(b);
    return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
  };
  const themeVars = config => {
    const full = config.backgroundType === 'FULL';
    const headerText = full ? config.headerTextColor : INK;
    return {
      '--rtp-primary': config.primaryColor,
      '--rtp-on-primary': config.headerTextColor,
      '--rtp-icon': config.iconColor,
      '--rtp-header-start': full ? config.primaryColor : withAlpha(config.primaryColor, 0.14),
      '--rtp-header-end': full ? config.primaryColor : withAlpha(config.primaryColor, 0),
      '--rtp-header-text': headerText,
      '--rtp-header-radius': full ? '36px' : '0px',
      '--rtp-header-pad-bottom': full ? '28px' : '12px',
      '--rtp-accent-soft-sdk': lighten(config.primaryColor, 0.82),
      '--rtp-close-border': withAlpha(headerText, 0.45),
      '--rtp-status-ink': headerText,
      '--rtp-glow': withAlpha(config.primaryColor, 0.35),
      '--rtp-glow-strong': withAlpha(config.primaryColor, 0.8),
      '--rtp-halo': withAlpha(config.primaryColor, 0.2)
    };
  };
  const fmt = (template, ...args) => template.replace(/%(\d)\$[ds]/g, (_, i) => String(args[Number(i) - 1]));
  const digits = (cents, locale) => new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'pt-BR', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2
  }).format(cents / 100);
  const money = (cents, locale) => `R$ ${digits(cents, locale)}`;
  const descriptorFor = (request, locale) => {
    const t = COPY[locale];
    const method = request.paymentMethod === 'DEBIT' ? t.rinne_method_debit : request.installments > 1 ? fmt(t.rinne_method_credit_installments, request.installments, money(Math.floor(request.amountCents / request.installments), locale)) : t.rinne_method_credit;
    return request.type === 'REFUND' ? fmt(t.rinne_descriptor_refund, method) : method;
  };
  const isTerminal = step => step.kind === 'approved' || step.kind === 'declined' || step.kind === 'failed' || step.kind === 'cancelled';
  const offersRetry = step => {
    if (step.kind === 'cancelled') return true;
    if (step.kind === 'declined' || step.kind === 'failed') return !!RETRIABLE[step.code];
    return false;
  };
  const screenCopy = (step, config, request, locale) => {
    const t = COPY[locale];
    const refund = request.type === 'REFUND';
    const pick = key => refund ? t[key + '_refund'] : t[key];
    switch (step.kind) {
      case 'preparing':
        return {
          title: t.rinne_preparing_title,
          subtitle: t.rinne_preparing_subtitle
        };
      case 'present':
        {
          const hint = HINTS.find(h => h.id === step.hint);
          return {
            title: config.tapTitle || pick('rinne_tap_title'),
            subtitle: hint ? t[hint.res] : config.tapSubtitle || t.rinne_tap_subtitle
          };
        }
      case 'processing':
        return {
          title: pick('rinne_processing_title'),
          subtitle: step.phase === 'CARD_READ' ? t.rinne_processing_card_read : step.phase === 'WORKING' ? t.rinne_processing_working : t.rinne_processing_authorising
        };
      case 'pin':
        return {
          title: t.rinne_pin_title,
          subtitle: step.retry ? t.rinne_pin_retry_subtitle : t.rinne_pin_subtitle
        };
      case 'approved':
        return {
          title: pick('rinne_approved_title'),
          subtitle: step.cardScheme || t.rinne_approved_subtitle
        };
      case 'declined':
        return {
          title: pick('rinne_declined_title'),
          subtitle: t[ERROR_RES[step.code]]
        };
      case 'failed':
        if (step.code === 'LOCATION_UNAVAILABLE') return {
          title: t.rinne_location_title,
          subtitle: t.rinne_location_subtitle
        };
        return {
          title: pick('rinne_failed_title'),
          subtitle: t[ERROR_RES[step.code]]
        };
      case 'cancelled':
        return {
          title: pick('rinne_cancelled_title'),
          subtitle: t.rinne_error_cancelled
        };
      default:
        return {
          title: '',
          subtitle: ''
        };
    }
  };
  const makePad = pinPadType => {
    const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
    if (pinPadType === 'SHUFFLED') {
      for (let i = keys.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [keys[i], keys[j]] = [keys[j], keys[i]];
      }
    }
    return {
      keys,
      col: Math.floor(Math.random() * 3),
      row: Math.floor(Math.random() * 3)
    };
  };
  const uuid = () => {
    if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
    return ('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx').replace(/[xy]/g, c => {
      const r = Math.random() * 16 | 0;
      return (c === 'x' ? r : r & 0x3 | 0x8).toString(16);
    });
  };
  const kColor = hex => `0xFF${hex.slice(1).toUpperCase()}.toInt()`;
  const kStr = s => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
  const kotlinFor = (config, request) => {
    const refund = request.type === 'REFUND';
    const cfg = [];
    if (config.primaryColor !== DEFAULT_CONFIG.primaryColor) cfg.push(`primaryColor = ${kColor(config.primaryColor)}`);
    if (config.headerTextColor !== DEFAULT_CONFIG.headerTextColor) cfg.push(`headerTextColor = ${kColor(config.headerTextColor)}`);
    if (config.iconColor !== DEFAULT_CONFIG.iconColor) cfg.push(`iconColor = ${kColor(config.iconColor)}`);
    if (config.backgroundType !== 'FULL') cfg.push(`backgroundType = BackgroundType.${config.backgroundType}`);
    if (config.merchantName) cfg.push(`merchantName = ${kStr(config.merchantName)}`);
    if (config.tapTitle) cfg.push(`tapTitle = ${kStr(config.tapTitle)}`);
    if (config.tapSubtitle) cfg.push(`tapSubtitle = ${kStr(config.tapSubtitle)}`);
    if (config.pinPadType !== 'SHIFTED') cfg.push(`pinPadType = PinPadType.${config.pinPadType}`);
    const req = [`amountCents = ${request.amountCents}`];
    if (refund) req.push('type = TransactionType.REFUND');
    req.push(`paymentMethod = PaymentMethod.${request.paymentMethod}`);
    if (request.installments > 1) req.push(`installments = ${request.installments}`);
    if (refund) req.push('originalRequestId = sale.requestId'); else req.push('requestId = order.id');
    const imports = ['com.rinne.sdk.CheckoutConfig', 'com.rinne.sdk.PaymentMethod', 'com.rinne.sdk.PaymentResult', 'com.rinne.sdk.checkout.Checkout', 'com.rinne.sdk.checkout.CheckoutRequest'];
    if (config.backgroundType !== 'FULL') imports.push('com.rinne.sdk.BackgroundType');
    if (config.pinPadType !== 'SHIFTED') imports.push('com.rinne.sdk.PinPadType');
    if (refund) imports.push('com.rinne.sdk.TransactionType');
    imports.sort();
    const block = (name, type, lines) => `val ${name} = ${type}(${lines.length ? '\n    ' + lines.join(',\n    ') + ',\n' : ''})`;
    return imports.map(i => `import ${i}`).join('\n') + '\n\n// Passed to Payments.getReady once. The terminal bakes the branding in.\n' + block('config', 'CheckoutConfig', cfg) + '\n\n// One request per sale.\n' + block('request', 'CheckoutRequest', req) + '\n\nprivate val checkout = registerForActivityResult(Checkout.Contract()) { result ->\n' + '    when (result) {\n' + '        is PaymentResult.Approved -> showReceipt(result.transactionId)\n' + '        is PaymentResult.Declined -> offerAnotherMethod(result.code)\n' + '        is PaymentResult.Failed -> showError(result.code, result.message)\n' + '        is PaymentResult.Cancelled -> Unit\n' + '    }\n' + '}\n\n' + 'checkout.launch(request)';
  };
  const xmlEscape = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, "\\'");
  const stringsXmlFor = (config, locale) => {
    const folder = locale === 'en' ? 'values' : 'values-pt-rBR';
    const lines = [];
    if (config.tapTitle) lines.push(`    <string name="rinne_tap_title">${xmlEscape(config.tapTitle)}</string>`);
    if (config.tapSubtitle) lines.push(`    <string name="rinne_tap_subtitle">${xmlEscape(config.tapSubtitle)}</string>`);
    return `<!-- res/${folder}/strings.xml -->\n` + '<!-- The same two lines as string resources, per locale. CheckoutConfig.tapTitle\n' + '     and tapSubtitle win over these when both are set. -->\n' + '<resources>\n' + lines.join('\n') + '\n</resources>';
  };
  const deliveredFor = step => {
    const id = uuid();
    switch (step.kind) {
      case 'approved':
        return `PaymentResult.Approved(\n    transactionId = "${id}",\n    cardScheme = "${step.cardScheme || 'VISA'}",\n)`;
      case 'declined':
        return `PaymentResult.Declined(\n    transactionId = "${id}",\n    code = ErrorCode.${step.code},\n)`;
      case 'failed':
        return `PaymentResult.Failed(\n    code = ErrorCode.${step.code},\n    message = null,\n)`;
      default:
        return 'PaymentResult.Cancelled(\n    code = ErrorCode.CANCELLED,\n)';
    }
  };
  const KOTLIN_KEYWORDS = /^(import|val|var|private|fun|when|is|Unit|null|true|false|return|class|object)$/;
  const tokenize = (code, lang) => {
    const out = [];
    const re = lang === 'xml' ? /(<!--[\s\S]*?-->)|("[^"]*")|(<\/?[\w-]+|\/?>)|([\w-]+=)|([^<>"=]+)/g : /(\/\/[^\n]*)|("(?:[^"\\]|\\.)*")|(0x[0-9A-Fa-f]+|\b\d+\b)|(\b[A-Za-z_][\w.]*\b)|([^\w"]+)/g;
    let m;
    let i = 0;
    while ((m = re.exec(code)) !== null) {
      const text = m[0];
      let cls = '';
      if (lang === 'xml') {
        if (m[1]) cls = 'c'; else if (m[2]) cls = 's'; else if (m[3]) cls = 'k'; else if (m[4]) cls = 't';
      } else if (m[1]) cls = 'c'; else if (m[2]) cls = 's'; else if (m[3]) cls = 'n'; else if (m[4]) {
        const word = text.split('.')[0];
        if (KOTLIN_KEYWORDS.test(text)) cls = 'k'; else if ((/^[A-Z]/).test(word)) cls = 't';
      }
      out.push(cls ? <span key={i++} className={cls}>{text}</span> : text);
    }
    return out;
  };
  const encodeState = state => {
    const json = JSON.stringify({
      c: state.config,
      r: state.request,
      l: state.locale,
      s: state.scenario
    });
    const b64 = btoa(unescape(encodeURIComponent(json)));
    return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  };
  const decodeState = hash => {
    try {
      const m = (/[#&]sim=([A-Za-z0-9_-]+)/).exec(hash || '');
      if (!m) return null;
      const b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
      const json = decodeURIComponent(escape(atob(b64)));
      const data = JSON.parse(json);
      const config = {
        ...DEFAULT_CONFIG,
        ...data.c || ({})
      };
      ['primaryColor', 'headerTextColor', 'iconColor'].forEach(k => {
        config[k] = normalizeHex(config[k]) || DEFAULT_CONFIG[k];
      });
      if (config.backgroundType !== 'TRANSPARENT') config.backgroundType = 'FULL';
      if (config.pinPadType !== 'SHUFFLED') config.pinPadType = 'SHIFTED';
      config.merchantName = String(config.merchantName || '').slice(0, 40);
      config.tapTitle = String(config.tapTitle || '').slice(0, 60);
      config.tapSubtitle = String(config.tapSubtitle || '').slice(0, 120);
      const request = {
        ...DEFAULT_REQUEST,
        ...data.r || ({})
      };
      request.amountCents = Math.max(1, Math.min(99999999, parseInt(request.amountCents, 10) || DEFAULT_REQUEST.amountCents));
      if (request.type !== 'REFUND') request.type = 'PURCHASE';
      if (request.paymentMethod !== 'DEBIT') request.paymentMethod = 'CREDIT';
      request.installments = [1, 2, 3, 6, 12].includes(request.installments) ? request.installments : 1;
      const scenario = {
        ...DEFAULT_SCENARIO,
        ...data.s || ({})
      };
      if (!['APPROVED', 'DECLINED', 'FAILED'].includes(scenario.outcome)) scenario.outcome = 'APPROVED';
      if (!FAILURE_CODES.includes(scenario.failureCode)) scenario.failureCode = 'NETWORK';
      if (!HINTS.some(h => h.id === scenario.hint)) scenario.hint = '';
      return {
        config,
        request,
        locale: data.l === 'en' ? 'en' : 'pt-BR',
        scenario
      };
    } catch (e) {
      return null;
    }
  };
  const initialFlow = () => ({
    step: {
      kind: 'idle'
    },
    mode: 'idle',
    pad: null,
    pin: '',
    pinAttempt: 0,
    locationGranted: false,
    delivered: null,
    session: 0,
    glide: false
  });
  const initialState = () => ({
    config: {
      ...DEFAULT_CONFIG
    },
    request: {
      ...DEFAULT_REQUEST
    },
    locale: 'pt-BR',
    scenario: {
      ...DEFAULT_SCENARIO
    },
    flow: initialFlow(),
    ui: {
      section: 'brand',
      codeTab: 'kotlin',
      dirty: false,
      copied: '',
      drafts: {}
    }
  });
  const reducer = (state, action) => {
    const {flow} = state;
    switch (action.type) {
      case 'SET_CONFIG':
        {
          const config = {
            ...state.config,
            ...action.patch
          };
          const next = {
            ...state,
            config,
            ui: {
              ...state.ui,
              dirty: true
            }
          };
          if (flow.step.kind === 'pin' && !flow.step.confirming && config.pinPadType !== state.config.pinPadType) {
            next.flow = {
              ...flow,
              pad: makePad(config.pinPadType),
              pin: ''
            };
          }
          return next;
        }
      case 'APPLY_PRESET':
        {
          const p = PRESETS.find(x => x.id === action.id);
          if (!p) return state;
          return {
            ...state,
            config: {
              ...state.config,
              primaryColor: p.primaryColor,
              headerTextColor: p.headerTextColor,
              iconColor: p.iconColor,
              backgroundType: p.backgroundType
            },
            ui: {
              ...state.ui,
              dirty: true,
              drafts: {}
            }
          };
        }
      case 'SET_REQUEST':
        {
          const request = {
            ...state.request,
            ...action.patch
          };
          if (request.type === 'REFUND' || request.paymentMethod === 'DEBIT') request.installments = 1;
          if (request.installments > 1) request.paymentMethod = 'CREDIT';
          return {
            ...state,
            request,
            ui: {
              ...state.ui,
              dirty: true
            }
          };
        }
      case 'SET_LOCALE':
        return {
          ...state,
          locale: action.locale,
          ui: {
            ...state.ui,
            dirty: true
          }
        };
      case 'SET_SCENARIO':
        return {
          ...state,
          scenario: {
            ...state.scenario,
            ...action.patch
          },
          ui: {
            ...state.ui,
            dirty: true
          }
        };
      case 'UI':
        return {
          ...state,
          ui: {
            ...state.ui,
            ...action.patch
          }
        };
      case 'DRAFT':
        return {
          ...state,
          ui: {
            ...state.ui,
            drafts: {
              ...state.ui.drafts,
              [action.id]: action.value
            }
          }
        };
      case 'HYDRATE':
        return {
          ...state,
          ...action.partial
        };
      case 'LAUNCH':
        if (!(flow.step.kind === 'idle' || isTerminal(flow.step))) return state;
        return {
          ...state,
          flow: {
            ...initialFlow(),
            step: {
              kind: 'preparing'
            },
            mode: action.mode || 'manual',
            session: flow.session + 1,
            locationGranted: flow.locationGranted
          }
        };
      case 'PLAY':
        return {
          ...state,
          flow: {
            ...initialFlow(),
            step: {
              kind: 'preparing'
            },
            mode: 'playing',
            session: flow.session + 1
          }
        };
      case 'PAUSE':
        return {
          ...state,
          flow: {
            ...flow,
            mode: 'manual',
            glide: false
          }
        };
      case 'STAGE':
        {
          if (isTerminal(flow.step)) return state;
          const next = {
            ...flow,
            step: action.step,
            glide: false
          };
          if (action.step.kind === 'pin') {
            next.pad = action.pad || flow.pad || makePad(state.config.pinPadType);
            next.pin = '';
          }
          if (isTerminal(action.step) && flow.mode === 'playing' && !action.keepPlaying) next.mode = 'manual';
          return {
            ...state,
            flow: next
          };
        }
      case 'GLIDE':
        return {
          ...state,
          flow: {
            ...flow,
            glide: action.on
          }
        };
      case 'TAP_CARD':
        {
          if (flow.step.kind !== 'present') return state;
          const sc = action.scenario;
          const timesOut = sc.outcome === 'FAILED' && sc.failureCode === 'CARD_READ_TIMEOUT';
          return {
            ...state,
            flow: {
              ...flow,
              glide: false,
              step: timesOut ? {
                kind: 'present',
                hint: 'AGAIN',
                failing: true
              } : {
                kind: 'processing',
                phase: 'CARD_READ'
              }
            }
          };
        }
      case 'PIN_KEY':
        if (flow.step.kind !== 'pin' || flow.step.confirming || flow.pin.length >= 4) return state;
        return {
          ...state,
          flow: {
            ...flow,
            pin: flow.pin + action.digit
          }
        };
      case 'PIN_CLEAR':
        if (flow.step.kind !== 'pin') return state;
        return {
          ...state,
          flow: {
            ...flow,
            pin: ''
          }
        };
      case 'PIN_CONFIRM':
        if (flow.step.kind !== 'pin' || flow.step.confirming || flow.pin.length !== 4) return state;
        return {
          ...state,
          flow: {
            ...flow,
            pinAttempt: flow.pinAttempt + 1,
            step: {
              kind: 'pin',
              retry: !!flow.step.retry,
              confirming: true
            }
          }
        };
      case 'PIN_CANCEL':
        if (flow.step.kind !== 'pin') return state;
        return {
          ...state,
          flow: {
            ...flow,
            pin: '',
            mode: flow.mode === 'playing' ? 'manual' : flow.mode,
            step: {
              kind: 'cancelled',
              code: 'CANCELLED'
            }
          }
        };
      case 'CANCEL':
        if (flow.step.kind !== 'preparing' && flow.step.kind !== 'present') return state;
        return {
          ...state,
          flow: {
            ...flow,
            glide: false,
            mode: flow.mode === 'playing' ? 'manual' : flow.mode,
            step: {
              kind: 'cancelled',
              code: 'CANCELLED'
            }
          }
        };
      case 'RETRY':
        if (!offersRetry(flow.step)) return state;
        return {
          ...state,
          flow: {
            ...flow,
            pin: '',
            pinAttempt: 0,
            step: {
              kind: 'present',
              hint: null
            }
          }
        };
      case 'GRANT_LOCATION':
        if (flow.step.kind !== 'failed' || flow.step.code !== 'LOCATION_UNAVAILABLE') return state;
        return {
          ...state,
          flow: {
            ...flow,
            locationGranted: true,
            step: {
              kind: 'preparing'
            }
          }
        };
      case 'DONE':
        if (!isTerminal(flow.step)) return state;
        return {
          ...state,
          flow: {
            ...flow,
            delivered: action.delivered,
            step: {
              kind: 'idle'
            },
            mode: action.loop ? 'playing' : 'idle',
            glide: false
          }
        };
      case 'JUMP':
        return {
          ...state,
          flow: {
            ...flow,
            step: action.step,
            pad: action.pad || flow.pad,
            pin: '',
            pinAttempt: 0,
            mode: 'manual',
            glide: false,
            session: flow.session + 1
          }
        };
      case 'RESET':
        return {
          ...state,
          flow: initialFlow()
        };
      default:
        return state;
    }
  };
  const jumpTarget = (screen, scenario, config) => {
    switch (screen) {
      case 'preparing':
        return {
          step: {
            kind: 'preparing',
            held: true
          }
        };
      case 'present':
        return {
          step: {
            kind: 'present',
            hint: scenario.hint || null,
            held: true
          }
        };
      case 'processing':
        return {
          step: {
            kind: 'processing',
            phase: 'AUTHORISING',
            held: true
          }
        };
      case 'pin':
        return {
          step: {
            kind: 'pin',
            retry: false,
            held: true
          },
          pad: makePad(config.pinPadType)
        };
      case 'approved':
        return {
          step: {
            kind: 'approved',
            cardScheme: 'VISA'
          }
        };
      case 'declined':
        return {
          step: {
            kind: 'declined',
            code: 'DECLINED_BY_ISSUER'
          }
        };
      case 'failed':
        return {
          step: {
            kind: 'failed',
            code: scenario.outcome === 'FAILED' ? scenario.failureCode : 'NETWORK'
          }
        };
      default:
        return {
          step: {
            kind: 'cancelled',
            code: 'CANCELLED'
          }
        };
    }
  };
  const glyph = (d, size, color) => <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
      <path d={d} fill={color} />
    </svg>;
  const uiIcon = name => {
    switch (name) {
      case 'play':
      case 'pause':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <path d={UI_ICON[name]} fill="currentColor" />
          </svg>;
      case 'chevron-left':
      case 'chevron-right':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <path d={name === 'chevron-left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
          </svg>;
      case 'card':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <rect x="2" y="5" width="20" height="14" rx="3" fill="none" stroke="currentColor" strokeWidth="2" />
            <path d="M2 10h20" stroke="currentColor" strokeWidth="2" />
          </svg>;
      case 'reset':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <path d="M12 5a7 7 0 1 1-6.3 4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
            <path d="M5 3v6h6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          </svg>;
      case 'expand':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          </svg>;
      case 'check':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <path d="M5 12l5 5L20 7" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
          </svg>;
      case 'copy':
        return <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
            <rect x="9" y="9" width="11" height="11" rx="2" fill="none" stroke="currentColor" strokeWidth="2" />
            <path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" fill="none" stroke="currentColor" strokeWidth="2" />
          </svg>;
      default:
        return null;
    }
  };
  const statusBar = color => <div className="rtp-status" aria-hidden="true" style={{
    color
  }}>
      <span>14:30</span>
      <span className="rtp-status-icons">
        <svg width="17" height="12" viewBox="0 0 17 12">
          <rect x="0" y="7" width="3" height="5" rx="1" fill={color} />
          <rect x="4.5" y="5" width="3" height="7" rx="1" fill={color} />
          <rect x="9" y="2.5" width="3" height="9.5" rx="1" fill={color} />
          <rect x="13.5" y="0" width="3" height="12" rx="1" fill={color} />
        </svg>
        <svg width="16" height="12" viewBox="0 0 16 12">
          <path d="M8 11.2 1 4.2a9.9 9.9 0 0 1 14 0L8 11.2Z" fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
        </svg>
        <svg width="25" height="12" viewBox="0 0 25 12">
          <rect x="0.65" y="0.65" width="21.7" height="10.7" rx="3" fill="none" stroke={color} strokeWidth="1.3" />
          <rect x="2.5" y="2.5" width="14" height="7" rx="1.5" fill={color} />
          <rect x="23.4" y="4" width="1.6" height="4" rx="0.8" fill={color} />
        </svg>
      </span>
    </div>;
  const navBar = color => <div className="rtp-navbar" aria-hidden="true">
      <span style={{
    background: color
  }} />
    </div>;
  const ring = (primary, soft) => {
    const r = 37 - 2.66;
    const c = 2 * Math.PI * r;
    return <svg className="rtp-ring" viewBox="0 0 74 74" aria-hidden="true">
        <circle cx="37" cy="37" r={r} fill="none" stroke={soft} strokeWidth="5.33" />
        <circle cx="37" cy="37" r={r} fill="none" stroke={primary} strokeWidth="5.33" strokeLinecap="round" strokeDasharray={`${c * 129 / 360} ${c}`} transform="rotate(-90 37 37)" />
      </svg>;
  };
  const badge = (color, d, label) => <div className="rtp-badge" role="img" aria-label={label}>
      <div className="rtp-badge-glow" style={{
    background: `radial-gradient(circle, ${withAlpha(color, 0.55)} 0%, ${withAlpha(color, 0)} 70%)`
  }} />
      <div className="rtp-badge-disc" style={{
    background: color
  }}>{glyph(d, 52, '#FFFFFF')}</div>
    </div>;
  const marks = label => <div className="rtp-marks" role="img" aria-label={label}>
      <div className="rtp-marks-row">
        {MARKS.map(m => <svg key={m.name} viewBox={`0 0 ${m.w} ${m.h}`} aria-hidden="true">
            {m.paths.map(([fill, d], i) => <path key={i} d={d} fill={fill} fillRule="evenodd" />)}
          </svg>)}
      </div>
      <div className="rtp-marks-label">{label}</div>
    </div>;
  const cardArt = () => <svg viewBox="0 0 156 98" aria-hidden="true">
      <defs>
        <linearGradient id="rtpCardGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#2B2540" />
          <stop offset="1" stopColor="#15121F" />
        </linearGradient>
      </defs>
      <rect width="156" height="98" rx="12" fill="url(#rtpCardGrad)" />
      <rect x="14" y="30" width="30" height="22" rx="4" fill="#E4C36B" />
      <path d="M14 41h30M29 30v22" stroke="#B89A4A" strokeWidth="1.2" />
      <g fill="none" stroke="#FFFFFF" strokeWidth="2.4" strokeLinecap="round" opacity="0.9">
        <path d="M116 33a13 13 0 0 1 0 18" />
        <path d="M123 27a21 21 0 0 1 0 30" />
        <path d="M130 21a29 29 0 0 1 0 42" />
      </g>
      <rect x="14" y="70" width="60" height="6" rx="3" fill="#FFFFFF" opacity="0.35" />
      <rect x="14" y="82" width="36" height="5" rx="2.5" fill="#FFFFFF" opacity="0.2" />
    </svg>;
  const hostScreen = (request, locale, onLaunch) => {
    const refund = request.type === 'REFUND';
    const cta = 'checkout.launch(request)';
    return <div className="rtp-host">
        {statusBar(INK)}
        <div className="rtp-host-top">
          <span className="rtp-host-badge">Your app</span>
          <span className="rtp-host-sub">Not part of the SDK</span>
        </div>
        <div className="rtp-host-body">
          <div className="rtp-wire" style={{
      width: '58%'
    }} />
          <div className="rtp-wire" style={{
      width: '82%'
    }} />
          <div className="rtp-wire tall" />
          <div className="rtp-wire tall" />
          <div className="rtp-wire" style={{
      width: '40%'
    }} />
        </div>
        <div className="rtp-host-total">
          <div className="rtp-host-total-row">
            <span>
              <b>{money(request.amountCents, locale)}</b>
              <small>{locale === 'en' ? refund ? 'refund' : 'total' : refund ? 'estorno' : 'total'}</small>
            </span>
          </div>
          <button type="button" className="rtp-host-cta" onClick={onLaunch}>
            {cta}
          </button>
        </div>
        {navBar(INK)}
      </div>;
  };
  const checkoutScreen = (step, config, request, locale, copy, actions) => {
    const t = COPY[locale];
    const canCancel = step.kind === 'preparing' || step.kind === 'present';
    const outcome = isTerminal(step);
    const locationDenied = step.kind === 'failed' && step.code === 'LOCATION_UNAVAILABLE';
    const showsMarks = step.kind === 'present' || step.kind === 'processing';
    const full = config.backgroundType === 'FULL';
    const headerText = full ? config.headerTextColor : INK;
    const accentSoft = lighten(config.primaryColor, 0.82);
    let slot;
    if (step.kind === 'present') slot = <div className="rtp-art" role="img" aria-label={t.rinne_a11y_contactless_symbol} />; else if (step.kind === 'approved') slot = badge(SUCCESS, GLYPH.check, t.rinne_a11y_approved); else if (outcome) slot = badge(DANGER, locationDenied ? GLYPH.location : GLYPH.alert, step.kind === 'declined' ? t.rinne_a11y_declined : t.rinne_a11y_failed); else slot = ring(config.primaryColor, accentSoft);
    let footer = null;
    if (showsMarks) footer = marks(t.rinne_marks_label); else if (step.kind === 'approved') {
      footer = <button type="button" className="rtp-btn" onClick={actions.done}>
          {t.rinne_action_done}
        </button>;
    } else if (outcome) {
      footer = <>
          {locationDenied ? <button type="button" className="rtp-btn" onClick={actions.grantLocation}>
              {t.rinne_location_grant}
            </button> : offersRetry(step) ? <button type="button" className="rtp-btn" onClick={actions.retry}>
              {t.rinne_action_retry}
            </button> : null}
          <button type="button" className="rtp-btn secondary" onClick={actions.done}>
            {t.rinne_action_done}
          </button>
        </>;
    }
    return <>
        <div className="rtp-header">
          {statusBar(headerText)}
          <div className="rtp-header-body">
            <div className="rtp-merchant">
              {canCancel && <button type="button" className="rtp-close" aria-label={t.rinne_action_cancel} onClick={actions.cancel}>
                  {glyph(GLYPH.close, 16, headerText)}
                </button>}
              {config.merchantName}
            </div>
            <div className="rtp-amount rtp-display">
              <span className="rtp-currency">R$</span>
              <span className="rtp-figure">{digits(request.amountCents, locale)}</span>
            </div>
            <div className="rtp-descriptor">{descriptorFor(request, locale)}</div>
          </div>
        </div>
        <div className="rtp-body">
          <div className="rtp-icon-slot">{slot}</div>
          <div className="rtp-title rtp-display">{copy.title}</div>
          {copy.subtitle ? <div className="rtp-subtitle">{copy.subtitle}</div> : null}
        </div>
        <div className="rtp-footer">{footer}</div>
        {step.kind === 'present' && !step.failing && <button type="button" className="rtp-tapzone" aria-label="Tap a card on the phone" onClick={actions.tap} />}
        {navBar(INK)}
      </>;
  };
  const pinPad = (config, request, locale, pad, pin, actions) => {
    const t = COPY[locale];
    const action = (kind, label, onClick, extra) => <button key={kind} type="button" className={`rtp-pad-action ${extra || ''}`} onClick={onClick}>
        <span className={`rtp-pad-action-icon ${kind}`}>{glyph(kind === 'cancel' ? GLYPH.close : kind === 'clear' ? GLYPH.back : GLYPH.check, 14, '#FFFFFF')}</span>
        <span>{label}</span>
      </button>;
    const key = (k, extraKey) => <button key={extraKey || k} type="button" className="rtp-pad-cell" onClick={() => actions.key(k)}>
        {k}
      </button>;
    let grid;
    if (config.pinPadType === 'SHUFFLED') {
      grid = <div className="rtp-pad-grid three">
          {pad.keys.slice(0, 9).map(k => key(k))}
          {action('cancel', t.rinne_pinpad_cancel, actions.cancel, 'tall')}
          {key(pad.keys[9])}
          {action('confirm', t.rinne_pinpad_enter, actions.confirm, 'tall')}
          {action('clear', t.rinne_pinpad_clear, actions.clear)}
        </div>;
    } else {
      const block = {
        1: [0, 0],
        2: [1, 0],
        3: [2, 0],
        4: [0, 1],
        5: [1, 1],
        6: [2, 1],
        7: [0, 2],
        8: [1, 2],
        9: [2, 2],
        0: [1, 3]
      };
      const at = {};
      Object.entries(block).forEach(([k, [c, r]]) => {
        at[`${c + pad.col},${r + pad.row}`] = k;
      });
      const cells = [];
      for (let r = 0; r < 6; r++) {
        for (let c = 0; c < 5; c++) {
          const k = at[`${c},${r}`];
          cells.push(k ? key(k, `${c},${r}`) : <div key={`${c},${r}`} className="rtp-pad-cell blank" />);
        }
      }
      grid = <>
          <div className="rtp-pad-grid five">{cells}</div>
          <div className="rtp-pad-bar">
            {action('cancel', t.rinne_pinpad_cancel, actions.cancel)}
            <span className="rtp-pad-divider" />
            {action('clear', t.rinne_pinpad_clear, actions.clear)}
            <span className="rtp-pad-divider" />
            {action('confirm', t.rinne_pinpad_enter, actions.confirm)}
          </div>
        </>;
    }
    return <div className="rtp-pad" role="dialog" aria-label={t.rinne_pinpad_title}>
        <div className="rtp-pad-status" />
        <div className="rtp-pad-nav">{t.rinne_pinpad_title}</div>
        <div className="rtp-pad-amount">
          <div>{money(request.amountCents, locale)}</div>
          {request.type === 'REFUND' && <div className="rtp-pad-details">{t.rinne_pinpad_refund}</div>}
          <div className="rtp-dots" aria-label={`${pin.length} of 4 digits`}>
            {[0, 1, 2, 3].map(i => <span key={i} className={`rtp-dot ${i < pin.length ? 'on' : ''}`} />)}
          </div>
        </div>
        {grid}
      </div>;
  };
  const device = (state, scale, actions, copy) => {
    const {config, request, locale, flow} = state;
    const style = {
      ...themeVars(config),
      '--rtp-scale': scale
    };
    return <div className="rtp-device" style={style}>
        <div className="rtp-glass">
          <div className="rtp-screen">
            {flow.step.kind === 'idle' ? hostScreen(request, locale, actions.launch) : checkoutScreen(flow.step, config, request, locale, copy, actions)}
            {flow.step.kind === 'pin' && flow.pad && pinPad(config, request, locale, flow.pad, flow.pin, actions.pad)}
          </div>
        </div>
        <div className="rtp-hole" aria-hidden="true" />
        <div className={`rtp-card ${flow.glide ? 'glide' : ''}`} aria-hidden="true">
          {cardArt()}
        </div>
      </div>;
  };
  const chip = ({key, on, onClick, children, swatch, disabled, role, title}) => <button key={key} type="button" className="rtp-chip" role={role} aria-checked={role === 'radio' ? !!on : undefined} aria-pressed={role ? undefined : !!on} disabled={disabled} title={title} onClick={onClick}>
      {swatch && <span className={`rtp-swatch ${swatch === 'none' ? 'empty' : ''}`} style={swatch === 'none' ? undefined : {
    background: swatch
  }} />}
      {children}
    </button>;
  const field = ({id, label, code, children, note}) => <div className="rtp-field">
      <label className="rtp-label" htmlFor={id}>
        <span>{label}</span>
        {code && <code>{code}</code>}
      </label>
      {children}
      {note && <div className="rtp-note">{note}</div>}
    </div>;
  const colorField = ({id, label, code, value, draft, onDraft, onChange, note}) => field({
    id,
    label,
    code,
    note,
    children: <div className="rtp-input-row">
          <span className="rtp-color-swatch" style={{
      background: value
    }}>
            <input id={id} type="color" value={value} aria-label={`${label} colour picker`} onChange={e => onChange(e.target.value.toUpperCase())} />
          </span>
          <input className="rtp-input mono" aria-label={`${label} hex value`} value={draft === undefined || draft === null ? value : draft} maxLength={7} spellCheck={false} onChange={e => {
      const v = e.target.value;
      onDraft(v);
      const hex = normalizeHex(v);
      if (hex) onChange(hex);
    }} onBlur={() => onDraft(null)} />
        </div>
  });
  const switchRow = ({id, checked, onChange, children}) => <label key={id} className="rtp-switch" htmlFor={id}>
      <input id={id} type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
      <span className="rtp-track" aria-hidden="true" />
      <span>{children}</span>
    </label>;
  const copyToClipboard = (text, finish) => {
    if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(finish, finish);
      return;
    }
    if (typeof document === 'undefined') return;
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.setAttribute('readonly', '');
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    try {
      document.execCommand('copy');
    } catch (e) {}
    document.body.removeChild(ta);
    finish();
  };
  const copyButton = ({text, label, done, onDone, className}) => <button type="button" className={`${className || 'rtp-btn-chrome'} ${done ? 'done' : ''}`} onClick={() => copyToClipboard(text, onDone)}>
      {uiIcon(done ? 'check' : 'copy')}
      {done ? 'Copied' : label}
    </button>;
  const useSimulator = variant => {
    const full = variant === 'full';
    const [state, dispatch] = useReducer(reducer, undefined, initialState);
    const {config, request, locale, scenario, flow, ui} = state;
    const rootRef = useRef(null);
    const wrapRef = useRef(null);
    const timers = useRef([]);
    const [size, setSize] = useState({
      root: 900,
      wrap: 382,
      vh: 900
    });
    const [autoplay, setAutoplay] = useState(!full);
    const [visible, setVisible] = useState(false);
    const [hovered, setHovered] = useState(false);
    const later = (ms, fn) => {
      timers.current.push(setTimeout(fn, ms));
    };
    const clearTimers = () => {
      timers.current.forEach(clearTimeout);
      timers.current = [];
    };
    const effectiveScenario = useMemo(() => {
      const rule = SANDBOX_RULES[String(request.amountCents % 100).padStart(2, '0')];
      if (!rule) return scenario;
      return {
        ...scenario,
        outcome: rule.outcome,
        failureCode: rule.code || scenario.failureCode
      };
    }, [scenario, request.amountCents]);
    useEffect(() => {
      if (typeof window === 'undefined') return;
      const partial = decodeState(window.location.hash);
      if (partial) dispatch({
        type: 'HYDRATE',
        partial
      });
      if (typeof document !== 'undefined' && document.fonts && document.fonts.load) {
        document.fonts.load('600 22px Sora').catch(() => {});
        document.fonts.load('400 13px "Space Grotesk"').catch(() => {});
      }
    }, []);
    useEffect(() => {
      if (!full || !ui.dirty || typeof window === 'undefined') return undefined;
      const id = setTimeout(() => {
        try {
          window.history.replaceState(null, '', '#sim=' + encodeState(state));
        } catch (e) {}
      }, 300);
      return () => clearTimeout(id);
    }, [config, request, locale, scenario, ui.dirty, full]);
    useEffect(() => {
      if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') return undefined;
      const update = () => {
        setSize({
          root: rootRef.current ? rootRef.current.clientWidth : 900,
          wrap: wrapRef.current ? wrapRef.current.clientWidth : 382,
          vh: window.innerHeight || 900
        });
      };
      const ro = new ResizeObserver(update);
      if (rootRef.current) ro.observe(rootRef.current);
      if (wrapRef.current) ro.observe(wrapRef.current);
      window.addEventListener('resize', update);
      update();
      return () => {
        ro.disconnect();
        window.removeEventListener('resize', update);
      };
    }, []);
    useEffect(() => {
      if (full || typeof IntersectionObserver === 'undefined' || !rootRef.current) return undefined;
      const io = new IntersectionObserver(entries => setVisible(entries.some(e => e.isIntersecting)), {
        threshold: 0.35
      });
      io.observe(rootRef.current);
      return () => io.disconnect();
    }, [full]);
    useEffect(() => () => clearTimers(), []);
    const playing = flow.mode === 'playing' && (full || autoplay && visible && !hovered);
    useEffect(() => {
      clearTimers();
      const step = flow.step;
      const sc = effectiveScenario;
      const fail = code => dispatch({
        type: 'STAGE',
        step: {
          kind: 'failed',
          code
        }
      });
      if (step.held && step.kind !== 'pin') return clearTimers;
      switch (step.kind) {
        case 'idle':
          if (!full && autoplay && visible && !hovered) later(flow.delivered ? 1500 : 600, () => dispatch({
            type: 'PLAY'
          }));
          break;
        case 'preparing':
          later(1100, () => {
            if (sc.locationDeniedFirst && !flow.locationGranted) return fail('LOCATION_UNAVAILABLE');
            if (sc.outcome === 'FAILED' && SETUP_CODES.includes(sc.failureCode)) return fail(sc.failureCode);
            dispatch({
              type: 'STAGE',
              step: {
                kind: 'present',
                hint: sc.hint || null
              }
            });
          });
          break;
        case 'present':
          if (step.failing) later(1500, () => fail('CARD_READ_TIMEOUT')); else if (playing) {
            later(700, () => dispatch({
              type: 'GLIDE',
              on: true
            }));
            later(1600, () => dispatch({
              type: 'TAP_CARD',
              scenario: sc
            }));
          }
          break;
        case 'processing':
          if (step.phase === 'CARD_READ') later(700, () => dispatch({
            type: 'STAGE',
            step: {
              kind: 'processing',
              phase: 'WORKING'
            }
          })); else if (step.phase === 'WORKING') {
            later(700, () => {
              if (sc.askPin) {
                if (sc.outcome === 'FAILED' && sc.failureCode === 'SECURE_ENTRY_BLOCKED') return fail('SECURE_ENTRY_BLOCKED');
                return dispatch({
                  type: 'STAGE',
                  step: {
                    kind: 'pin',
                    retry: false
                  },
                  pad: makePad(config.pinPadType)
                });
              }
              dispatch({
                type: 'STAGE',
                step: {
                  kind: 'processing',
                  phase: 'AUTHORISING'
                }
              });
            });
          } else {
            later(1200, () => {
              if (sc.outcome === 'APPROVED') return dispatch({
                type: 'STAGE',
                step: {
                  kind: 'approved',
                  cardScheme: 'VISA'
                },
                keepPlaying: !full
              });
              if (sc.outcome === 'DECLINED') return dispatch({
                type: 'STAGE',
                step: {
                  kind: 'declined',
                  code: 'DECLINED_BY_ISSUER'
                },
                keepPlaying: !full
              });
              fail(sc.failureCode);
            });
          }
          break;
        case 'pin':
          if (step.confirming) {
            later(400, () => {
              if (sc.wrongPinFirst && flow.pinAttempt === 1) return dispatch({
                type: 'STAGE',
                step: {
                  kind: 'pin',
                  retry: true
                },
                pad: makePad(config.pinPadType)
              });
              if (sc.outcome === 'FAILED' && sc.failureCode === 'PIN_FAILED') return fail('PIN_FAILED');
              dispatch({
                type: 'STAGE',
                step: {
                  kind: 'processing',
                  phase: 'AUTHORISING'
                }
              });
            });
          } else if (playing) {
            const keys = flow.pad ? flow.pad.keys : ['1', '2', '3', '4'];
            if (flow.pin.length < 4) later(flow.pin.length === 0 ? 500 : 220, () => dispatch({
              type: 'PIN_KEY',
              digit: keys[flow.pin.length]
            })); else later(250, () => dispatch({
              type: 'PIN_CONFIRM'
            }));
          } else if (flow.pin.length === 4) {
            later(400, () => dispatch({
              type: 'PIN_CONFIRM'
            }));
          }
          break;
        case 'approved':
        case 'declined':
        case 'failed':
        case 'cancelled':
          if (!full && playing) later(2800, () => dispatch({
            type: 'DONE',
            delivered: null,
            loop: true
          }));
          break;
        default:
          break;
      }
      return clearTimers;
    }, [flow.step, flow.mode, flow.session, flow.pin, flow.pinAttempt, playing, effectiveScenario, config.pinPadType, autoplay, visible, hovered]);
    return {
      full,
      state,
      dispatch,
      rootRef,
      wrapRef,
      later,
      size,
      autoplay,
      setAutoplay,
      setHovered,
      effectiveScenario
    };
  };
  const renderSimulator = variant => {
    const sim = useSimulator(variant);
    const {full, state, dispatch, rootRef, wrapRef, later, size, autoplay, setAutoplay, setHovered, effectiveScenario} = sim;
    const {config, request, locale, scenario, flow, ui} = state;
    const t = COPY[locale];
    const narrow = size.root < 640;
    const fit = Math.max(0.2, (size.wrap - 8) / 382);
    const cap = full ? 1 : narrow ? 0.58 : 0.66;
    const scaleValue = Math.round(Math.min(cap, fit) * 1000) / 1000;
    const stageRef = useRef(null);
    const [phoneVisible, setPhoneVisible] = useState(true);
    useEffect(() => {
      if (!full || !narrow || typeof IntersectionObserver === 'undefined' || !wrapRef.current) return undefined;
      const io = new IntersectionObserver(entries => setPhoneVisible(entries.some(e => e.isIntersecting && e.intersectionRatio >= 0.25 || e.boundingClientRect.top > 0)), {
        threshold: [0, 0.25],
        rootMargin: '-150px 0px 0px 0px'
      });
      io.observe(wrapRef.current);
      return () => io.disconnect();
    }, [full, narrow]);
    const [studioVisible, setStudioVisible] = useState(true);
    useEffect(() => {
      if (!full || !narrow || typeof IntersectionObserver === 'undefined' || !rootRef.current) return undefined;
      const io = new IntersectionObserver(entries => setStudioVisible(entries.some(e => e.isIntersecting)), {
        threshold: 0
      });
      io.observe(rootRef.current);
      return () => io.disconnect();
    }, [full, narrow]);
    const showPip = full && narrow && !phoneVisible && studioVisible;
    const pipRef = useRef(null);
    useEffect(() => {
      const el = pipRef.current;
      if (!el) return;
      el.inert = true;
      el.querySelectorAll('button, input, select, a, [tabindex]').forEach(n => {
        n.tabIndex = -1;
      });
    }, [showPip, flow.step, flow.pin]);
    const scrollToStage = () => {
      if (!stageRef.current || typeof window === 'undefined') return;
      const top = stageRef.current.getBoundingClientRect().top + window.scrollY - 180;
      window.scrollTo({
        top: Math.max(0, top),
        behavior: 'smooth'
      });
    };
    const pickerRef = useRef(null);
    const [pickerEdges, setPickerEdges] = useState({
      left: false,
      right: false
    });
    const measurePicker = () => {
      const el = pickerRef.current;
      if (!el) return;
      const left = el.scrollLeft > 2;
      const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 2;
      setPickerEdges(prev => prev.left === left && prev.right === right ? prev : {
        left,
        right
      });
    };
    useEffect(() => {
      measurePicker();
    }, [size.root, size.wrap, full]);
    const scrollPicker = dir => {
      if (pickerRef.current) pickerRef.current.scrollBy({
        left: dir * 180,
        behavior: 'smooth'
      });
    };
    const launch = () => dispatch({
      type: 'LAUNCH',
      mode: 'manual'
    });
    const tapCard = () => dispatch({
      type: 'TAP_CARD',
      scenario: effectiveScenario
    });
    const tapWithCard = () => {
      if (flow.step.kind !== 'present' || flow.glide) return;
      dispatch({
        type: 'GLIDE',
        on: true
      });
      later(900, () => dispatch({
        type: 'TAP_CARD',
        scenario: effectiveScenario
      }));
    };
    const actions = {
      launch,
      tap: tapCard,
      cancel: () => dispatch({
        type: 'CANCEL'
      }),
      retry: () => dispatch({
        type: 'RETRY'
      }),
      grantLocation: () => dispatch({
        type: 'GRANT_LOCATION'
      }),
      done: () => dispatch({
        type: 'DONE',
        delivered: deliveredFor(flow.step),
        loop: false
      }),
      pad: {
        key: d => dispatch({
          type: 'PIN_KEY',
          digit: d
        }),
        clear: () => dispatch({
          type: 'PIN_CLEAR'
        }),
        confirm: () => dispatch({
          type: 'PIN_CONFIRM'
        }),
        cancel: () => dispatch({
          type: 'PIN_CANCEL'
        })
      }
    };
    const jump = screen => {
      const target = jumpTarget(screen, effectiveScenario, config);
      dispatch({
        type: 'JUMP',
        step: target.step,
        pad: target.pad
      });
    };
    const setConfig = patch => dispatch({
      type: 'SET_CONFIG',
      patch
    });
    const setRequest = patch => dispatch({
      type: 'SET_REQUEST',
      patch
    });
    const setScenario = patch => dispatch({
      type: 'SET_SCENARIO',
      patch
    });
    const setUi = patch => dispatch({
      type: 'UI',
      patch
    });
    const flash = what => {
      setUi({
        copied: what,
        dirty: true
      });
      later(1800, () => setUi({
        copied: ''
      }));
    };
    const copy = screenCopy(flow.step, config, request, locale);
    const currentScreen = flow.step.kind === 'idle' ? null : flow.step.kind;
    const preset = PRESETS.find(p => p.primaryColor === config.primaryColor && p.headerTextColor === config.headerTextColor && p.iconColor === config.iconColor && p.backgroundType === config.backgroundType);
    const centavos = String(request.amountCents % 100).padStart(2, '0');
    const sandboxRule = SANDBOX_RULES[centavos];
    const contrast = config.backgroundType === 'FULL' ? contrastRatio(config.primaryColor, config.headerTextColor) : null;
    const kotlin = kotlinFor(config, request);
    const stringsXml = config.tapTitle || config.tapSubtitle ? stringsXmlFor(config, locale) : null;
    const shareUrl = () => {
      if (typeof window === 'undefined') return '';
      return window.location.origin + (full ? window.location.pathname : SIMULATOR_PATH) + '#sim=' + encodeState(state);
    };
    const openLink = SIMULATOR_PATH + '#sim=' + encodeState(state);
    const captionFor = () => {
      switch (flow.step.kind) {
        case 'idle':
          return <>
              <strong>Your app.</strong> The button calls <code>checkout.launch(request)</code>.
            </>;
        case 'preparing':
          return 'The SDK attests the device and starts the card reader.';
        case 'present':
          return flow.step.failing ? 'The reader asked for the card again.' : 'Waiting for a card, phone or watch on the back of the phone.';
        case 'processing':
          return 'The card was read; the kernel is authorising online.';
        case 'pin':
          return <>
              <strong>Certified PIN pad.</strong> Its layout is fixed by PCI rules; the nav bar, the digit dots and the progress bar follow <code>primaryColor</code>.
            </>;
        case 'approved':
          return 'The operator taps Done before your callback fires.';
        case 'declined':
          return 'A decline is an answer, not a fault: no retry with the same card.';
        case 'failed':
          return offersRetry(flow.step) ? 'A technical fault with a retry that can work.' : 'A fault that retrying cannot fix.';
        default:
          return 'Cancelled by the operator, the cardholder or your app.';
      }
    };
    const screenPicker = <div className={`rtp-scroller ${pickerEdges.left ? 'has-left' : ''} ${pickerEdges.right ? 'has-right' : ''}`}>
        {pickerEdges.left && <button type="button" className="rtp-scroll-btn left" aria-label="Scroll screens left" onClick={() => scrollPicker(-1)}>
            {uiIcon('chevron-left')}
          </button>}
      <div ref={pickerRef} onScroll={measurePicker} className="rtp-chips" role="radiogroup" aria-label="Screen" onKeyDown={e => {
      if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
      e.preventDefault();
      const i = SCREENS.findIndex(s => s.id === currentScreen);
      const n = (i + (e.key === 'ArrowRight' ? 1 : -1) + SCREENS.length) % SCREENS.length;
      jump(SCREENS[n].id);
      const el = e.currentTarget.querySelectorAll('[role="radio"]')[n];
      if (el) el.focus();
    }}>
        {SCREENS.map(s => chip({
      key: s.id,
      role: 'radio',
      on: currentScreen === s.id,
      onClick: () => jump(s.id),
      children: s.label
    }))}
      </div>
        {pickerEdges.right && <button type="button" className="rtp-scroll-btn right" aria-label="Scroll screens right" onClick={() => scrollPicker(1)}>
            {uiIcon('chevron-right')}
          </button>}
      </div>;
    const presetChips = <div className="rtp-chips wrap" role="group" aria-label="Brand preset">
        {PRESETS.map(p => chip({
      key: p.id,
      on: preset && preset.id === p.id,
      swatch: p.primaryColor,
      onClick: () => dispatch({
        type: 'APPLY_PRESET',
        id: p.id
      }),
      children: p.id
    }))}
        {full && chip({
      key: 'custom',
      on: !preset,
      swatch: preset ? 'none' : config.primaryColor,
      disabled: !!preset,
      title: 'Edit a colour to make your own',
      children: 'Custom'
    })}
      </div>;
    const localeChips = <div className="rtp-chips wrap" role="group" aria-label="Screen language">
        {chip({
      key: 'pt',
      on: locale === 'pt-BR',
      onClick: () => dispatch({
        type: 'SET_LOCALE',
        locale: 'pt-BR'
      }),
      children: 'Português (BR)'
    })}
        {chip({
      key: 'en',
      on: locale === 'en',
      onClick: () => dispatch({
        type: 'SET_LOCALE',
        locale: 'en'
      }),
      children: 'English'
    })}
      </div>;
    const phone = <div className="rtp-phone-wrap" ref={wrapRef} style={{
      '--rtp-halo': withAlpha(config.primaryColor, 0.2)
    }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
        {device(state, scaleValue, actions, copy)}
      </div>;
    const liveRegion = <div className="rtp-sr" aria-live="polite">
        {flow.step.kind === 'idle' ? 'Your app' : `${copy.title}. ${copy.subtitle || ''}`}
      </div>;
    if (!full) {
      return <div className="rtp not-prose" ref={rootRef}>
          <div className="rtp-preview">
            <div className="rtp-stage">
              {phone}
              <div className="rtp-caption">{captionFor()}</div>
            </div>
            <div className="rtp-preview-aside">
              <h3>Your brand on the checkout</h3>
              <p>Pick a preset to see the header, the buttons and the certified PIN pad follow your colours. The screens loop through an approved sale on their own.</p>
              {presetChips}
              {localeChips}
              <div className="rtp-actions" style={{
        justifyContent: 'flex-start'
      }}>
                <button type="button" className="rtp-btn-chrome" onClick={() => setAutoplay(v => !v)} aria-pressed={autoplay}>
                  {uiIcon(autoplay ? 'pause' : 'play')}
                  {autoplay ? 'Pause' : 'Play'}
                </button>
                <a className="rtp-btn-chrome primary" href={openLink}>
                  Open the simulator
                  <span aria-hidden="true">→</span>
                </a>
              </div>
            </div>
          </div>
          {liveRegion}
        </div>;
    }
    const section = ui.section;
    const draftFor = id => ui.drafts[id];
    const setDraft = (id, value) => dispatch({
      type: 'DRAFT',
      id,
      value
    });
    const tabButton = s => <button key={s.id} type="button" role="tab" className="rtp-tab" aria-selected={section === s.id} id={`rtp-tab-${s.id}`} aria-controls={`rtp-panel-${s.id}`} onClick={() => setUi({
      section: s.id
    })}>
        {s.label}
      </button>;
    let panel = null;
    if (section === 'brand') {
      panel = <div className="rtp-panel" role="tabpanel" id="rtp-panel-brand" aria-labelledby="rtp-tab-brand">
          {field({
        id: 'rtp-preset',
        label: 'Preset',
        children: presetChips
      })}
          <div className="rtp-grid two">
            {colorField({
        id: 'rtp-primary',
        label: 'Brand colour',
        code: 'primaryColor',
        value: config.primaryColor,
        draft: draftFor('primary'),
        onDraft: v => setDraft('primary', v),
        onChange: v => setConfig({
          primaryColor: v
        }),
        note: 'Header, primary button, ring, and the PIN pad\u2019s brand parts.'
      })}
            {colorField({
        id: 'rtp-header-text',
        label: 'Header text',
        code: 'headerTextColor',
        value: config.headerTextColor,
        draft: draftFor('headerText'),
        onDraft: v => setDraft('headerText', v),
        onChange: v => setConfig({
          headerTextColor: v
        }),
        note: 'Text and icons on the brand band, and the pad\u2019s nav bar text. The wash always uses dark ink.'
      })}
            {colorField({
        id: 'rtp-icon',
        label: 'Icons',
        code: 'iconColor',
        value: config.iconColor,
        draft: draftFor('icon'),
        onDraft: v => setDraft('icon', v),
        onChange: v => setConfig({
          iconColor: v
        }),
        note: 'The contactless artwork.'
      })}
            {field({
        id: 'rtp-bg',
        label: 'Header',
        code: 'backgroundType',
        children: <div className="rtp-seg" role="group" aria-label="Header style">
                  {[{
          id: 'FULL',
          text: 'Solid brand band with rounded bottom corners.'
        }, {
          id: 'TRANSPARENT',
          text: 'Faint top-down wash fading into the page, dark text.'
        }].map(o => <button key={o.id} type="button" className="rtp-seg-opt" aria-pressed={config.backgroundType === o.id} onClick={() => setConfig({
          backgroundType: o.id
        })}>
                      <span className="rtp-thumb" aria-hidden="true">
                        <i className={o.id === 'FULL' ? 'band' : ''} style={{
          background: o.id === 'FULL' ? config.primaryColor : `linear-gradient(${withAlpha(config.primaryColor, 0.14)}, ${withAlpha(config.primaryColor, 0)})`
        }} />
                        <em />
                      </span>
                      <b>{o.id}</b>
                      <small>{o.text}</small>
                    </button>)}
                </div>
      })}
          </div>
          {contrast !== null && contrast < 3 && <div className="rtp-note warn">Header text is hard to read on this brand colour (contrast {contrast.toFixed(1)}:1). Pick a darker or lighter header text.</div>}
        </div>;
    } else if (section === 'copy') {
      panel = <div className="rtp-panel" role="tabpanel" id="rtp-panel-copy" aria-labelledby="rtp-tab-copy">
          <div className="rtp-grid roomy">
            {field({
        id: 'rtp-merchant',
        label: 'Merchant name',
        code: 'merchantName',
        note: 'Centred in the header. Leave it empty to hide the name.',
        children: <input id="rtp-merchant" className="rtp-input" value={config.merchantName} maxLength={40} placeholder="null hides the name" onChange={e => setConfig({
          merchantName: e.target.value
        })} />
      })}
            {field({
        id: 'rtp-locale',
        label: 'Screen language',
        note: 'The device language decides this on a phone. It is not a CheckoutConfig field.',
        children: localeChips
      })}
            {field({
        id: 'rtp-tap-title',
        label: 'Tap headline',
        code: 'tapTitle',
        note: 'Wins over the string resource. Reader guidance still replaces the supporting line while it applies.',
        children: <input id="rtp-tap-title" className="rtp-input" value={config.tapTitle} maxLength={60} placeholder={request.type === 'REFUND' ? t.rinne_tap_title_refund : t.rinne_tap_title} onChange={e => setConfig({
          tapTitle: e.target.value
        })} />
      })}
            {field({
        id: 'rtp-tap-subtitle',
        label: 'Tap supporting line',
        code: 'tapSubtitle',
        children: <input id="rtp-tap-subtitle" className="rtp-input" value={config.tapSubtitle} maxLength={120} placeholder={t.rinne_tap_subtitle} onChange={e => setConfig({
          tapSubtitle: e.target.value
        })} />
      })}
          </div>
          <div className="rtp-note">
            Every other line on the screens is an Android string resource prefixed <code>rinne_</code>; override it in your app for each locale. The strings.xml tab in the code panel shows the two lines above as resources.
          </div>
        </div>;
    } else if (section === 'pad') {
      panel = <div className="rtp-panel" role="tabpanel" id="rtp-panel-pad" aria-labelledby="rtp-tab-pad">
          {field({
        id: 'rtp-pad-type',
        label: 'Key layout',
        code: 'pinPadType',
        children: <div className="rtp-seg" role="group" aria-label="PIN pad layout">
                {[{
          id: 'SHIFTED',
          text: 'Digits keep their 1-2-3 order; the whole block moves to a new position on the pad for each transaction.'
        }, {
          id: 'SHUFFLED',
          text: 'Digit positions are randomised on every transaction. A little slower to type, harder to shoulder-surf.'
        }].map(o => <button key={o.id} type="button" className="rtp-seg-opt" aria-pressed={config.pinPadType === o.id} onClick={() => setConfig({
          pinPadType: o.id
        })}>
                    <b>{o.id}</b>
                    <small>{o.text}</small>
                  </button>)}
              </div>
      })}
          {switchRow({
        id: 'rtp-ask-pin',
        checked: scenario.askPin,
        onChange: v => setScenario({
          askPin: v
        }),
        children: 'The card asks for a PIN'
      })}
          <div className="rtp-actions" style={{
        justifyContent: 'flex-start'
      }}>
            <button type="button" className="rtp-btn-chrome" onClick={() => jump('pin')}>
              Show the pad
            </button>
          </div>
          <div className="rtp-note">The pad is the certified PIN provider's own screen and is deliberately plain: a PCI requirement, not a style choice. Only its nav bar colour and text, the entered-digit dots and the progress bar follow your brand; the keys, spacing and copy are fixed. On a device the card and the amount decide whether a PIN is asked, not your app.</div>
        </div>;
    } else if (section === 'sale') {
      panel = <div className="rtp-panel" role="tabpanel" id="rtp-panel-sale" aria-labelledby="rtp-tab-sale">
          <div className="rtp-grid two">
            {field({
        id: 'rtp-amount',
        label: 'Amount',
        code: 'amountCents',
        note: `amountCents = ${request.amountCents}`,
        children: <div className="rtp-input-row">
                  <span className="rtp-badge-chip">R$</span>
                  <input id="rtp-amount" className="rtp-input" inputMode="numeric" value={digits(request.amountCents, locale)} onChange={e => {
          const raw = e.target.value.replace(/\D/g, '').slice(0, 8);
          setRequest({
            amountCents: Math.max(1, parseInt(raw || '0', 10))
          });
        }} />
                </div>
      })}
            {field({
        id: 'rtp-kind',
        label: 'Kind',
        code: 'type',
        children: <div className="rtp-chips wrap" role="group" aria-label="Transaction type">
                  {chip({
          key: 'purchase',
          on: request.type === 'PURCHASE',
          onClick: () => setRequest({
            type: 'PURCHASE'
          }),
          children: 'PURCHASE'
        })}
                  {chip({
          key: 'refund',
          on: request.type === 'REFUND',
          onClick: () => setRequest({
            type: 'REFUND'
          }),
          children: 'REFUND'
        })}
                </div>
      })}
            {field({
        id: 'rtp-method',
        label: 'Payment method',
        code: 'paymentMethod',
        children: <div className="rtp-chips wrap" role="group" aria-label="Payment method">
                  {chip({
          key: 'credit',
          on: request.paymentMethod === 'CREDIT',
          onClick: () => setRequest({
            paymentMethod: 'CREDIT'
          }),
          children: 'CREDIT'
        })}
                  {chip({
          key: 'debit',
          on: request.paymentMethod === 'DEBIT',
          onClick: () => setRequest({
            paymentMethod: 'DEBIT'
          }),
          children: 'DEBIT'
        })}
                </div>
      })}
            {field({
        id: 'rtp-installments',
        label: 'Installments',
        code: 'installments',
        note: request.type === 'REFUND' ? 'A refund returns the full amount in one payment.' : request.installments > 1 ? `${request.installments}x ${locale === 'en' ? 'of' : 'de'} ${money(Math.floor(request.amountCents / request.installments), locale)}` : undefined,
        children: <div className="rtp-chips wrap" role="group" aria-label="Installments">
                  {[1, 2, 3, 6, 12].map(n => chip({
          key: String(n),
          on: request.installments === n,
          disabled: request.type === 'REFUND',
          onClick: () => setRequest({
            installments: n,
            paymentMethod: n > 1 ? 'CREDIT' : request.paymentMethod
          }),
          children: n === 1 ? locale === 'en' ? '1x' : 'à vista' : `${n}x`
        }))}
                </div>
      })}
          </div>
          <div className={`rtp-note ${sandboxRule ? 'warn' : 'ok'}`}>
            <strong>Tip:</strong> use sandbox amounts to simulate the outcome you want. In the sandbox the last two digits of the amount decide it: 51, 05, 54 and 14 decline, 96 fails, anything else approves. Here, any other amount leaves the outcome to the Scenario section.{' '}
            {money(request.amountCents, locale)} ends in {centavos} → {sandboxRule ? sandboxRule.label : `${effectiveScenario.outcome.toLowerCase()}, from the Scenario section`}.
          </div>
        </div>;
    } else if (section === 'scenario') {
      panel = <div className="rtp-panel" role="tabpanel" id="rtp-panel-scenario" aria-labelledby="rtp-tab-scenario">
          {field({
        id: 'rtp-outcome',
        label: 'Outcome to simulate',
        note: sandboxRule ? `Set by the amount, as in the sandbox: ${money(request.amountCents, locale)} ends in ${centavos}. Change the amount in the Sale section to choose here.` : undefined,
        children: <div className="rtp-chips wrap" role="group" aria-label="Outcome">
                {[['APPROVED', 'Approved'], ['DECLINED', 'Declined by issuer'], ['FAILED', 'Failed']].map(([id, label]) => chip({
          key: id,
          on: effectiveScenario.outcome === id,
          disabled: !!sandboxRule,
          onClick: () => setScenario({
            outcome: id
          }),
          children: label
        }))}
              </div>
      })}
          {effectiveScenario.outcome === 'FAILED' && field({
        id: 'rtp-code',
        label: 'Error code',
        code: 'ErrorCode',
        note: `ErrorCatalog[${effectiveScenario.failureCode}].retriable = ${RETRIABLE[effectiveScenario.failureCode]}`,
        children: <div className="rtp-input-row">
                  <select id="rtp-code" className="rtp-select" value={effectiveScenario.failureCode} disabled={!!sandboxRule} onChange={e => setScenario({
          failureCode: e.target.value
        })}>
                    {FAILURE_CODES.map(c => <option key={c} value={c}>
                        {c}
                      </option>)}
                  </select>
                  <span className={`rtp-tag ${RETRIABLE[effectiveScenario.failureCode] ? 'ok' : 'no'}`}>{RETRIABLE[effectiveScenario.failureCode] ? 'retry' : 'no retry'}</span>
                </div>
      })}
          <div className="rtp-grid roomy">
            {field({
        id: 'rtp-hint',
        label: 'Reader guidance',
        note: 'The card reader can replace the supporting line on the tap screen while its guidance applies.',
        children: <select id="rtp-hint" className="rtp-select" value={scenario.hint} onChange={e => setScenario({
          hint: e.target.value
        })}>
                  <option value="">None</option>
                  {HINTS.map(h => <option key={h.id} value={h.id}>
                      {t[h.res]}
                    </option>)}
                </select>
      })}
            <div className="rtp-field">
              <span className="rtp-label">Detours</span>
              {switchRow({
        id: 'rtp-wrong-pin',
        checked: scenario.wrongPinFirst,
        onChange: v => setScenario({
          wrongPinFirst: v
        }),
        children: 'The first PIN is wrong'
      })}
              {switchRow({
        id: 'rtp-location',
        checked: scenario.locationDeniedFirst,
        onChange: v => setScenario({
          locationDeniedFirst: v
        }),
        children: 'Location is denied at first'
      })}
            </div>
          </div>
          <div className="rtp-note">Declined and Failed are different screens on purpose: a decline is the issuer's answer and offers no retry with the same card; a fault offers Try again only where retrying could work.</div>
        </div>;
    }
    const stageFooter = <>
        <div className="rtp-caption">{captionFor()}</div>
        <div className="rtp-actions">
          {flow.mode === 'playing' && !isTerminal(flow.step) ? <button type="button" className="rtp-btn-chrome" onClick={() => dispatch({
      type: 'PAUSE'
    })}>
              {uiIcon('pause')}
              Pause
            </button> : <button type="button" className="rtp-btn-chrome primary" onClick={() => dispatch({
      type: 'PLAY'
    })}>
              {uiIcon('play')}
              {flow.step.kind === 'idle' ? 'Play the flow' : 'Play again'}
            </button>}
          <button type="button" className="rtp-btn-chrome dashed" disabled={flow.step.kind !== 'present' || !!flow.step.failing || flow.glide} onClick={tapWithCard}>
            {uiIcon('card')}
            Tap a card
          </button>
          <button type="button" className="rtp-btn-chrome" onClick={() => dispatch({
      type: 'RESET'
    })} disabled={flow.step.kind === 'idle' && !flow.delivered}>
            {uiIcon('reset')}
            Reset
          </button>
        </div>
        <div className="rtp-hint">Simulation only. No device, network or card is involved.</div>
      </>;
    return <div className="rtp not-prose" ref={rootRef}>
        <div className="rtp-studio">
          <div className="rtp-stage-col">
          <div className="rtp-stage" ref={stageRef}>
            {screenPicker}
            {phone}
            {narrow && stageFooter}
          </div>
          </div>

          <div className="rtp-inspector">
            <div className="rtp-inspector-head">
              <div className="rtp-inspector-title">
                <span>Configure</span>
                <span className="rtp-badge-chip">
                  <span className="rtp-live" aria-hidden="true" />
                  Sandbox · simulation
                </span>
              </div>
              <div className="rtp-inspector-tools">{copyButton({
      text: shareUrl(),
      label: 'Copy link',
      done: ui.copied === 'link',
      onDone: () => flash('link')
    })}</div>
            </div>
            <div className="rtp-tabs" role="tablist" aria-label="Configuration sections">
              {SECTIONS.map(tabButton)}
            </div>
            {panel}
            {!narrow && <div className="rtp-inspector-foot">{stageFooter}</div>}
          </div>

          <div className="rtp-code">
            <div className="rtp-code-bar" role="tablist" aria-label="Generated code">
              <button type="button" role="tab" className="rtp-code-tab" aria-selected={ui.codeTab === 'kotlin'} onClick={() => setUi({
      codeTab: 'kotlin'
    })}>
                Kotlin
              </button>
              {stringsXml && <button type="button" role="tab" className="rtp-code-tab" aria-selected={ui.codeTab === 'xml'} onClick={() => setUi({
      codeTab: 'xml'
    })}>
                  strings.xml
                </button>}
              <div className="rtp-code-tools">{copyButton({
      className: 'rtp-code-copy',
      text: ui.codeTab === 'xml' && stringsXml ? stringsXml : kotlin,
      label: 'Copy',
      done: ui.copied === 'code',
      onDone: () => flash('code')
    })}</div>
            </div>
            <pre tabIndex={0}>{ui.codeTab === 'xml' && stringsXml ? tokenize(stringsXml, 'xml') : tokenize(kotlin, 'kotlin')}</pre>
            {flow.delivered && <div className="rtp-delivered" role="status">
                <div className="rtp-delivered-title">Delivered to your registerForActivityResult callback</div>
                <pre>{tokenize(flow.delivered, 'kotlin')}</pre>
              </div>}
          </div>
        </div>
        {showPip && <div className="rtp-pip" role="button" tabIndex={0} aria-label="Back to the phone" onClick={scrollToStage} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && scrollToStage()}>
            <div className="rtp-pip-phone" aria-hidden="true" ref={pipRef}>
              {device(state, 0.22, actions, copy)}
            </div>
          </div>}
        {liveRegion}
      </div>;
  };
  return renderSimulator(variant);
};

Tap on Phone lets a merchant accept contactless cards, phones and watches on an ordinary Android phone. Your app describes the sale, the SDK owns the screen from tap to outcome, and your backend sees the transaction like any other Rinne transaction.

<TapOnPhoneSimulator variant="preview" />

## Platforms

<CardGroup cols={2}>
  <Card title="Android" icon="android" href="/tap-on-phone/android/quickstart">
    Available in closed beta. Kotlin SDK for Android 10 and newer, on phones with NFC and Google Play services.
  </Card>

  <Card title="iOS" icon="apple" href="/tap-on-phone/ios">
    Coming soon. The same integration model, the same token from your backend and the same branding object.
  </Card>
</CardGroup>

## How a payment flows

```mermaid theme={null}
sequenceDiagram
    participant Backend as Your backend
    participant App as Your app
    participant SDK as Tap on Phone SDK
    participant Rinne as Rinne API

    Backend->>Rinne: POST /v1/terminal/token with your API key
    Rinne-->>Backend: access_token, valid for 15 minutes
    App->>Backend: Ask for a token
    Backend-->>App: access_token
    App->>SDK: Payments.getReady(token, CheckoutConfig)
    SDK-->>App: ReadyResult.Ready
    App->>SDK: checkout.launch(CheckoutRequest)
    Note over SDK: Tap, PIN pad when the card asks, authorisation
    SDK->>Rinne: Registers the transaction
    SDK-->>App: PaymentResult, once the operator taps Done
    Rinne-->>Backend: transaction.created webhook
```

<Steps>
  <Step title="Your backend mints a short-lived access token">
    It calls `POST /v1/terminal/token` with your API key. The key never ships inside the app. The token expires after fifteen minutes by default and works only on the Tap on Phone surface.
  </Step>

  <Step title="Your app prepares the terminal once">
    `Payments.getReady` takes the token, attests the device and provisions keys. It is idempotent, so calling it again on a prepared terminal returns immediately.
  </Step>

  <Step title="Your app launches the checkout for one sale">
    `Checkout.Contract` opens Rinne's screens with the amount, the payment method and your idempotency key. The customer taps, enters a PIN on the certified pad when the card asks for one, and the operator reads the outcome.
  </Step>

  <Step title="Your app receives exactly one result">
    `PaymentResult` is `Approved`, `Declined`, `Failed` or `Cancelled`. It arrives through Android's activity-result machinery, which survives the process being killed mid-payment.
  </Step>

  <Step title="The transaction appears on the Rinne API">
    The SDK registers the transaction with Rinne while authorising. Read it back through the [transactions API](/api-reference/introduction) or a [`transaction.created` webhook](/guides/webhooks) using the `requestId` you sent.
  </Step>
</Steps>

## What the SDK gives you

* **Certified checkout screens** for preparing, presenting the card, processing, PIN entry and every outcome, in English and Brazilian Portuguese.
* **One branding object.** `CheckoutConfig` themes the screens and the certified PIN pad together. Try every field in the [simulator](/tap-on-phone/android/simulator).
* **A stable error taxonomy.** Thirteen `ErrorCode` values, each with operator text and a retry hint, instead of the dozens of low-level causes underneath.
* **Card-present refunds.** A refund is a card re-tap for the full amount of a sale, launched with the same contract.
* **Onboarding helpers.** `AntennaSetup` shows the merchant where the NFC antenna is, and `Payments.checkDeviceReadiness` reports NFC, connectivity, location and Play services before a customer is waiting.

## Requirements

| Requirement                                             | Value                                                                                                                            |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Android version                                         | 10 (API 29) or newer                                                                                                             |
| Hardware                                                | NFC, Google Play services                                                                                                        |
| Kotlin                                                  | 2.x with `kotlinx.coroutines` on the classpath                                                                                   |
| Permissions declared by the SDK                         | `NFC`, `VIBRATE`, `INTERNET`, `ACCESS_NETWORK_STATE`                                                                             |
| Permissions merged by the certified card-reader library | `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `HIGH_SAMPLING_RATE_SENSORS`, `RECEIVE_BOOT_COMPLETED`, `HIDE_OVERLAY_WINDOWS` |
| Permission the checkout asks for at runtime             | `ACCESS_FINE_LOCATION`                                                                                                           |
| Currency                                                | BRL only                                                                                                                         |

The merged manifest of your app carries all nine permissions, which matters when you answer the Play Console questionnaire; [Going to production](/tap-on-phone/android/production) lists what each one is for.

The device runs an integrity check before every transaction. Emulators, rooted devices and devices without Play services fail it with `ATTESTATION_FAILED`, so test on a physical phone.

<Note>
  During the closed beta, integrations run against the **sandbox** environment with the sandbox artifact. Sandbox payments authorise against a simulated acquirer and never move money. Rinne provides the production artifact and credentials when your integration is approved for launch.
</Note>

## What the SDK never exposes

The SDK never receives the card number, track data, PIN, expiry, cardholder name or cryptogram, and none of them can appear in a `PaymentResult`. Receipt fields such as the masked last four digits come from the transaction record on the Rinne API, not from the SDK.

## Get access

<Info>
  **Tap on Phone is in closed beta.** Access is granted on request. To join, email [contact@rinne.com.br](mailto:contact@rinne.com.br) with your company name and a short description of your app. You receive credentials for the SDK registry, a sandbox API key with the `tap_on_phone.*` permission (or the `*.*` wildcard) and a merchant enabled for contactless capture.
</Info>

## Start here

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/tap-on-phone/android/quickstart">
    From an empty project to an approved sandbox payment in six steps.
  </Card>

  <Card title="Simulator" icon="play" href="/tap-on-phone/android/simulator">
    Brand the screens, play the flow and copy the Kotlin that produces it.
  </Card>

  <Card title="Customization" icon="palette" href="/tap-on-phone/android/customization">
    Every `CheckoutConfig` field, what you cannot change, and how to override the copy.
  </Card>

  <Card title="Results and errors" icon="circle-check" href="/tap-on-phone/android/results-and-errors">
    What your app receives after a payment, and what each error code means.
  </Card>
</CardGroup>
