/* Image Cropper — review, crop, clean and save identity photos. Identity comes from the YOUreka session; the only operator credential is the save-time OTP (required by a2loft). Notifications are Carbon toasts. */ /* global Vue, axios, Croppa, customElements */ /* window.__SESSION__ is injected server-side by /cropper */ Vue.config.ignoredElements = [/^cds-/]; (function () { 'use strict'; var toastSeq = 0; var app = new Vue({ el: '#app', data: { loading: true, session: null, message: { message: { caption: null, file_url: null, domain: '' }, }, agent: null, process: null, reviewed: false, accepted: false, cleanedImage: null, requiresCleaning: '', otp: '', reviewOptions: [ 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)', 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)', 'IMAGE: Blurry ', 'IMAGE: Improper lighting ', 'IMAGE: Not coloured.', 'IMAGE: Scanned photo', 'IMAGE: Has no adequate space to allow for proper cropping ', 'IMAGE: Not taken on a recommended background.', 'IMAGE: Photo is a selfie', 'DRESSING: Unacceptable dress. ', 'HEAD: Skewed (leaning away from the midline - vertically or horizontally)', 'EYES: Not looking into the camera (looking downward, upward, or sideward)', 'EYES: Not properly opened', 'EARS: Not visible ', 'EARS: Both ears are not equally visible (face not aligned central)', 'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)', 'GLASSES: Subject is wearing glasses ', 'JUST WRING: Goes against almost all acceptable passport criteria.', 'Others not specified.', ].map(function (name, index) { return { index: index + 1, name: name }; }), selectedReviewOptions: [], otherReviewOption: '', busy: { load: false, clean: false, review: false, save: false }, toasts: [], myCroppa: {}, }, computed: { initials: function () { var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; return n.slice(0, 2).toUpperCase(); }, canReview: function () { if (this.busy.review) return false; if (this.accepted) return true; return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0; }, canSave: function () { return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== ''; }, }, created: function () { var self = this; // injected by the server when the page was rendered (no round-trip) this.session = window.__SESSION__ || null; if (!this.session) { window.location.href = '/auth/login'; return; } this.loading = false; Promise.all( ['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) { return customElements.whenDefined(n).catch(function () {}); }) ).then(function () { self.$forceUpdate(); }); }, methods: { toast: function (kind, title, caption) { var self = this; var id = ++toastSeq; this.toasts.push({ id: id, kind: kind, title: title, caption: caption || '' }); setTimeout(function () { self.removeToast(id); }, 6000); }, removeToast: function (id) { this.toasts = this.toasts.filter(function (t) { return t.id !== id; }); }, reset: function () { this.message = { message: { caption: null, file_url: null, domain: '' } }; this.agent = null; this.process = null; this.reviewed = false; this.accepted = false; this.cleanedImage = null; this.selectedReviewOptions = []; this.otherReviewOption = ''; this.requiresCleaning = ''; this.otp = ''; }, loadPicture: async function (mode) { var self = this; this.busy.load = true; try { var uploadId = null; if (mode === 'by-id') { uploadId = prompt('Enter picture upload ID'); if (!uploadId) return; } var { data } = await axios.get( uploadId ? '/cropper/picture/load/' + encodeURIComponent(uploadId) : '/cropper/picture/next' ); if (!data.ok) { this.reset(); // nothing (more) to review — back to the load state this.toast('info', 'No picture', data.message || 'Nothing to load.'); return; } this.message = data.data; var image = await axios.get( '/cropper/picture/' + encodeURIComponent(this.message.message.file_name.replace(/\//gi, '_')) ); if (!image.data.ok) { this.reset(); this.toast('error', 'Load failed', "Couldn't load the attached image. Please try again."); return; } this.message.message.file_url = image.data.data; this.cleanedImage = null; this.agent = null; this.process = this.message.message.process || null; if (this.message.message.upload_id) { var review = await axios.get( '/cropper/picture/review/' + encodeURIComponent(this.message.message.upload_id) ); this.agent = review.data.ok ? review.data.data : null; } } catch ({ message }) { this.toast('error', 'Operation Failed', message); } finally { this.busy.load = false; } }, cleanImage: async function () { var self = this; this.busy.clean = true; try { var dataUrl = this.myCroppa.generateDataUrl('image/jpeg'); var response = await fetch('/cropper/api/removebg', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_file_b64: dataUrl.split(',')[1] }), }); if (!response.ok) { var err = await response.json().catch(function () { return {}; }); throw new Error(err.message || 'Failed to clean image.'); } var contentType = response.headers.get('content-type') || ''; if (contentType.indexOf('image/') !== 0) { throw new Error('Image cleanup service returned an invalid response.'); } var blob = await response.blob(); var fr = new FileReader(); fr.onload = function () { self.cleanedImage = fr.result; }; fr.readAsDataURL(blob); } catch ({ message }) { this.toast('error', 'Image Cleanup Failed', message); } finally { this.busy.clean = false; } }, reviewPicture: async function () { var self = this; if (!this.canReview) return; if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) { this.toast('error', 'Operation Failed', 'Please indicate why this image is not good enough.'); return; } this.busy.review = true; var options = [...this.selectedReviewOptions, this.otherReviewOption].filter(function (o) { return o && o.length > 0; }); var reviewMessage = this.accepted ? null : options.join('\n\n'); try { var hasProcess = !!this.message.message.process; var data = {}; if (hasProcess) { var { domain, reference } = this.message.message.process.variables; data.process = { correlationKey: domain + '-' + reference, name: 'PictureReviewed', variables: { ok: this.accepted, reviewRejectMessage: reviewMessage }, }; } else { data.raw = { phone: this.message.message.caption, reviewRejectMessage: reviewMessage }; } var { data: result } = await axios.post('/cropper/picture/review', { deleteFromQueue: true, message: this.message, mode: hasProcess ? 'process' : 'raw', ...data, }); if (!result.ok) { this.toast('error', 'Operation Failed', result.message || 'Review failed.'); return; } if (reviewMessage) { this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.'); } this.reset(); } catch ({ message }) { this.toast('error', 'Operation Failed', message); } finally { this.busy.review = false; } }, removeFromQueue: async function () { var self = this; if (!confirm('Are you sure you want to dequeue?')) return; this.busy.review = true; try { var { data } = await axios.post('/cropper/dequeue', { message: this.message }); if (!data.ok) { this.toast('error', 'Not Removed', data.message || 'Removal failed.'); return; } this.toast('success', 'Removed!', data.message || ''); this.reset(); } catch ({ message }) { this.toast('error', 'Not Removed', message); } finally { this.busy.review = false; } }, saveChanges: async function () { this.busy.save = true; try { var { data } = await axios.post( '/cropper/profile/picture?otp=' + encodeURIComponent(this.otp), { pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'), phone: this.message.message.caption, message: this.message, requiresCleaning: this.requiresCleaning !== 'No', } ); if (!data.ok) { this.toast('error', 'Not Saved', data.message || 'Save failed.'); return; } this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption); this.reset(); } catch ({ message }) { this.toast('error', 'Not Saved', message); } finally { this.busy.save = false; } }, }, }); })();