PageSourceSearch

https://ninjatms.com/app/login.php

html ninjatms.com collected 2026-10-01 15:18:08 UTC 42,432 bytes, 1,277 lines download raw bytes

1<!doctype html>
2<html lang="en">
3<head>
4	<title>Log in</title>
5	<!-- Google tag (gtag.js) -->
vendor: 65 bytes, lines 5-6
5
6<script async src="https://www.googletagmanager.com/gtag/js?id=
6G-NZRY30GYKV
vendor: 11 bytes, line 6
6"></script>
6<script>
vendor: 124 bytes, line 6
6window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '
6G-NZRY30GYKV
vendor: 3 bytes, line 6
6');
6</script>
6<meta charset="utf-8">
7<meta name="viewport" content="width=device-width, initial-scale=1">
8<meta name="mobile-web-app-capable" content="yes">
9<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10<meta name="robots" content="noindex, nofollow, noarchive">
11<meta name="csrf-token" content="f3996184780c4c98d107f77b655efb58ff96d767e4fc37caa603fd04045fc06a_1790867886">
12<link rel="preconnect" href="https://fonts.googleapis.com">
13<link rel="preconnect" href="https://cdnjs.cloudflare.com">
14<link rel="preconnect" href="https://cdn.jsdelivr.net">
15<link rel="preconnect" href="https://code.jquery.com">
16<!--<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>-->
17<!--<link rel="stylesheet" referrerpolicy="no-referrer" href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,700;0,800;1,400;1,700;1,800&display=swap">
18<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@100;200;400;600;800&display=swap" rel="stylesheet">-->
19<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
20<link href="https://cdn.datatables.net/v/bs5/dt-1.13.8/b-2.4.2/b-colvis-2.4.2/r-2.5.0/datatables.min.css" rel="stylesheet">
21
22
23<link rel="stylesheet" referrerpolicy="no-referrer" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/select2.min.css">
24<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/select2-bootstrap-5-theme.min.css">
25
26<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/daterangepicker/daterangepicker.css" id="daterangecss">
27<!-- url changes based on lightmode/darkmode -->
28
29
30<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" integrity="sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
31<!-- migrate to 6.7-->
32<!-- migrate to 7.0 -->
33<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer">
34<link rel="stylesheet" referrerpolicy="no-referrer" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
35<!--<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/apexcharts/3.40.0/apexcharts.min.css" integrity="sha512-tJYqW5NWrT0JEkWYxrI4IK2jvT7PAiOwElIGTjALSyr8ZrilUQf+gjw2z6woWGSZqeXASyBXUr+WbtqiQgxUYg==" crossorigin="anonymous" referrerpolicy="no-referrer">-->
36<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/apexcharts/5.3.5/apexcharts.min.css" integrity="sha512-IqtQ7LKr3He47p7HjxynmqZfN07VljNkdGyGDdDJ//f1r6bT0IEKQf2CCtSgun/pvbFlNnPDMRrMSQhmSxmSSg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
37<link rel="stylesheet" href="css/style.css?722968"><link rel="stylesheet" href="css/light-mode.css?487997"><!--
38<link rel="shortcut icon" href="https://ninjatms.com/favicon.ico" type="image/x-icon" />
39<link rel="apple-touch-icon" sizes="76x76" href="ico/apple-touch-icon.png">
40<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png">
41<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png">
42<link rel="manifest" href="ico/site.webmanifest">
43-->
44<link rel="icon" type="image/png" href="https://ninjatms.com/icons/favicon-48x48.png" sizes="48x48">
45<link rel="icon" type="image/svg+xml" href="https://ninjatms.com/icons/favicon.svg">
46<link rel="shortcut icon" href="https://ninjatms.com/icons/favicon.ico">
47<link rel="apple-touch-icon" sizes="180x180" href="https://ninjatms.com/icons/apple-touch-icon.png">
48<link rel="manifest" href="https://ninjatms.com/icons/site.webmanifest">
49<style>
50.rightcanvasbutton{
51    position: fixed;
52	right: 10px;
53    bottom: 10px;
54}
55.dropdown:hover .dropdown-menu {
56/* this is required for dropdown hover because it was taken out of bootstrap */
57	display: block;
58	margin-top: 0;
59}
60.toast-container {
61    position: fixed;
62    bottom: 0;
63    right: 0;
64    z-index: 1050; /* Adjust as needed */
65    padding: 3rem;
66}
67/* 
68styles for chat  start
69*/ 
70#offcanvasRight {
71	width: 34%;
72}
73#chat_container {
74	display: flex;
75	flex-direction: column;
76	height: 100%;
77}
78/* Left column: contact list spans full height of offcanvas */
79#offcanvasRight .chat-tabs-col {
80	display: flex;
81	flex-direction: column;
82	border-right: 1px solid var(--bs-border-color, rgba(0, 0, 0, .1));
83	background: var(--bs-body-bg, #fff);
84	overflow: hidden;
85}
86#offcanvasRight .chat-tabs-search {
87	position: relative;
88	padding: 6px 10px;
89	border-bottom: 1px solid var(--bs-border-color, rgba(0, 0, 0, .08));
90	background: var(--bs-tertiary-bg, #f8f9fa);
91}
92#offcanvasRight .chat-tabs-search .fas {
93	position: absolute;
94	left: 22px;
95	top: 50%;
96	transform: translateY(-50%);
97	color: var(--bs-secondary-color, #6c757d);
98	font-size: 0.8rem;
99	pointer-events: none;
100}
101#offcanvasRight #chat-tabs-filter {
102	padding-left: 28px;
103	border-radius: 6px;
104}
105/* Sticky contact header pinned at the top of every conversation pane. Avatar + name on the left,
106   email/phone quick-actions on the right. Sized to align with the contacts search bar across the
107   divider so the two read as a unified top row. Subtle shadow conveys it floats above scrolled content. */
108.chat-contact-header {
109	position: sticky;
110	top: -12px; /* i had to do this because 12px see-through space above this is rendering */
111	z-index: 2;
112	display: flex;
113	align-items: center;
114	gap: 10px;
115	padding: 6px 10px;
116	background: var(--bs-body-bg, #fff);
117	border-bottom: 1px solid var(--bs-border-color, rgba(0, 0, 0, .08));
118	box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
119	margin: -12px -12px 8px; /* counter the .tab-content padding so the header spans full width */
120}
121.chat-contact-header .chat-contact-avatar-wrap {
122	position: relative;
123	flex-shrink: 0;
124	width: 28px;
125	height: 28px;
126	line-height: 1; /* kill any leftover text leading so img/initials cases render the same height */
127}
128.chat-contact-header .chat-contact-avatar {
129	display: flex;
130	align-items: center;
131	justify-content: center;
132	width: 28px;
133	height: 28px;
134	border-radius: 50%;
135	color: #fff;
136	font-size: 0.7rem;
137	font-weight: 600;
138	letter-spacing: 0.02em;
139	overflow: hidden;
140	line-height: 1;
141}
142.chat-contact-header .chat-contact-avatar img {
143	display: block;       /* default <img> is inline → carries baseline descender whitespace that
144							 makes pfp avatars render taller than initials avatars. block strips it. */
145	width: 100%;
146	height: 100%;
147	object-fit: cover;
148}
149/* Centered loading spinner shown briefly while get-chat-messages resolves. */
150.chat-loading {
151	display: flex;
152	align-items: center;
153	justify-content: center;
154	min-height: 200px;
155	color: var(--bs-secondary-color, #6c757d);
156}
157.chat-contact-header .chat-contact-online {
158	position: absolute;
159	right: -1px;
160	bottom: -1px;
161	width: 9px;
162	height: 9px;
163	border-radius: 50%;
164	border: 2px solid var(--bs-body-bg, #fff);
165}
166.chat-contact-header .chat-contact-online.is-online  {
167	background: #28a745;
168	animation: chat-contact-online-glow 2s ease-in-out infinite;
169}
170.chat-contact-header .chat-contact-online.is-offline { background: #adb5bd; }
171@keyframes chat-contact-online-glow {
172	0%, 100% {
173		box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.55), 0 0 4px 1px rgba(40, 167, 69, 0.5);
174	}
175	50% {
176		box-shadow: 0 0 0 4px rgba(40, 167, 69, 0),    0 0 8px 3px rgba(40, 167, 69, 0.85);
177	}
178}
179@media (prefers-reduced-motion: reduce) {
180	.chat-contact-header .chat-contact-online.is-online { animation: none; }
181}
182.chat-contact-header .chat-contact-online.is-online.is-ai-support {
183	background: #a855f7;
184	animation: chat-contact-online-glow-ai 1.6s ease-in-out infinite;
185}
186@keyframes chat-contact-online-glow-ai {
187	0%, 100% {
188		box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.85), 0 0 6px 2px rgba(168, 85, 247, 0.85);
189	}
190	50% {
191		box-shadow: 0 0 0 6px rgba(168, 85, 247, 0),    0 0 14px 5px rgba(168, 85, 247, 1);
192	}
193}
194@media (prefers-reduced-motion: reduce) {
195	.chat-contact-header .chat-contact-online.is-online.is-ai-support { animation: none; }
196}
197.chat-contact-header .chat-contact-name {
198	flex: 0 1 auto;
199	min-width: 0;
200	font-size: 0.9rem;
201	color: var(--bs-body-color, #212529);
202}
203.chat-contact-header .chat-contact-meta {
204	display: flex;
205	align-items: center;
206	gap: 12px;
207	color: var(--bs-secondary-color, #6c757d);
208	flex-shrink: 0;
209}
210.chat-contact-header .chat-contact-link {
211	color: inherit;
212	text-decoration: none;
213	display: inline-flex;
214	align-items: center;
215	gap: 4px;
216	transition: color 0.15s ease;
217	max-width: 220px;
218}
219.chat-contact-header .chat-contact-link:hover {
220	color: var(--bs-primary, #0d6efd);
221}
222.chat-contact-header .chat-contact-link i {
223	opacity: 0.7;
224}
225.chat-contact-header .chat-contact-link-text {
226	overflow: hidden;
227	text-overflow: ellipsis;
228	white-space: nowrap;
229}
230@media (max-width: 575.98px) {
231	/* On narrow screens, hide the inline email/phone text — keep just icons as touch targets. */
232	.chat-contact-header .chat-contact-link-text { display: none; }
233}
234#offcanvasRight .nav-tabs {
235	margin: 0;
236	padding: 4px 0;
237	flex-grow: 1;
238	overflow-y: auto;
239	border: 0;
240	width: 100%;
241	display: block;
242}
243#offcanvasRight .nav-tabs .nav-link {
244	display: flex;
245	align-items: center;
246	gap: 10px;
247	width: 100%;
248	text-align: left;
249	border: 0;
250	border-radius: 0;
251	padding: 8px 12px;
252	color: var(--bs-body-color, #212529);
253	background: transparent;
254	font-size: 0.9rem;
255	cursor: pointer;
256	position: relative;
257	transition: background-color .12s ease;
258}
259#offcanvasRight .nav-tabs .nav-link:hover {
260	background: var(--bs-tertiary-bg, rgba(0, 0, 0, .04));
261}
262#offcanvasRight .nav-tabs .nav-link.active {
263	background: var(--bs-primary-bg-subtle, #cfe2ff);
264	color: var(--bs-primary-text-emphasis, #052c65);
265	font-weight: 600;
266	border-left: 3px solid var(--bs-primary, #0d6efd);
267	padding-left: 9px;
268}
269#offcanvasRight .nav-tabs .nav-link .chat-online-indicator {
270	flex: 0 0 auto;
271	font-size: 0.55rem;
272	width: 10px;
273	display: inline-flex;
274	align-items: center;
275	justify-content: center;
276}
277#offcanvasRight .nav-tabs .nav-link .chat-online-indicator .fa-circle.text-success {
278	animation: chat-online-pulse 2s ease-in-out infinite;
279	border-radius: 50%;
280	box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7);
281}
282@keyframes chat-online-pulse {
283	0% {
284		box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7), 0 0 4px rgba(25, 135, 84, 0.6);
285	}
286	70% {
287		box-shadow: 0 0 0 6px rgba(25, 135, 84, 0), 0 0 8px rgba(25, 135, 84, 0.9);
288	}
289	100% {
290		box-shadow: 0 0 0 0 rgba(25, 135, 84, 0), 0 0 4px rgba(25, 135, 84, 0.6);
291	}
292}
293@media (prefers-reduced-motion: reduce) {
294	#offcanvasRight .nav-tabs .nav-link .chat-online-indicator .fa-circle.text-success {
295		animation: none;
296	}
297}
298#offcanvasRight .nav-tabs .nav-link .chat-online-indicator.is-ai-support .fa-circle.text-success {
299	animation: chat-online-pulse-ai 1.6s ease-in-out infinite;
300	color: #a855f7 !important;
301}
302@keyframes chat-online-pulse-ai {
303	0%   { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.85), 0 0 6px 2px rgba(168, 85, 247, 0.85); }
304	70%  { box-shadow: 0 0 0 8px rgba(168, 85, 247, 0),  0 0 14px 5px rgba(168, 85, 247, 1); }
305	100% { box-shadow: 0 0 0 0 rgba(168, 85, 247, 0),    0 0 6px 2px rgba(168, 85, 247, 0.85); }
306}
307@media (prefers-reduced-motion: reduce) {
308	#offcanvasRight .nav-tabs .nav-link .chat-online-indicator.is-ai-support .fa-circle.text-success {
309		animation: none;
310	}
311}
312#offcanvasRight .nav-tabs .nav-link .chat-user {
313	flex: 1 1 auto;
314	overflow: hidden;
315	text-overflow: ellipsis;
316	white-space: nowrap;
317	min-width: 0;
318}
319#offcanvasRight .nav-tabs .nav-link .badge {
320	flex: 0 0 auto;
321}
322/* Right column: messages + chat input */
323#offcanvasRight .chat-content-col {
324	min-width: 0; /* allow flex children to shrink */
325}
326#offcanvasRight .tab-content {
327	flex-grow: 1;
328	overflow-y: auto;
329	padding: 12px;
330}
331#offcanvasRight .tab-pane {
332	height: auto;
333	min-height: 100%;
334}
335/* Pin chat content to the bottom of the pane (chat-app style) so the typing
336   indicator sits right under the last bubble instead of floating above empty space. */
337#offcanvasRight .tab-pane.chat > :first-child {
338	margin-top: auto;
339}
340#chat_form, .chat-form-bar {
341	position: static;
342	width: 100%;
343	padding: 10px 12px;
344	margin: 0;
345	border-top: 1px solid var(--bs-border-color, rgba(0, 0, 0, .08));
346	background: var(--bs-body-bg, #fff);
347	flex: 0 0 auto;
348	z-index: 1;
349}
350.chat-typing-indicator {
351	display: flex;
352	align-items: center;
353	gap: 8px;
354	padding: 6px 12px;
355	font-size: 0.78rem;
356	color: var(--bs-secondary-color, #6c757d);
357	font-style: italic;
358	background: var(--bs-tertiary-bg, #f1f3f5);
359	border-radius: 14px;
360	margin: 8px 8px;
361	width: fit-content;
362	max-width: 80%;
363}
364/* Live draft text inside the typing indicator. Shown when a tenant chatting with a support
365   user is currently composing — replaces the generic "X is typing" copy with their actual
366   in-progress message. Wraps to multiple lines if the draft is long. */
367.chat-typing-indicator .chat-typing-preview {
368	color: var(--bs-body-color, #212529);
369	font-style: italic;
370	white-space: pre-wrap;
371	word-break: break-word;
372}
373.chat-typing-indicator .chat-typing-preview::before { content: '"'; opacity: 0.5; }
374.chat-typing-indicator .chat-typing-preview::after  { content: '"'; opacity: 0.5; }
375.chat-typing-dots {
376	display: inline-flex;
377	gap: 3px;
378}
379.chat-typing-dots span {
380	display: inline-block;
381	width: 6px;
382	height: 6px;
383	border-radius: 50%;
384	background: var(--bs-secondary-color, #6c757d);
385	opacity: 0.4;
386	animation: chat-typing-bounce 1.2s ease-in-out infinite;
387}
388.chat-typing-dots span:nth-child(2) { animation-delay: 0.15s; }
389.chat-typing-dots span:nth-child(3) { animation-delay: 0.3s; }
390@keyframes chat-typing-bounce {
391	0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
392	30% { opacity: 1; transform: translateY(-3px); }
393}
394@media (prefers-reduced-motion: reduce) {
395	.chat-typing-dots span { animation: none; opacity: 0.6; }
396}
397.chat-typing-mini {
398	display: inline-flex;
399	align-items: center;
400	gap: 2px;
401	margin-left: 6px;
402	flex: 0 0 auto;
403}
404.chat-typing-mini span {
405	display: inline-block;
406	width: 4px;
407	height: 4px;
408	border-radius: 50%;
409	background: var(--bs-primary, #0d6efd);
410	opacity: 0.4;
411	animation: chat-typing-bounce 1.2s ease-in-out infinite;
412}
413.chat-typing-mini span:nth-child(2) { animation-delay: 0.15s; }
414.chat-typing-mini span:nth-child(3) { animation-delay: 0.3s; }
415@media (prefers-reduced-motion: reduce) {
416	.chat-typing-mini span { animation: none; opacity: 0.7; }
417}
418
419/* Bounce animation for chat-notification badges (nav icon + per-contact).
420   Custom keyframe instead of animate.css's bounceIn because Bootstrap's
421   .translate-middle locks `transform: translate(-50%,-50%) !important` on
422   these badges, which kills animate.css's transform-driven keyframes.
423   Composing translate() with scale() here lets the bounce play without
424   losing the centering. Applied/restarted from chat.js _bounceBadge(). */
425@keyframes chat-badge-bounce {
426	0%   { transform: translate(-50%, -50%) scale(0.3); opacity: 0; }
427	20%  { transform: translate(-50%, -50%) scale(1.15); opacity: 1; }
428	40%  { transform: translate(-50%, -50%) scale(0.9); }
429	60%  { transform: translate(-50%, -50%) scale(1.04); }
430	80%  { transform: translate(-50%, -50%) scale(0.97); }
431	100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
432}
433.chat-badge-bounce {
434	animation: chat-badge-bounce 0.5s cubic-bezier(0.215, 0.61, 0.355, 1) both !important;
435}
436@media (prefers-reduced-motion: reduce) {
437	.chat-badge-bounce { animation: none !important; }
438}
439
440/* Reusable empty-state — chat (no chats yet) and alerts (no alerts yet) share this look. */
441.empty-state {
442	display: flex;
443	flex-direction: column;
444	align-items: center;
445	justify-content: center;
446	padding: 48px 24px;
447	color: var(--bs-secondary-color, #6c757d);
448	text-align: center;
449	min-height: 50vh;
450	gap: 4px;
451}
452.empty-state-icon {
453	font-size: 3rem;
454	margin-bottom: 14px;
455	color: var(--bs-secondary-color, #6c757d);
456	opacity: 0.35;
457}
458.empty-state-icon.pulse {
459	animation: empty-state-pulse 2.4s ease-in-out infinite;
460}
461.empty-state-title {
462	font-size: 1.05rem;
463	font-weight: 600;
464	color: var(--bs-body-color, #212529);
465	margin-bottom: 4px;
466}
467.empty-state-desc {
468	font-size: 0.88rem;
469	max-width: 320px;
470	line-height: 1.45;
471	color: var(--bs-secondary-color, #6c757d);
472}
473@keyframes empty-state-pulse {
474	0%, 100% { opacity: 0.25; transform: scale(1); }
475	50%      { opacity: 0.55; transform: scale(1.05); }
476}
477@media (prefers-reduced-motion: reduce) {
478	.empty-state-icon.pulse { animation: none; }
479}
480/* Support welcome card — pinned at the top of every conversation with UID 2 (Ted). Message history
481   flows beneath it; doubles as a standing reassurance that a real human reads every message. */
482.empty-state-support {
483	padding: 36px 24px;
484	gap: 6px;
485}
486.empty-state-support.support-header {
487	min-height: 0;
488	padding: 24px 20px 20px;
489	border-bottom: 1px solid var(--bs-border-color, #dee2e6);
490	margin-bottom: 8px;
491}
492.empty-state-support .support-avatar-wrap {
493	position: relative;
494	width: 76px;
495	height: 76px;
496	margin-bottom: 12px;
497}
498.empty-state-support .support-avatar {
499	width: 76px;
500	height: 76px;
501	border-radius: 50%;
502	object-fit: cover;
503	box-shadow: 0 4px 14px rgba(13, 110, 253, 0.25);
504	background: #1f2937;
505	padding: 6px;
506}
507.empty-state-support .support-online-dot {
508	position: absolute;
509	right: 2px;
510	bottom: 2px;
511	width: 16px;
512	height: 16px;
513	border-radius: 50%;
514	background: #28a745;
515	border: 3px solid var(--bs-body-bg, #fff);
516	box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.25);
517	animation: support-pulse 2s ease-in-out infinite;
518}
519@keyframes support-pulse {
520	0%, 100% { box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.25); }
521	50%      { box-shadow: 0 0 0 6px rgba(40, 167, 69, 0.05); }
522}
523.empty-state-support .support-title {
524	font-size: 1.25rem;
525	font-weight: 700;
526}
527.empty-state-support .support-desc {
528	max-width: 360px;
529	font-size: 0.92rem;
530	color: var(--bs-body-color, #212529);
531	margin-bottom: 14px;
532}
533.empty-state-support .support-perks {
534	max-width: 360px;
535	width: 100%;
536	margin: 0;
537	padding: 0;
538	text-align: left;
539	font-size: 0.85rem;
540	color: var(--bs-secondary-color, #6c757d);
541}
542.empty-state-support .support-perks li {
543	display: flex;
544	align-items: flex-start;
545	gap: 10px;
546	padding: 6px 0;
547	line-height: 1.4;
548}
549.empty-state-support .support-perks li i {
550	flex: 0 0 18px;
551	margin-top: 2px;
552	text-align: center;
553}
554@media (prefers-reduced-motion: reduce) {
555	.empty-state-support .support-online-dot { animation: none; }
556}
557
558/* ── AI Support (Jim) welcome card — distinct surface, NOT a copy of the
559   human Support card. Horizontal layout, tech-feel stripe, tag pills. ─── */
560.ai-welcome {
561	position: relative;
562	background: linear-gradient(140deg, rgba(30,58,138,0.18) 0%, rgba(15,23,42,0.45) 100%);
563	border: 1px solid rgba(96,165,250,0.22);
564	border-radius: 12px;
565	padding: 14px 16px 12px;
566	margin: 8px 8px 14px;
567	overflow: hidden;
568}
569.ai-welcome-stripe {
570	position: absolute;
571	top: 0; left: 0; right: 0;
572	height: 3px;
573	background: linear-gradient(90deg, #60a5fa 0%, #3b82f6 35%, #22c55e 65%, #60a5fa 100%);
574	background-size: 300% 100%;
575	animation: ai-welcome-stripe-flow 6s linear infinite;
576}
577@keyframes ai-welcome-stripe-flow {
578	0%   { background-position:   0% 50%; }
579	100% { background-position: 300% 50%; }
580}
581@media (prefers-reduced-motion: reduce) {
582	.ai-welcome-stripe { animation: none; }
583}
584.ai-welcome-row {
585	display: flex;
586	align-items: flex-start;
587	gap: 14px;
588}
589.ai-welcome-avatar-wrap {
590	position: relative;
591	flex: 0 0 auto;
592}
593.ai-welcome-avatar {
594	width: 54px;
595	height: 54px;
596	border-radius: 12px;
597	display: flex;
598	align-items: center;
599	justify-content: center;
600	background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #1d4ed8 100%);
601	color: #fff;
602	font-size: 24px;
603	box-shadow: 0 0 0 1px rgba(255,255,255,0.06), 0 6px 18px rgba(29,78,216,0.45);
604}
605.ai-welcome-status {
606	position: absolute;
607	right: -2px;
608	bottom: -2px;
609	width: 12px;
610	height: 12px;
611	border-radius: 50%;
612	background: #a855f7;
613	border: 2px solid #0f172a;
614	animation: ai-welcome-pulse 1.6s ease-in-out infinite;
615}
616@keyframes ai-welcome-pulse {
617	0%, 100% { box-shadow: 0 0 0 0 rgba(168,85,247,0.85), 0 0 6px 2px rgba(168,85,247,0.85); }
618	50%      { box-shadow: 0 0 0 6px rgba(168,85,247,0),    0 0 12px 4px rgba(168,85,247,1); }
619}
620@media (prefers-reduced-motion: reduce) {
621	.ai-welcome-status { animation: none; }
622}
623.ai-welcome-body {
624	flex: 1 1 auto;
625	min-width: 0;
626}
627.ai-welcome-eyebrow {
628	font-size: 0.7rem;
629	font-weight: 600;
630	letter-spacing: 0.6px;
631	text-transform: uppercase;
632	color: #93c5fd;
633	margin-bottom: 2px;
634}
635.ai-welcome-eyebrow i {
636	margin-right: 4px;
637}
638.ai-welcome-name {
639	font-size: 1.4rem;
640	font-weight: 700;
641	color: var(--bs-body-color, #f8fafc);
642	line-height: 1.1;
643	margin-bottom: 4px;
644}
645.ai-welcome-tagline {
646	font-size: 0.85rem;
647	color: var(--bs-secondary-color, #cbd5e1);
648	line-height: 1.4;
649	margin-bottom: 10px;
650}
651.ai-welcome-tags {
652	display: flex;
653	flex-wrap: wrap;
654	gap: 6px;
655}
656.ai-tag {
657	display: inline-flex;
658	align-items: center;
659	gap: 5px;
660	font-size: 0.72rem;
661	padding: 3px 9px;
662	border-radius: 999px;
663	background: rgba(59,130,246,0.12);
664	border: 1px solid rgba(96,165,250,0.25);
665	color: #bfdbfe;
666}
667.ai-tag i {
668	font-size: 0.7em;
669	opacity: 0.85;
670}
671.ai-welcome-prompts {
672	margin-top: 14px;
673	padding-top: 12px;
674	border-top: 1px dashed rgba(96,165,250,0.18);
675}
676.ai-welcome-prompts-label {
677	font-size: 0.7rem;
678	font-weight: 600;
679	letter-spacing: 0.6px;
680	text-transform: uppercase;
681	color: var(--bs-secondary-color, #94a3b8);
682	margin-bottom: 8px;
683}
684.ai-welcome-prompts-label i {
685	margin-right: 6px;
686}
687.ai-welcome-prompts-list {
688	display: flex;
689	flex-wrap: wrap;
690	gap: 6px;
691}
692.ai-try-chip {
693	font-size: 0.78rem;
694	padding: 5px 12px;
695	border-radius: 14px;
696	background: rgba(15,23,42,0.4);
697	border: 1px solid rgba(96,165,250,0.3);
698	color: #dbeafe;
699	cursor: pointer;
700	white-space: normal;
701	text-align: left;
702	line-height: 1.3;
703	transition: background 0.15s ease, border-color 0.15s ease;
704}
705.ai-try-chip:hover {
706	background: rgba(59,130,246,0.18);
707	border-color: rgba(96,165,250,0.55);
708	color: #fff;
709}
710
711/* Jim's small avatar in the conversation header (sticky top bar) — same
712   robot-head identity as the welcome card, sized to match other contacts. */
713.chat-contact-avatar.chat-contact-avatar-ai {
714	background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #1d4ed8 100%) !important;
715	display: flex;
716	align-items: center;
717	justify-content: center;
718	color: #fff;
719}
720.chat-contact-avatar.chat-contact-avatar-ai i {
721	font-size: 1.05em;
722}
723
724#chat_form .form-floating .form-control {
725	border-radius: 6px 0 0 6px;
726}
727#chat_form .btn[type="submit"] {
728	border-radius: 0 6px 6px 0;
729}
730#messageContent {
731	height: auto;
732	overflow: visible;
733}
734#messageContent p {
735	margin:0px;
736}
737/*
738hijacked from
739https://stackoverflow.com/questions/71154905/css-for-chat-room-speech-bubble-position
740
741*/
742.msg {
743	position: relative;
744	max-width: 90%;
745	padding: 7px 15px;
746	margin-bottom: 2px;
747}
748.chat {
749	--rad: 20px;
750	--rad-sm: 3px;
751	display: flex;
752	flex-direction: column;
753	/* padding: 20px; */
754	/*max-width: 500px;*/
755	margin: auto;
756}
757.msg.sent {
758	border-radius: var(--rad) var(--rad-sm) var(--rad-sm) var(--rad);
759	background: #42a5f5;
760	color: #fff;
761	/* moves it to the right */
762	margin-left: auto;
763}
764
765.msg.rcvd {
766	border-radius: var(--rad-sm) var(--rad) var(--rad) var(--rad-sm);
767	background: #f1f1f1;
768	color: #555;
769	/* moves it to the left */
770	margin-right: auto;
771}
772.msg.sent a {
773	color:#fff;
774}
775
776/* Improve radius for messages group */
777
778.msg.sent:first-child,
779.msg.rcvd+.msg.sent {
780	border-top-right-radius: var(--rad);
781}
782
783.msg.rcvd:first-child,
784.msg.sent+.msg.rcvd {
785	border-top-left-radius: var(--rad);
786}
787/* time */
788.msg::before {
789	content: attr(data-time);
790	font-size: 0.8rem;
791	position: absolute;
792	bottom: 100%;
793	color: #888;
794	white-space: nowrap;
795	/* Hidden by default */
796	display: none;
797}
798.msg.sent::before {
799	right: 15px;
800}
801.msg.rcvd::before {
802	left: 15px;
803}
804/* Show time only for first message in group */
805.msg:first-child::before,
806.msg.sent+.msg.rcvd::before,
807.msg.rcvd+.msg.sent::before {
808  /* Show only for first message in group */
809	display: block;
810}
811/* Seen receipt — prepended inside the bubble's <small> header, left of the sender
812   name (kept INSIDE .msg.sent: a sibling div would break the .msg adjacency
813   selectors above). White-on-blue: the sent bubble is #42a5f5.
814   .seen-pending = circle-notch spinner while unseen; .seen-check = ✓ once seen. */
815.msg.sent .seen-check,
816.msg.sent .seen-pending {
817	text-decoration: none;
818	cursor: default;
819	color: rgba(255,255,255,.9);
820	margin-right: 2px;
821}
822.msg.sent .seen-pending {
823	color: rgba(255,255,255,.7); /* dimmer — still waiting */
824}
825.msg.sent .seen-check .fa-check,
826.msg.sent .seen-pending .fa-circle-notch {
827	font-size: .7rem;
828}
829.offcanvas{
830	transition: none !important;
831}
832</style>
833<style id="custom-theme">
834</style>
835<script>
836function getQueryParams() {
837    var params = {};
838    var queryString = window.location.search.substring(1);
839    var regex = /([^&=]+)=([^&]*)/g;
840    var match;
841    while (match = regex.exec(queryString)) {
842        params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
843    }
844    return params;
845}
846var queryParams = getQueryParams();
847
848// Access the id parameter
849var thispageid = queryParams.id;
850var protocol = window.location.href.indexOf("https://") == 0 ? "https" : "http";
851var path = window.location.pathname;
852var thispage = path.split("/").pop() || 'index.php'
853	
854//options to js
855var dispatchDriversByDefault = false;
856
857
858	const DOMAIN = 'ninjatms.com';</script>
858
859
860	<link rel="preconnect" href="https://www.google.com" crossorigin>
861	<link rel="preconnect" href="https://www.gstatic.com" crossorigin>
862	<style>
863		.container{
864			height:100vh;
865		}
866		.main-head {
867			height: 150px;
868			background: #FFF;
869		}
870		.sidenav {
871			height: 100%;
872			background-color: #000;
873			overflow-x: hidden;
874			padding-top: 20px;
875			background: linear-gradient(-45deg,  #ffffff, #5ba9ff, #001646, #00051e);
876			background-size: 400% 400%;
877			animation: gradient 15s ease infinite;
878		}
879		@keyframes gradient {
880			0% {
881				background-position: 0% 50%
882			}
883			50% {
884				background-position: 100% 50%
885			}
886			100% {
887				background-position: 0% 50%
888			}
889		}
890		main {
891			padding: 0px 10px;
892		}
893		@media screen and (max-height: 576px) {
894			.sidenav {
895				padding-top: 15px;
896			}
897		}
898		@media screen and (max-width: 576px) {
899			.login-form {
900				margin-top: 10%;
901			}
902			.register-form {
903				margin-top: 10%;
904			}
905		}
906		@media screen and (max-width: 768px){
907			.sidenav{
908				display:none
909			}
910			main{
911				margin-left:0px;
912			}
913		}
914		@media screen and (min-width: 768px) {
915			main {
916				margin-left: 40%;
917			}
918			.sidenav {
919				width: 40%;
920				position: fixed;
921				z-index: 1;
922				top: 0;
923				left: 0;
924			}
925			.login-form {
926				margin-top: 15%;
927			}
928			.register-form {
929				margin-top: 20%;
930			}
931		}
932		.login-main-text {
933			margin-top: 5%;
934			padding: 95px;
935			color: #fff;
936			background-color: rgba(0,0,0,0.8);
937		}
938		.login-main-text h2 {
939			font-weight:800
940		}
941		
942.header {
943    display: flex;
944    align-items: center; /* Aligns items vertically */
945    gap: 10px; /* Adjusts space between image and text */
946}
947
948.header img {
949    height: 75px; /* Adjust size as needed */
950}
951
952.text-container {
953    display: flex;
954    flex-direction: column; /* Stacks the TMS name and system text vertically */
955}
956
957/* ── OAuth sign-in buttons (carrierpacket.link-style stack) ───────────── */
958.auth-oauth .btn{
959	display:flex;
960	align-items:center;
961	justify-content:center;
962	gap:10px;
963	font-weight:500;
964	background:#fff;
965	color:#3c4043;
966}
967.auth-oauth .btn-google{ border:1px solid #dadce0; font-family:'Roboto',system-ui,sans-serif; }
968.auth-oauth .btn-ms{ border:1px solid #d0d4d8; font-family:'Segoe UI',system-ui,sans-serif; }
969.auth-oauth .btn-magic{ border:1px solid var(--bs-primary); color:var(--bs-primary); }
970.auth-oauth .btn:hover{ background:#f8f9fa; }
971.auth-oauth .ms-logo{ display:grid; grid-template-columns:8px 8px; grid-gap:2px; }
972.auth-oauth .ms-logo span{ width:8px; height:8px; display:block; }
973.auth-or{
974	display:flex;
975	align-items:center;
976	gap:12px;
977	color:#6c757d;
978	font-size:13px;
979	margin:18px 0;
980}
981.auth-or::before, .auth-or::after{ content:""; flex:1; border-top:1px solid #dee2e6; }
982	</style>
983<script>const a = 'OTM2MzJmYTFjNDVmNmQ2YjU3ZjI3MzQ3ZDE0ZDk0OWI=';</script>
983
984</head>
985<body>
985<script>
986	var tmode = localStorage.getItem('t')
987	if (!tmode) {
988		localStorage.setItem('t', 'dark')
989		tmode = 'dark';
990	}
991	if (tmode && tmode == 'dark') {
992		try {
993			//<link rel="stylesheet" type="text/css" href="css/daterangepicker.min.css">
994			document.getElementById('daterangecss').setAttribute('href', 'css/daterangepicker.min.css')
995			var themeMode = document.querySelector('#theme-mode')
996			if (themeMode) {
997				themeMode.checked = true;
998				document.documentElement.setAttribute('data-bs-theme', tmode);
999				const tlab = document.getElementById("theme-label") ?? null
1000				if (tlab) {
1001					tlab.innerHTML = 'Dark Mode';
1002				}
1003			} else {
1004				document.documentElement.setAttribute('data-bs-theme', tmode);
1005			}
1006		} catch (error) {
1007			console.log('darkmode/lightmode error', error)
1008		}
1009	}else{
1010		document.documentElement.setAttribute('data-bs-theme', tmode);
1011	}
1012</script>
1012
1013	<div class="sidenav">
1014		<div class="login-main-text">
1015			<h2 class="animate__animated animate__fadeIn header">
1016				<a href="https://ninjatms.com/" title="Ninja TMS"><img src="https://ninjatms.com/images/ninjatms-simple.png" alt="Ninja TMS"></a>
1017				<div class="text-container">
1018					<em><a href="https://ninjatms.com/" title="Ninja TMS" class="text-white text-decoration-none">Ninja TMS</a></em>
1019					<span class="animate__animated animate__fadeIn" style="font-size: 13px;font-weight: 200;">Transportation Management System</span>
1020				</div>
1021			</h2>
1022
1023			
1024		</div>
1025	</div>
1026	<main>
1027		<div class="container">
1028			<div class="row">
1029				<div class="col-md-12 col-lg-6"> 
1030					<div class="login-form">
1031						<h1 class="display-6">Login</h1>
1032						<div id="rate-limit-banner" class="alert alert-warning d-none" role="alert">
1033							<i class="fa-solid fa-lock"></i> <strong>Too many failed login attempts.</strong>
1034							Try again in <span id="rate-limit-countdown">00:00</span>.
1035							<div class="small mt-1">Logins are blocked for 10 minutes after 7 failed attempts.</div>
1036						</div>
1037						<form id="login-form">
1038							<input type="hidden" name="logmein" id="logmein" value="6d17f3e">
1039							<div class="form-group">
1040								<label for="email">Email</label>
1041								<input id="email" name="email" type="text" class="form-control" placeholder="Email Address" value="">
1042							</div>
1043							<div class="form-group mt-3">
1044								<label for="password">Password</label>
1045								<input id="password" name="password" type="password" class="form-control" placeholder="Password">
1046							</div>
1047							<div class="row mt-3">
1048								<div class="col">
1049									<button type="submit" class="btn btn-primary g-recaptcha" data-sitekey="6LeEJWwqAAAAAJD9D3_2Ikgs2CMRrj0CK8jioVDm" data-callback="onLoginSubmit" data-action="submit" disabled><i class="fas fa-sign-in-alt"></i> Login</button>
1050								</div>
1051							</div>
1052						</form>
1053					</div>
1054					<p class="mt-3"><a href="https://ninjatms.com/app/forgot.php">Forgot your password?</a></p>
1055				</div>
1056				<div class="row mt-3">
1057					<div class="col mt-3">
1058						<hr>
1059					</div>
1060				</div>
1061				<div class="row mt-3">
1062					<div class="col mt-3">
1063						<p>Don't have an account? Get registered here </p>
1064						<a href="https://ninjatms.com/app/register.php" class="btn btn-success"><i class="fas fa-user-plus"></i> Sign up</a>
1065					</div>
1066				</div>
1067			</div>
1068			<div class="row mt-5">
1069				<div class="col-md-6 col-sm-12" id="message"></div>
1070			</div>
1071		</div>
1072	</main>
1073	
1073<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.1/js/bootstrap.min.js" integrity="sha512-ewfXo9Gq53e1q1+WDTjaHAGZ8UvCWq0eXONhwDuIoaH8xz2r96uoAYaQCm1oQhnBfRXrvJztNXFsTloJfgbL5Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
1073
1074	
1074<script src="https://code.jquery.com/jquery-3.6.1.min.js" integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
vendor: 81 bytes, lines 1074-1076
1074
1075	<script src="https://www.google.com/recaptcha/api.js" async defer></script>
1076	
1076<script>
1077		// Invisible reCAPTCHA callback — fires with a token on button click; triggers the form submit
1078		function onLoginSubmit(token){ $('#login-form').trigger('submit'); }
1079		const eld_redirect = false;
1080		const eid = '';
1081		const return_url = '';
1082		var javascript = true
1083		var n = Math.floor(Math.random() * (8008135 - 1337 + 1)) + 1337
1084
1085		let rlRemaining = 0;
1086		let rlHideScheduled = false;
1087		let recaptchaReady = false;
1088		let loginSpinning = false;
1089
1090		// Single source of truth for the submit button's disabled state
1091		function refreshSubmitState(){
1092			const shouldDisable = loginSpinning || !recaptchaReady || rlRemaining > 0;
1093			$('#login-form button[type=submit]').prop('disabled', shouldDisable);
1094		}
1095
1096		function updateRateLimitUI(seconds){
1097			rlRemaining = Math.max(0, seconds|0);
1098			const $banner = $('#rate-limit-banner');
1099			const mins = Math.floor(rlRemaining / 60);
1100			const secs = rlRemaining % 60;
1101			$('#rate-limit-countdown').text(
1102				String(mins).padStart(2,'0') + ':' + String(secs).padStart(2,'0')
1103			);
1104			if(rlRemaining > 0){
1105				rlHideScheduled = false;
1106				$banner.removeClass('d-none');
1107			}else if(!rlHideScheduled){
1108				// Counted down to 00:00 — dismiss banner shortly after so user sees 00:00 briefly
1109				rlHideScheduled = true;
1110				setTimeout(function(){ $('#rate-limit-banner').addClass('d-none'); }, 2000);
1111			}
1112			refreshSubmitState();
1113		}
1114		setInterval(function(){ if(rlRemaining > 0) updateRateLimitUI(rlRemaining - 1); }, 1000);
1115
1116		// Poll until reCAPTCHA's async api.js has defined grecaptcha, then enable the button
1117		(function waitForGrecaptcha(){
1118			if(typeof grecaptcha !== 'undefined' && typeof grecaptcha.ready === 'function'){
1119				grecaptcha.ready(function(){
1120					recaptchaReady = true;
1121					refreshSubmitState();
1122				});
1123			}else{
1124				setTimeout(waitForGrecaptcha, 100);
1125			}
1126		})();
1127
1128		// Tiny escapers used when injecting server-supplied messages into the DOM.
1129		// Server messages are mostly hardcoded strings, but defense-in-depth.
1130		function escapeMsg(s){
1131			return String(s == null ? '' : s)
1132				.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1133				.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1134		}
1135		function escapeAttr(s){ return escapeMsg(s); }
1136
1137		$(function(){
1138			const sessionID = localStorage.removeItem("sessionID");
1139			$('main').attr('data-k', a);
1140			updateRateLimitUI(rlRemaining);
1141
1142			// Swap the submit-button icon between sign-in and loading spinner; disabled state handled by refreshSubmitState
1143			function setLoginLoading(isLoading){
1144				loginSpinning = isLoading;
1145				const $icon = $('#login-form button[type=submit] i');
1146				if(isLoading){
1147					$icon.removeClass('fa-sign-in-alt').addClass('fa-circle-notch fa-spin');
1148				}else{
1149					$icon.removeClass('fa-circle-notch fa-spin').addClass('fa-sign-in-alt');
1150				}
1151				refreshSubmitState();
1152			}
1153
1154			$(document).on('submit', '#login-form', function(e){
1155				e.preventDefault();
1156				if(rlRemaining > 0) return;
1157				if(javascript){
1158					setLoginLoading(true);
1159					const kibble = atob($('main').attr('data-k'))
1160					let formObject = $(this).serializeArray();
1161					formObject.push({ name: 'code', value: 'e9ec28a' });
1162					let payload = $.param(formObject);
1163					$.post('api/_auth.php?bZ4765d=9z&'+kibble, payload, function(response){
1164						//console.log(response)
1165						const r = (typeof response === 'object' && response) ? response : {};
1166
1167						// 429 — rate-limited (too many failed attempts). Same handler as before.
1168						if(r.status == 429){
1169							setLoginLoading(false);
1170							updateRateLimitUI(r.seconds_remaining || 600);
1171							$('#message').html('<div class="alert alert-danger" role="alert"><i class="fa-solid fa-lock"></i> ' + escapeMsg(r.message) + '</div>');
1172							return;
1173						}
1174
1175						// 423 — account exists but is disabled. No retry guidance — this
1176						// won't change without admin intervention. Direct them to support.
1177						if(r.status == 423){
1178							setLoginLoading(false);
1179							$('#message').html('<div class="alert alert-danger" role="alert"><i class="fa-solid fa-ban"></i> ' + escapeMsg(r.message || 'This account is disabled.') + '</div>');
1180							return;
1181						}
1182
1183						// 403 — credentials OK but email not confirmed yet. Show the resend
1184						// button so the user has a one-click recovery path.
1185						if(r.status == 403 && r.unverified){
1186							setLoginLoading(false);
1187							const email = r.email || $('#email').val();
1188							const html =
1189								'<div class="alert alert-warning" role="alert">
1189' +
1190									'<i class="fa-solid fa-envelope-circle-check"></i> ' + escapeMsg(r.message || 'Please confirm your email address to log in.') +
1191									'<div class="mt-3">' +
1192										'<button type="button" class="btn btn-sm btn-warning resend-confirmation-btn" data-email="' + escapeAttr(email) + '">' +
1193											'<i class="fa-solid fa-paper-plane"></i> Resend confirmation email' +
1194										'</button>' +
1195									'</div>' +
1196								'</div>';
1197							$('#message').html(html);
1198							return;
1199						}
1200
1201						// 200 — success.
1202						if(r.status == 200 || r.status == '200'){
1203							// Detect a fresh-confirmation login via the URL param that
1204							// app/confirm_account.php sets on its redirect — works after
1205							// confirmation_code is no longer cleared by login itself.
1206							const justConfirmed = new URLSearchParams(location.search).has('confirmed');
1207							if(justConfirmed){
1208								if(eld_redirect){
1209									window.location.href = 'settings.php?eldlogin=motive&eid='+eid+'#eld-section';
1210								}else{
1211									window.location.href = 'settings.php?newuser&first_login=1';
1212								}
1213							}else{
1214								window.location.href = return_url || 'index.php';
1215							}
1216							return;
1217						}
1218
1219						// 401 — wrong creds. Surface tries_remaining if the server included it.
1220						setLoginLoading(false);
1221						let msg = r.message || 'Login failed';
1222						if(typeof r.tries_remaining === 'number'){
1223							msg += ' — ' + r.tries_remaining + ' ' + (r.tries_remaining === 1 ? 'try' : 'tries') + ' remaining';
1224						}
1225						$('#message').html('<div class="alert alert-danger" role="alert"><i class="fa-solid fa-circle-exclamation"></i> ' + escapeMsg(msg) + '</div>');
1226					}).fail(function(){
1227						setLoginLoading(false);
1228						$('#message').html('<div class="alert alert-danger" role="alert"><i class="fa-solid fa-circle-exclamation"></i> Network error. Please try again.</div>');
1229					})
1230				}
1231			})
1232
1233			// Resend confirmation email — wired only on the alert that the 403/unverified
1234			// branch above injects. Endpoint returns generic 200 even when the email
1235			// wasn't actually pending, so the UI just thanks the user either way; the
1236			// only branch that surfaces a different message is the 5-min throttle (429).
1237			$(document).on('click', '.resend-confirmation-btn', function(e){
1238				e.preventDefault();
1239				const $btn  = $(this);
1240				const email = $btn.data('email');
1241				const $icon = $btn.find('i');
1242				$btn.prop('disabled', true);
1243				$icon.removeClass().addClass('fa-solid fa-circle-notch fa-spin');
1244				const kibble = atob($('main').attr('data-k'));
1245				$.post('api/_auth.php?'+kibble, { mode: 'resend-confirmation', email: email }, function(response){
1246					const r = (typeof response === 'object' && response) ? response : {};
1247					if(r.status == 429){
1248						$icon.removeClass().addClass('fa-solid fa-clock');
1249						$btn.prop('disabled', true).removeClass('btn-warning').addClass('btn-secondary');
1250						$btn.contents().last().replaceWith(' Wait '+ Math.ceil((r.seconds_remaining||300)/60) +' min');
1251						$('#message').prepend('<div class="alert alert-warning small mb-2" role="alert"><i class="fa-solid fa-hourglass-half"></i> ' + escapeMsg(r.message) + '</div>');
1252						return;
1253					}
1254					// Both 200 and any unexpected non-429 land here as "we tried" — the
1255					// generic-200 contract from the server means we can't tell the user
1256					// whether the email actually went out without leaking account state.
1257					$icon.removeClass().addClass('fa-solid fa-check');
1258					$btn.prop('disabled', true).removeClass('btn-warning').addClass('btn-success');
1259					$btn.contents().last().replaceWith(' Sent — check your inbox');
1260				}, 'json').fail(function(){
1261					$icon.removeClass().addClass('fa-solid fa-circle-exclamation');
1262					$btn.prop('disabled', false).removeClass('btn-warning').addClass('btn-danger');
1263					$btn.contents().last().replaceWith(' Network error — try again');
1264				});
1265			});
1266			setTimeout(function(){
1267				var email = $('#email').val();
1268				if(email.length>0){
1269					$('#password').focus()
1270				}else{
1271					$('#email').focus()
1272				}
1273			},250)
1274		})
1275
1276	</script>
1276
1277</body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.