mirror of
https://github.com/itflow-org/itflow
synced 2026-09-01 12:25:11 +00:00
Feature: Major spruce up of the client portal profile page, added Department, Location, Title, Phone with Edit, editing Pin, Recent signins and recent activity along with a seperate activity page
This commit is contained in:
138
client/activity.php
Normal file
138
client/activity.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/*
|
||||
* Client Portal
|
||||
* Everything this contact has done in the portal, and every sign-in
|
||||
*/
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'");
|
||||
|
||||
require_once "includes/inc_all.php";
|
||||
|
||||
/*
|
||||
* No capability gate: this is the contact's own record of their own activity,
|
||||
* not a section of the portal. Scoped on log_user_id - the portal user this
|
||||
* contact signs in as - so an agent working this client never appears here,
|
||||
* and on log_client_id as a second fence.
|
||||
*/
|
||||
$page = intval($_GET['page'] ?? 1);
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
$records_per_page = 25;
|
||||
$offset = ($page - 1) * $records_per_page;
|
||||
|
||||
$log_scope = "log_user_id = $session_user_id AND log_client_id = $session_client_id";
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS total FROM logs WHERE $log_scope"));
|
||||
$total_records = intval($row['total']);
|
||||
$total_pages = (int) ceil($total_records / $records_per_page);
|
||||
|
||||
// A page number past the end would show nothing at all with no way back
|
||||
if ($total_pages > 0 && $page > $total_pages) {
|
||||
$page = $total_pages;
|
||||
$offset = ($page - 1) * $records_per_page;
|
||||
}
|
||||
|
||||
$sql_activity = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT log_action, log_created_at, log_description, log_ip, log_type FROM logs
|
||||
WHERE $log_scope
|
||||
ORDER BY log_id DESC
|
||||
LIMIT $records_per_page OFFSET $offset"
|
||||
);
|
||||
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-0">Your activity</h3>
|
||||
<a class="btn btn-secondary" href="profile.php"><i class="fa fa-fw fa-user me-2"></i>Back to profile</a>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
<?php if ($total_records == 0) { ?>
|
||||
|
||||
<?= portalEmptyState('Nothing has been recorded on your account yet.') ?>
|
||||
|
||||
<?php } else { ?>
|
||||
|
||||
<table class="table table-bordered border border-dark">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>What happened</th>
|
||||
<th>From</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<?php
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql_activity)) {
|
||||
$log_type = escapeHtml($row['log_type']);
|
||||
$log_action = escapeHtml($row['log_action']);
|
||||
$log_description = escapeHtml($row['log_description']);
|
||||
$log_ip = escapeHtml($row['log_ip']);
|
||||
|
||||
// Sign-ins are the rows people scan this page for, so they
|
||||
// get the accent rather than sitting in the same grey as
|
||||
// every password change
|
||||
if ($row['log_type'] === 'Client Login') {
|
||||
$log_badge_color = 'primary';
|
||||
$log_label = 'Sign-in';
|
||||
} else {
|
||||
$log_badge_color = 'secondary';
|
||||
$log_label = "$log_type $log_action";
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= portalDateTime($row['log_created_at']) ?></td>
|
||||
<td><span class="p-2 badge text-bg-<?= $log_badge_color ?>"><?= $log_label ?></span></td>
|
||||
<td><?= $log_description ?></td>
|
||||
<td class="font-monospace"><?= $log_ip ?></td>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($total_pages > 1) { ?>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-sm">
|
||||
<p class="text-muted mb-0">
|
||||
Page <strong><?= $page ?></strong> of <strong><?= $total_pages ?></strong>
|
||||
— <strong><?= $total_records ?></strong> records
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<ul class="pagination justify-content-sm-end mb-0">
|
||||
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="?page=<?= $page - 1 ?>">Previous</a>
|
||||
</li>
|
||||
<li class="page-item <?= $page >= $total_pages ? 'disabled' : '' ?>">
|
||||
<a class="page-link" href="?page=<?= $page + 1 ?>">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<?php
|
||||
require_once "includes/footer.php";
|
||||
@@ -72,6 +72,43 @@ function enforceContactCan($capability) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A timestamp a person can read, in the company's configured date and time
|
||||
* format rather than the raw DATETIME the database hands back.
|
||||
*
|
||||
* Today and yesterday are named instead of dated, because on an activity list
|
||||
* the recent rows are the ones being scanned and "Today at 4:12 PM" answers
|
||||
* "was that just now?" faster than a date does. Anything older gets the full
|
||||
* date, since by then the date is the useful part.
|
||||
*
|
||||
* Returns HTML-escaped output - callers print it directly.
|
||||
*/
|
||||
function portalDateTime($datetime) {
|
||||
global $config_date_format, $config_time_format;
|
||||
|
||||
if (empty($datetime)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$timestamp = strtotime($datetime);
|
||||
if ($timestamp === false) {
|
||||
return escapeHtml($datetime);
|
||||
}
|
||||
|
||||
$time = date($config_time_format, $timestamp);
|
||||
$day = date('Y-m-d', $timestamp);
|
||||
|
||||
if ($day === date('Y-m-d')) {
|
||||
return escapeHtml("Today at $time");
|
||||
}
|
||||
|
||||
if ($day === date('Y-m-d', strtotime('-1 day'))) {
|
||||
return escapeHtml("Yesterday at $time");
|
||||
}
|
||||
|
||||
return escapeHtml(date($config_date_format, $timestamp) . " at $time");
|
||||
}
|
||||
|
||||
/*
|
||||
* The empty state for the portal's list pages.
|
||||
*
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
|
||||
<script src="/js/confirm_modal.js"></script>
|
||||
|
||||
<?php if (!empty($portal_load_phone_inputs)) { ?>
|
||||
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
|
||||
<script src="/js/phone_inputs.js"></script>
|
||||
<?php } ?>
|
||||
|
||||
<script src="/js/keepalive.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -40,9 +40,23 @@ header("X-Frame-Options: DENY"); // Legacy
|
||||
which left this portal without .text-bold, the bg-dark text pairing or any theme. -->
|
||||
<link rel="stylesheet" href="/css/itflow_custom.css">
|
||||
|
||||
<?php /* Only the pages that set this flag before including inc_all.php pull
|
||||
in intl-tel-input - it is ~200KB of JS and flag sprites, and one
|
||||
page uses it. */ ?>
|
||||
<?php if (!empty($portal_load_phone_inputs)) { ?>
|
||||
<link rel="stylesheet" href="/libs/intl-tel-input/css/intlTelInput.min.css">
|
||||
<?php } ?>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary theme-<?= escapeHtml($config_theme) ?>" data-lte-primary="<?= escapeHtml($config_theme) ?>">
|
||||
<?php /* Same country bridge as the agent header: intl-tel-input wants an ISO2
|
||||
code, the companies table stores a country NAME. Given as a data
|
||||
attribute rather than an inline <script>, which this portal's CSP
|
||||
(default-src 'self') would block outright. Empty when the company has
|
||||
no country set, which js/phone_inputs.js reads as "let the library
|
||||
decide". */ ?>
|
||||
<body class="bg-body-tertiary theme-<?= escapeHtml($config_theme) ?>" data-lte-primary="<?= escapeHtml($config_theme) ?>"
|
||||
data-itflow-phone-country="<?= escapeHtml($country_iso2_array[$session_company_country] ?? '') ?>">
|
||||
|
||||
<!-- Navbar -->
|
||||
|
||||
@@ -125,6 +139,7 @@ header("X-Frame-Options: DENY"); // Legacy
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="/client/profile.php"><i class="fas fa-fw fa-user me-2"></i>Account</a>
|
||||
<a class="dropdown-item" href="/client/activity.php"><i class="fas fa-fw fa-list me-2"></i>Activity</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="/client/post.php?logout"><i class="fas fa-fw fa-sign-out-alt me-2"></i>Sign out</a>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,102 @@ if (isset($_POST['add_ticket_comment'])) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isset($_POST['set_contact_phone'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
/*
|
||||
* SCOPING: as with the PIN above, the row updated is the logged-in
|
||||
* contact's own, from the session. No contact_id parameter exists on this
|
||||
* handler, so there is nothing to point at somebody else's record with.
|
||||
*
|
||||
* Sanitising exactly as agent/post/contact_model.php does - digits only for
|
||||
* every phone field - so a number typed in the portal is stored in the same
|
||||
* shape as one typed by an agent and formatPhoneNumber() renders both the
|
||||
* same way. Anything else would make the portal the odd one out.
|
||||
*/
|
||||
$phone_country_code = preg_replace("/[^0-9]/", '', $_POST['phone_country_code'] ?? '');
|
||||
$phone = preg_replace("/[^0-9]/", '', $_POST['phone'] ?? '');
|
||||
$extension = preg_replace("/[^0-9]/", '', $_POST['extension'] ?? '');
|
||||
$mobile_country_code = preg_replace("/[^0-9]/", '', $_POST['mobile_country_code'] ?? '');
|
||||
$mobile = preg_replace("/[^0-9]/", '', $_POST['mobile'] ?? '');
|
||||
|
||||
// Columns are varchar(200), country codes varchar(10)
|
||||
$phone_country_code = substr($phone_country_code, 0, 10);
|
||||
$mobile_country_code = substr($mobile_country_code, 0, 10);
|
||||
$phone = substr($phone, 0, 200);
|
||||
$extension = substr($extension, 0, 200);
|
||||
$mobile = substr($mobile, 0, 200);
|
||||
|
||||
// A country code on its own is not a number - drop it rather than store a
|
||||
// dangling code the formatter would try to render
|
||||
if (empty($phone)) {
|
||||
$phone_country_code = '';
|
||||
$extension = '';
|
||||
}
|
||||
if (empty($mobile)) {
|
||||
$mobile_country_code = '';
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE contacts SET
|
||||
contact_phone_country_code = '$phone_country_code',
|
||||
contact_phone = '$phone',
|
||||
contact_extension = '$extension',
|
||||
contact_mobile_country_code = '$mobile_country_code',
|
||||
contact_mobile = '$mobile'
|
||||
WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id");
|
||||
|
||||
logAudit("Contact", "Edit", "Client contact $session_contact_name updated their phone numbers in the client portal", $session_client_id, $session_contact_id);
|
||||
|
||||
flashAlert("Phone numbers updated");
|
||||
|
||||
redirect('profile.php');
|
||||
|
||||
}
|
||||
|
||||
if (isset($_POST['set_contact_pin'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
/*
|
||||
* SCOPING: the row updated is the logged-in contact's own, taken from the
|
||||
* session. There is no contact_id parameter on this handler by design, so
|
||||
* there is nothing for a contact to point at somebody else's record with.
|
||||
*
|
||||
* No capability gate: the PIN belongs to the contact, not to a portal
|
||||
* section, so every signed-in contact manages their own - same as the
|
||||
* password change above.
|
||||
*/
|
||||
$pin = trim($_POST['pin'] ?? '');
|
||||
|
||||
if ($pin === '') {
|
||||
flashAlert("Enter a PIN, or leave the page to keep the current one", 'error');
|
||||
redirect('profile.php');
|
||||
}
|
||||
|
||||
if (strlen($pin) < 4) {
|
||||
flashAlert("Your PIN needs to be at least 4 characters", 'error');
|
||||
redirect('profile.php');
|
||||
}
|
||||
|
||||
// contact_pin is varchar(255) - trim to fit rather than let an over-long
|
||||
// value error out under strict mode
|
||||
$pin = escapeSql(substr($pin, 0, 255));
|
||||
|
||||
mysqli_query($mysqli, "UPDATE contacts SET contact_pin = '$pin' WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id");
|
||||
|
||||
// The PIN itself never goes in the log - it is a verification secret, and
|
||||
// an audit trail an agent can read would defeat the point of having one
|
||||
logAudit("Contact", "Edit", "Client contact $session_contact_name set their phone PIN in the client portal", $session_client_id, $session_contact_id);
|
||||
|
||||
flashAlert("Phone PIN updated");
|
||||
|
||||
redirect('profile.php');
|
||||
|
||||
}
|
||||
|
||||
if (isset($_GET['approve_ticket_task'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
@@ -4,45 +4,501 @@
|
||||
* User profile
|
||||
*/
|
||||
|
||||
// Read by client/includes/header.php and footer.php - see the note there.
|
||||
// Must be set before inc_all.php, which pulls the header in.
|
||||
$portal_load_phone_inputs = true;
|
||||
|
||||
header("Content-Security-Policy: default-src 'self'");
|
||||
|
||||
require_once 'includes/inc_all.php';
|
||||
|
||||
/*
|
||||
* check_login.php runs these through escapeSql(), which is for writing to the
|
||||
* database rather than printing. It leaves a backslash in front of any quote in
|
||||
* the value - O'Brien renders as O\'Brien - which is why the name was already
|
||||
* wrapped in stripslashes() here. Email, PIN and company were printed raw, and
|
||||
* the company name had no escaping of any kind. Normalise all four in one place
|
||||
* and escape at the point of output.
|
||||
*/
|
||||
$contact_name = escapeHtml(stripslashes($session_contact_name));
|
||||
$contact_email = escapeHtml(stripslashes($session_contact_email));
|
||||
$contact_pin = escapeHtml(stripslashes($session_contact_pin));
|
||||
$client_name = escapeHtml(stripslashes($session_client_name));
|
||||
|
||||
/*
|
||||
* check_login.php loads the handful of contact columns every portal page needs.
|
||||
* The rest are only wanted here, so they are fetched on this page rather than
|
||||
* added to a query that runs on every request. Scoped to the session contact
|
||||
* and their client - nothing here reads an id from the request.
|
||||
*/
|
||||
$row = mysqli_fetch_assoc(mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT contact_department, contact_extension, contact_location_id, contact_mobile,
|
||||
contact_mobile_country_code, contact_phone, contact_phone_country_code
|
||||
FROM contacts
|
||||
WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id
|
||||
LIMIT 1"
|
||||
));
|
||||
|
||||
$contact_title = escapeHtml(stripslashes($session_contact_title));
|
||||
$contact_department = escapeHtml($row['contact_department']);
|
||||
$contact_phone_country_code = escapeHtml($row['contact_phone_country_code']);
|
||||
$contact_extension = escapeHtml($row['contact_extension']);
|
||||
$contact_mobile_country_code = escapeHtml($row['contact_mobile_country_code']);
|
||||
$contact_location_id = intval($row['contact_location_id']);
|
||||
|
||||
/*
|
||||
* Two renderings of the same digits. The tables show them formatted for
|
||||
* reading; the modal inputs get formatPhoneNumber(..., false), which is the
|
||||
* form-input variant - intl-tel-input runs in separateDialCode mode, so the
|
||||
* visible input holds the national number only and the dial code lives in the
|
||||
* hidden field beside it.
|
||||
*/
|
||||
$contact_phone = escapeHtml(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code']));
|
||||
$contact_mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code']));
|
||||
$contact_phone_input = escapeHtml(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code'], false));
|
||||
$contact_mobile_input = escapeHtml(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code'], false));
|
||||
|
||||
/*
|
||||
* The location is joined on client too, so a contact whose location_id points
|
||||
* at another client's row - stale data, a moved contact - gets nothing rather
|
||||
* than another company's address.
|
||||
*/
|
||||
$contact_location = '';
|
||||
$contact_location_address = '';
|
||||
if (!empty($contact_location_id)) {
|
||||
$location = mysqli_fetch_assoc(mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT location_address, location_city, location_country, location_name, location_state, location_zip
|
||||
FROM locations
|
||||
WHERE location_id = $contact_location_id
|
||||
AND location_client_id = $session_client_id
|
||||
AND location_archived_at IS NULL
|
||||
LIMIT 1"
|
||||
));
|
||||
if ($location) {
|
||||
$contact_location = escapeHtml($location['location_name']);
|
||||
$contact_location_address = nl2br(escapeHtml(formatAddress(
|
||||
$location['location_address'],
|
||||
$location['location_city'],
|
||||
$location['location_state'],
|
||||
$location['location_zip'],
|
||||
$location['location_country']
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
$login_method = $_SESSION['login_method'] ?? 'local';
|
||||
|
||||
if ($login_method === 'local') {
|
||||
$login_method_display = 'Password';
|
||||
} elseif ($login_method === 'azure') {
|
||||
$login_method_display = 'Microsoft account';
|
||||
} else {
|
||||
$login_method_display = escapeHtml(ucfirst($login_method));
|
||||
}
|
||||
|
||||
/*
|
||||
* These three decide which sections of the portal a contact can reach - the
|
||||
* same flags contactCan() switches on - so the answer to "why can't I see
|
||||
* invoices" is on this page rather than only in the agent's copy of the record.
|
||||
*
|
||||
* All three are booleans. The billing row used to be written as
|
||||
* ($session_contact_is_billing_contact == $session_contact_id), comparing a
|
||||
* bool to a contact id. PHP casts the id to bool for that comparison, so it
|
||||
* gave the right answer for every real contact and would only have misreported
|
||||
* at id 0 - working by luck rather than by meaning.
|
||||
*/
|
||||
$contact_roles = [
|
||||
['Primary', (bool) $session_contact_primary, 'Everything in the portal'],
|
||||
['Billing', (bool) $session_contact_is_billing_contact, 'Invoices, quotes and payments'],
|
||||
['Technical', (bool) $session_contact_is_technical_contact, 'Assets, documents, domains and contacts'],
|
||||
];
|
||||
|
||||
/*
|
||||
* Recent activity, both scoped on log_user_id - the portal user this contact
|
||||
* signs in as. logAudit() fills that from the $session_user_id global, and
|
||||
* login.php deliberately sets it before logging a client login ("Option B" in
|
||||
* that file), so both the sign-ins and the actions carry it. An agent working
|
||||
* on this client writes their own user id, so their work never appears here.
|
||||
*
|
||||
* Successful sign-ins only. Failed attempts are logged without a reliable user
|
||||
* id - login.php cannot always resolve one from a bad email - so they cannot
|
||||
* honestly be attributed to this contact.
|
||||
*/
|
||||
$sql_logins = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT log_created_at, log_ip FROM logs
|
||||
WHERE log_type = 'Client Login'
|
||||
AND log_action = 'Success'
|
||||
AND log_user_id = $session_user_id
|
||||
AND log_client_id = $session_client_id
|
||||
ORDER BY log_id DESC
|
||||
LIMIT 5"
|
||||
);
|
||||
|
||||
$sql_actions = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT log_action, log_created_at, log_description, log_type FROM logs
|
||||
WHERE log_user_id = $session_user_id
|
||||
AND log_client_id = $session_client_id
|
||||
AND log_type != 'Client Login'
|
||||
ORDER BY log_id DESC
|
||||
LIMIT 5"
|
||||
);
|
||||
|
||||
?>
|
||||
|
||||
<h2>Profile</h2>
|
||||
<h3>Profile</h3>
|
||||
<hr>
|
||||
|
||||
<p>Name: <?= stripslashes(escapeHtml($session_contact_name)) ?></p>
|
||||
<p>Email: <?= $session_contact_email ?></p>
|
||||
<p>PIN: <?= $session_contact_pin ?></p>
|
||||
<p>Client: <?= $session_client_name ?></p>
|
||||
<br>
|
||||
<p>Client Primary Contact: <?php if ($session_contact_primary == 1) {echo "Yes"; } else {echo "No";} ?></p>
|
||||
<p>Client Technical Contact: <?php if ($session_contact_is_technical_contact) {echo "Yes"; } else {echo "No";} ?></p>
|
||||
<p>Client Billing Contact: <?php if ($session_contact_is_billing_contact == $session_contact_id) {echo "Yes"; } else {echo "No";} ?></p>
|
||||
<br>
|
||||
<p>Login via: <?= $_SESSION['login_method'] ?> </p>
|
||||
<p>User ID: <?= $_SESSION['user_id'] ?> </p>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-lg-7 mb-4">
|
||||
|
||||
<table class="table table-bordered border border-dark">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th colspan="2">Your details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25">Name</th>
|
||||
<td><?= $contact_name ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<td><?= $contact_email ?></td>
|
||||
</tr>
|
||||
<?php if (!empty($contact_title)) { ?>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td><?= $contact_title ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<?php if (!empty($contact_department)) { ?>
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<td><?= $contact_department ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<td><?= $client_name ?></td>
|
||||
</tr>
|
||||
<?php if (!empty($contact_location)) { ?>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<td>
|
||||
<?= $contact_location ?>
|
||||
<?php if (!empty($contact_location_address)) { ?>
|
||||
<br><small class="text-muted"><?= $contact_location_address ?></small>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<tr>
|
||||
<th>Phone</th>
|
||||
<td>
|
||||
<?php if (empty($contact_phone)) { ?>
|
||||
<span class="text-muted">Not set</span>
|
||||
<?php } else { ?>
|
||||
<?= $contact_phone ?>
|
||||
<?php if (!empty($contact_extension)) { ?>
|
||||
<span class="text-muted">ext. <?= $contact_extension ?></span>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal" data-bs-target="#phoneModal">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Mobile</th>
|
||||
<td>
|
||||
<?php if (empty($contact_mobile)) { ?>
|
||||
<span class="text-muted">Not set</span>
|
||||
<?php } else { ?>
|
||||
<?= $contact_mobile ?>
|
||||
<?php } ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal" data-bs-target="#phoneModal">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<small class="text-muted">
|
||||
To change your name, email, title, department or location, raise a ticket and we will
|
||||
update them for you.
|
||||
</small>
|
||||
|
||||
<!-- // Show option to change password if auth provider is local -->
|
||||
<?php if ($_SESSION['login_method'] == 'local'): ?>
|
||||
<hr>
|
||||
<div class="col-md-6">
|
||||
<h4>Password</h4>
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
<div class="mb-3">
|
||||
<label>New Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
|
||||
<input type="password" class="form-control" minlength="8" required data-toggle="password" name="new_password" placeholder="Leave blank for no change" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" name="edit_profile" class="btn btn-primary text-bold mt-3"><i class="fas fa-check me-2"></i>Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<div class="col-lg-5 mb-4">
|
||||
|
||||
<table class="table table-bordered border border-dark">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th colspan="2">Portal access</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($contact_roles as $contact_role) { ?>
|
||||
<tr>
|
||||
<th class="w-50">
|
||||
<?= $contact_role[0] ?> contact
|
||||
<br><small class="text-muted fw-normal"><?= $contact_role[2] ?></small>
|
||||
</th>
|
||||
<td class="align-middle">
|
||||
<?php if ($contact_role[1]) { ?>
|
||||
<span class="p-2 badge text-bg-success">Yes</span>
|
||||
<?php } else { ?>
|
||||
<span class="p-2 badge text-bg-secondary">No</span>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<tr>
|
||||
<th>Sign in with</th>
|
||||
<td class="align-middle">
|
||||
<?= $login_method_display ?>
|
||||
<?php if ($login_method === 'local') { ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal" data-bs-target="#passwordModal">Change</button>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
Phone PIN
|
||||
<br><small class="text-muted fw-normal">Confirms it is you when you call</small>
|
||||
</th>
|
||||
<td class="align-middle">
|
||||
<?php if (empty($contact_pin)) { ?>
|
||||
<span class="text-muted">Not set</span>
|
||||
<?php } else { ?>
|
||||
<span class="font-monospace"><?= $contact_pin ?></span>
|
||||
<?php } ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
|
||||
data-bs-toggle="modal" data-bs-target="#pinModal">
|
||||
<?= empty($contact_pin) ? 'Set' : 'Change' ?>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-lg-5 mb-4">
|
||||
|
||||
<table class="table table-bordered border border-dark">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th colspan="2">Recent sign-ins</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (mysqli_num_rows($sql_logins) == 0) { ?>
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">No sign-ins recorded yet.</td>
|
||||
</tr>
|
||||
<?php } else { ?>
|
||||
<?php while ($row = mysqli_fetch_assoc($sql_logins)) { ?>
|
||||
<tr>
|
||||
<td><?= portalDateTime($row['log_created_at']) ?></td>
|
||||
<td class="font-monospace"><?= escapeHtml($row['log_ip']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<small class="text-muted">
|
||||
Somewhere here you do not recognise? Raise a ticket and change your password.
|
||||
</small>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 mb-4">
|
||||
|
||||
<table class="table table-bordered border border-dark">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th colspan="2">Recent activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (mysqli_num_rows($sql_actions) == 0) { ?>
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">Nothing recorded yet.</td>
|
||||
</tr>
|
||||
<?php } else { ?>
|
||||
<?php while ($row = mysqli_fetch_assoc($sql_actions)) { ?>
|
||||
<tr>
|
||||
<td class="text-nowrap"><?= portalDateTime($row['log_created_at']) ?></td>
|
||||
<td><?= escapeHtml($row['log_description']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a href="activity.php"><i class="fa fa-fw fa-list me-2"></i>View all your activity</a>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-muted">
|
||||
<small>Portal user ID <?= intval($_SESSION['user_id']) ?> — quote this if we ask for it.</small>
|
||||
</p>
|
||||
|
||||
|
||||
<div class="modal fade" id="phoneModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title text-white"><i class="fa fa-fw fa-phone me-2"></i>Your phone numbers</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="text-muted">
|
||||
Keeping these current means we can reach you about a ticket without hunting
|
||||
for a number. Clear a field to remove it.
|
||||
</p>
|
||||
|
||||
<label for="contactPhone">Phone / <span class="text-secondary">Extension</span></label>
|
||||
<div class="row g-2">
|
||||
<div class="col-8 mb-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-phone"></i></span>
|
||||
<input type="hidden" name="phone_country_code" value="<?= $contact_phone_country_code ?>">
|
||||
<input type="tel" class="form-control" id="contactPhone" name="phone"
|
||||
placeholder="Phone Number" maxlength="200" value="<?= $contact_phone_input ?>"
|
||||
data-itflow-phone="phone_country_code">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4 mb-3">
|
||||
<input type="text" class="form-control" id="contactExtension" name="extension"
|
||||
inputmode="numeric" maxlength="200" placeholder="ext."
|
||||
value="<?= $contact_extension ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="contactMobile">Mobile</label>
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-mobile-alt"></i></span>
|
||||
<input type="hidden" name="mobile_country_code" value="<?= $contact_mobile_country_code ?>">
|
||||
<input type="tel" class="form-control" id="contactMobile" name="mobile"
|
||||
placeholder="Mobile Number" maxlength="200" value="<?= $contact_mobile_input ?>"
|
||||
data-itflow-phone="mobile_country_code">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="set_contact_phone" class="btn btn-primary text-bold">
|
||||
<i class="fas fa-check me-2"></i>Save
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="pinModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title text-white">
|
||||
<i class="fa fa-fw fa-key me-2"></i><?= empty($contact_pin) ? 'Set a phone PIN' : 'Change your phone PIN' ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<p class="text-muted">
|
||||
We ask for this to confirm it is really you when you call. Pick something you
|
||||
will remember but that is not easy to guess.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="contactPin"><?= empty($contact_pin) ? 'New PIN' : 'Replace with' ?></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
|
||||
<input type="text" class="form-control" id="contactPin" name="pin"
|
||||
minlength="4" maxlength="255" placeholder="At least 4 characters"
|
||||
autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="set_contact_pin" class="btn btn-primary text-bold">
|
||||
<i class="fas fa-check me-2"></i><?= empty($contact_pin) ? 'Save PIN' : 'Update PIN' ?>
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($login_method === 'local') { ?>
|
||||
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="post.php" method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
|
||||
<div class="modal-header bg-dark">
|
||||
<h5 class="modal-title text-white"><i class="fa fa-fw fa-lock me-2"></i>Change your password</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newPassword">New password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
|
||||
<input type="password" class="form-control" id="newPassword" name="new_password"
|
||||
minlength="8" placeholder="At least 8 characters" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="edit_profile" class="btn btn-primary text-bold">
|
||||
<i class="fas fa-check me-2"></i>Save password
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php
|
||||
require_once 'includes/footer.php';
|
||||
|
||||
@@ -56,6 +56,7 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
|
||||
<script src="/js/keepalive.js"></script>
|
||||
<script src="/libs/DataTables/datatables.min.js"></script>
|
||||
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
|
||||
<script src="/js/phone_inputs.js"></script>
|
||||
|
||||
<!-- AdminLTE App -->
|
||||
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
||||
|
||||
182
js/app.js
182
js/app.js
@@ -621,7 +621,17 @@ function itflowInit() {
|
||||
});
|
||||
|
||||
// Phone inputs
|
||||
itflowStep('phone-inputs', initPhoneInputs);
|
||||
// js/phone_inputs.js is only loaded where phone fields exist - the agent
|
||||
// footer and the portal profile page. Referencing the bare identifier here
|
||||
// would throw a ReferenceError anywhere it is absent (setup/index.php loads
|
||||
// app.js on its own), and that throw happens while evaluating the argument,
|
||||
// before itflowStep's try/catch can contain it - taking every step after
|
||||
// this one down with it.
|
||||
itflowStep('phone-inputs', function () {
|
||||
if (typeof initPhoneInputs === 'function') {
|
||||
initPhoneInputs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// modal_footer.php re-loads this file on every ajax modal open, so run now if
|
||||
@@ -923,173 +933,3 @@ function itflowWatchNetworkIp(networkId, ipId) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* intl-tel-input on every phone field.
|
||||
*
|
||||
* ITFlow stores the dial code and the number in separate columns
|
||||
* (contact_phone_country_code / contact_phone and friends), so the library runs
|
||||
* in separateDialCode mode: its dropdown owns the dial code, the visible input
|
||||
* holds only the national number. That keeps the existing schema, the API and
|
||||
* every render site untouched.
|
||||
*
|
||||
* Markup contract:
|
||||
* <input type="hidden" name="phone_country_code" value="1">
|
||||
* <input type="tel" name="phone" data-itflow-phone="phone_country_code">
|
||||
*
|
||||
* Which country a field starts on:
|
||||
*
|
||||
* A saved record ALWAYS keeps the dial code it was saved with. Anything else
|
||||
* silently rewrites data - open a UK contact under a US client, close the
|
||||
* modal, and its +44 would have been saved back as +1.
|
||||
*
|
||||
* Context only decides WHICH country claims that code, since a code is not a
|
||||
* country (+1 covers 25 of them). In order: the address Country picker named
|
||||
* by data-itflow-phone-country-select on the input, then
|
||||
* data-itflow-phone-country on the form (the contact modals use this for the
|
||||
* client's country), then the same attribute on <body> (the company's).
|
||||
* If none of them claims the stored code, the code's canonical country wins -
|
||||
* priority 0 in the library's own data, i.e. US for +1 rather than whichever
|
||||
* territory happens to sort first.
|
||||
*
|
||||
* With nothing stored - a new record - context is the whole answer.
|
||||
*/
|
||||
function initPhoneInputs() {
|
||||
if (typeof window.intlTelInput !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[data-itflow-phone]').forEach(function (el) {
|
||||
// One field must never take the rest down with it. The first version of
|
||||
// this called v17 API names that v29 dropped, and because the whole
|
||||
// sweep shared one try/catch the throw on the first phone field meant
|
||||
// every mobile and fax input after it silently never initialised.
|
||||
try {
|
||||
initOnePhoneInput(el);
|
||||
} catch (e) {
|
||||
console.error('itflow phone input failed:', el.name, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initOnePhoneInput(el) {
|
||||
// modal_footer.php re-executes this file on every ajax modal open, so
|
||||
// without a guard each open would stack another instance on the input.
|
||||
if (el.dataset.itiReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = el.form;
|
||||
var hidden = form ? form.querySelector('input[name="' + el.dataset.itflowPhone + '"]') : null;
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
el.dataset.itiReady = '1';
|
||||
|
||||
var countrySelect = null;
|
||||
if (form && el.dataset.itflowPhoneCountrySelect) {
|
||||
countrySelect = form.querySelector('[name="' + el.dataset.itflowPhoneCountrySelect + '"]');
|
||||
}
|
||||
|
||||
var stored = (hidden.value || '').replace(/[^0-9]/g, '');
|
||||
var context = isoFromSelect(countrySelect)
|
||||
|| (form ? (form.dataset.itflowPhoneCountry || '') : '')
|
||||
|| (document.body.dataset.itflowPhoneCountry || '');
|
||||
|
||||
var initial = stored ? isoForDialCode(stored, context) : context;
|
||||
|
||||
var iti = window.intlTelInput(el, {
|
||||
initialCountry: initial.toLowerCase(),
|
||||
separateDialCode: true,
|
||||
countrySearch: true,
|
||||
formatAsYouType: true
|
||||
});
|
||||
|
||||
var sync = function () {
|
||||
var country = iti.getSelectedCountry();
|
||||
hidden.value = country && country.dialCode ? country.dialCode : '';
|
||||
};
|
||||
|
||||
// Only write back on load when we know the stored code survived. If it
|
||||
// resolved to nothing - a code no country uses - leave the field exactly as
|
||||
// saved rather than blanking it; the user picking a country will set it.
|
||||
var selected = iti.getSelectedCountry();
|
||||
if (!stored || (selected && selected.dialCode === stored)) {
|
||||
sync();
|
||||
}
|
||||
|
||||
el.addEventListener('countrychange', sync);
|
||||
|
||||
// Follow the address country picker while the form is open.
|
||||
if (countrySelect) {
|
||||
countrySelect.addEventListener('change', function () {
|
||||
var iso2 = isoFromSelect(countrySelect);
|
||||
if (iso2) {
|
||||
iti.setSelectedCountry(iso2.toLowerCase());
|
||||
sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Belt and braces for a form saved without ever opening the dropdown.
|
||||
if (form && !form.dataset.itiSyncBound) {
|
||||
form.dataset.itiSyncBound = '1';
|
||||
form.addEventListener('submit', function () {
|
||||
form.querySelectorAll('input[data-itflow-phone]').forEach(function (input) {
|
||||
var target = form.querySelector('input[name="' + input.dataset.itflowPhone + '"]');
|
||||
var inst = window.intlTelInput.getInstance(input);
|
||||
if (target && inst) {
|
||||
var c = inst.getSelectedCountry();
|
||||
target.value = c && c.dialCode ? c.dialCode : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO2 for the country a <select> is currently on.
|
||||
*
|
||||
* Each <option> carries data-iso2, stamped by PHP from $country_iso2_array, so
|
||||
* the 194-entry name -> ISO2 map never has to be duplicated into JS or shipped
|
||||
* to the browser as a blob. It also sidesteps an inline <script>, which the
|
||||
* CSP work would have to unpick later.
|
||||
*/
|
||||
function isoFromSelect(select) {
|
||||
if (!select) {
|
||||
return '';
|
||||
}
|
||||
var option = select.selectedOptions ? select.selectedOptions[0] : null;
|
||||
return option && option.dataset ? (option.dataset.iso2 || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country to show for a stored dial code.
|
||||
*
|
||||
* Prefers the contextual country when it actually uses that code, otherwise the
|
||||
* canonical one. Plain .find() is wrong here - the library's data is in name
|
||||
* order, so +1 would resolve to American Samoa. Priority 0 is the library's own
|
||||
* marker for the country that owns a shared code.
|
||||
*/
|
||||
function isoForDialCode(dialCode, preferIso2) {
|
||||
if (!dialCode || typeof window.intlTelInput.getAllCountries !== 'function') {
|
||||
return '';
|
||||
}
|
||||
var matches = window.intlTelInput.getAllCountries().filter(function (c) {
|
||||
return c.dialCode === dialCode;
|
||||
});
|
||||
if (!matches.length) {
|
||||
return '';
|
||||
}
|
||||
if (preferIso2) {
|
||||
var preferred = matches.find(function (c) {
|
||||
return c.iso2 === preferIso2.toLowerCase();
|
||||
});
|
||||
if (preferred) {
|
||||
return preferred.iso2;
|
||||
}
|
||||
}
|
||||
return matches.reduce(function (best, c) {
|
||||
return (c.priority || 0) < (best.priority || 0) ? c : best;
|
||||
}).iso2;
|
||||
}
|
||||
|
||||
194
js/phone_inputs.js
Normal file
194
js/phone_inputs.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* ITFlow - intl-tel-input wiring, shared by the agent side and the client portal.
|
||||
*
|
||||
* Lived in js/app.js until the portal needed it too. app.js cannot be loaded in
|
||||
* the portal - it calls DataTables, TinyMCE, Tom Select, Flatpickr and IMask
|
||||
* unconditionally with no typeof guards, none of which the portal loads - so
|
||||
* the choice was to copy this or to split it out. Copying it would have left
|
||||
* two implementations of a subtle piece of logic to drift apart, so it is here.
|
||||
*
|
||||
* app.js still drives it on the agent side via itflowStep('phone-inputs', ...),
|
||||
* which is what re-runs it for ajax modals. The portal has no app.js, so this
|
||||
* file self-starts on DOMContentLoaded below. Both paths are safe together:
|
||||
* initOnePhoneInput() carries its own re-entry guard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* intl-tel-input on every phone field.
|
||||
*
|
||||
* ITFlow stores the dial code and the number in separate columns
|
||||
* (contact_phone_country_code / contact_phone and friends), so the library runs
|
||||
* in separateDialCode mode: its dropdown owns the dial code, the visible input
|
||||
* holds only the national number. That keeps the existing schema, the API and
|
||||
* every render site untouched.
|
||||
*
|
||||
* Markup contract:
|
||||
* <input type="hidden" name="phone_country_code" value="1">
|
||||
* <input type="tel" name="phone" data-itflow-phone="phone_country_code">
|
||||
*
|
||||
* Which country a field starts on:
|
||||
*
|
||||
* A saved record ALWAYS keeps the dial code it was saved with. Anything else
|
||||
* silently rewrites data - open a UK contact under a US client, close the
|
||||
* modal, and its +44 would have been saved back as +1.
|
||||
*
|
||||
* Context only decides WHICH country claims that code, since a code is not a
|
||||
* country (+1 covers 25 of them). In order: the address Country picker named
|
||||
* by data-itflow-phone-country-select on the input, then
|
||||
* data-itflow-phone-country on the form (the contact modals use this for the
|
||||
* client's country), then the same attribute on <body> (the company's).
|
||||
* If none of them claims the stored code, the code's canonical country wins -
|
||||
* priority 0 in the library's own data, i.e. US for +1 rather than whichever
|
||||
* territory happens to sort first.
|
||||
*
|
||||
* With nothing stored - a new record - context is the whole answer.
|
||||
*/
|
||||
function initPhoneInputs() {
|
||||
if (typeof window.intlTelInput !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[data-itflow-phone]').forEach(function (el) {
|
||||
// One field must never take the rest down with it. The first version of
|
||||
// this called v17 API names that v29 dropped, and because the whole
|
||||
// sweep shared one try/catch the throw on the first phone field meant
|
||||
// every mobile and fax input after it silently never initialised.
|
||||
try {
|
||||
initOnePhoneInput(el);
|
||||
} catch (e) {
|
||||
console.error('itflow phone input failed:', el.name, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initOnePhoneInput(el) {
|
||||
// modal_footer.php re-executes this file on every ajax modal open, so
|
||||
// without a guard each open would stack another instance on the input.
|
||||
if (el.dataset.itiReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = el.form;
|
||||
var hidden = form ? form.querySelector('input[name="' + el.dataset.itflowPhone + '"]') : null;
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
el.dataset.itiReady = '1';
|
||||
|
||||
var countrySelect = null;
|
||||
if (form && el.dataset.itflowPhoneCountrySelect) {
|
||||
countrySelect = form.querySelector('[name="' + el.dataset.itflowPhoneCountrySelect + '"]');
|
||||
}
|
||||
|
||||
var stored = (hidden.value || '').replace(/[^0-9]/g, '');
|
||||
var context = isoFromSelect(countrySelect)
|
||||
|| (form ? (form.dataset.itflowPhoneCountry || '') : '')
|
||||
|| (document.body.dataset.itflowPhoneCountry || '');
|
||||
|
||||
var initial = stored ? isoForDialCode(stored, context) : context;
|
||||
|
||||
var iti = window.intlTelInput(el, {
|
||||
initialCountry: initial.toLowerCase(),
|
||||
separateDialCode: true,
|
||||
countrySearch: true,
|
||||
formatAsYouType: true
|
||||
});
|
||||
|
||||
var sync = function () {
|
||||
var country = iti.getSelectedCountry();
|
||||
hidden.value = country && country.dialCode ? country.dialCode : '';
|
||||
};
|
||||
|
||||
// Only write back on load when we know the stored code survived. If it
|
||||
// resolved to nothing - a code no country uses - leave the field exactly as
|
||||
// saved rather than blanking it; the user picking a country will set it.
|
||||
var selected = iti.getSelectedCountry();
|
||||
if (!stored || (selected && selected.dialCode === stored)) {
|
||||
sync();
|
||||
}
|
||||
|
||||
el.addEventListener('countrychange', sync);
|
||||
|
||||
// Follow the address country picker while the form is open.
|
||||
if (countrySelect) {
|
||||
countrySelect.addEventListener('change', function () {
|
||||
var iso2 = isoFromSelect(countrySelect);
|
||||
if (iso2) {
|
||||
iti.setSelectedCountry(iso2.toLowerCase());
|
||||
sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Belt and braces for a form saved without ever opening the dropdown.
|
||||
if (form && !form.dataset.itiSyncBound) {
|
||||
form.dataset.itiSyncBound = '1';
|
||||
form.addEventListener('submit', function () {
|
||||
form.querySelectorAll('input[data-itflow-phone]').forEach(function (input) {
|
||||
var target = form.querySelector('input[name="' + input.dataset.itflowPhone + '"]');
|
||||
var inst = window.intlTelInput.getInstance(input);
|
||||
if (target && inst) {
|
||||
var c = inst.getSelectedCountry();
|
||||
target.value = c && c.dialCode ? c.dialCode : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO2 for the country a <select> is currently on.
|
||||
*
|
||||
* Each <option> carries data-iso2, stamped by PHP from $country_iso2_array, so
|
||||
* the 194-entry name -> ISO2 map never has to be duplicated into JS or shipped
|
||||
* to the browser as a blob. It also sidesteps an inline <script>, which the
|
||||
* CSP work would have to unpick later.
|
||||
*/
|
||||
function isoFromSelect(select) {
|
||||
if (!select) {
|
||||
return '';
|
||||
}
|
||||
var option = select.selectedOptions ? select.selectedOptions[0] : null;
|
||||
return option && option.dataset ? (option.dataset.iso2 || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country to show for a stored dial code.
|
||||
*
|
||||
* Prefers the contextual country when it actually uses that code, otherwise the
|
||||
* canonical one. Plain .find() is wrong here - the library's data is in name
|
||||
* order, so +1 would resolve to American Samoa. Priority 0 is the library's own
|
||||
* marker for the country that owns a shared code.
|
||||
*/
|
||||
function isoForDialCode(dialCode, preferIso2) {
|
||||
if (!dialCode || typeof window.intlTelInput.getAllCountries !== 'function') {
|
||||
return '';
|
||||
}
|
||||
var matches = window.intlTelInput.getAllCountries().filter(function (c) {
|
||||
return c.dialCode === dialCode;
|
||||
});
|
||||
if (!matches.length) {
|
||||
return '';
|
||||
}
|
||||
if (preferIso2) {
|
||||
var preferred = matches.find(function (c) {
|
||||
return c.iso2 === preferIso2.toLowerCase();
|
||||
});
|
||||
if (preferred) {
|
||||
return preferred.iso2;
|
||||
}
|
||||
}
|
||||
return matches.reduce(function (best, c) {
|
||||
return (c.priority || 0) < (best.priority || 0) ? c : best;
|
||||
}).iso2;
|
||||
}
|
||||
/*
|
||||
* Self-start for pages with no app.js - the client portal. On the agent side
|
||||
* app.js has already run by the time this fires and every input carries its
|
||||
* data-itiReady guard, so this is a no-op there rather than a double init.
|
||||
*/
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPhoneInputs);
|
||||
} else {
|
||||
initPhoneInputs();
|
||||
}
|
||||
Reference in New Issue
Block a user