/** * PrivacyPopup.js * Privacy authorization popup for WeChat mini-game. * * When a privacy-sensitive API (e.g. wx.getUserInfo) is called, WeChat * triggers `wx.onNeedPrivacyAuthorization`. This popup shows a compliant * dialog that explains what data we collect and lets the user agree or * decline before we call `resolve({ buttonAction: 'agree' })`. * * Compliance notes (WeChat 2024+): * - Must clearly state what data is collected and why. * - Must provide a way to decline (user can still play with placeholder). * - Must link to the full privacy policy. */ const { SCREEN_WIDTH, SCREEN_HEIGHT, } = require('../base/GameGlobal'); const { t } = require('../i18n/I18n'); // Colors const OVERLAY_BG = 'rgba(0, 0, 0, 0.75)'; const DIALOG_BG = '#1e1e32'; const DIALOG_BORDER = '#4a90d9'; const TITLE_COLOR = '#FFD700'; const TEXT_COLOR = '#CCCCCC'; const LINK_COLOR = '#4a90d9'; const AGREE_BG = '#4a90d9'; const AGREE_TEXT = '#FFFFFF'; const DECLINE_BG = 'rgba(255, 255, 255, 0.08)'; const DECLINE_BORDER = '#666666'; const DECLINE_TEXT = '#AAAAAA'; class PrivacyPopup { constructor() { /** @type {boolean} Whether the popup is currently visible. */ this._active = false; /** @type {Function|null} The resolve callback from onNeedPrivacyAuthorization. */ this._resolveCallback = null; /** @type {string} Which API triggered the privacy request. */ this._referrer = ''; /** @type {object|null} Button hit-test rects (computed during render). */ this._agreeBtn = null; this._declineBtn = null; this._policyLink = null; } // ============================================================ // Public API // ============================================================ get active() { return this._active; } /** * Show the privacy popup. * @param {Function} resolve - The resolve callback from onNeedPrivacyAuthorization. * @param {object} eventInfo - The eventInfo from onNeedPrivacyAuthorization. */ show(resolve, eventInfo) { // Guard: if popup is already active, chain the new resolve callback // so that when the current popup resolves, both callbacks get called. // This prevents showing two popups at once. if (this._active && this._resolveCallback) { console.warn('[PrivacyPopup] Already active, chaining resolve callback. referrer:', this._referrer, ', new referrer:', (eventInfo && eventInfo.referrer) || ''); const prevResolve = this._resolveCallback; this._resolveCallback = (result) => { try { prevResolve(result); } catch (e) { /* ignore */ } try { resolve(result); } catch (e) { /* ignore */ } }; return; } this._active = true; this._resolveCallback = resolve; this._referrer = (eventInfo && eventInfo.referrer) || ''; console.log('[PrivacyPopup] Showing popup, referrer:', this._referrer); } /** * Hide the popup (without resolving — used internally after resolve is called). */ hide() { this._active = false; this._resolveCallback = null; this._agreeBtn = null; this._declineBtn = null; this._policyLink = null; } /** * Handle touch event. Returns true if the touch was consumed. * @param {string} eventType - 'touchstart' | 'touchend' * @param {object} e - The touch event object. * @returns {boolean} */ handleTouch(eventType, e) { if (!this._active) return false; if (eventType !== 'touchend') return true; // Consume touchstart to prevent bleed-through const touch = e.changedTouches && e.changedTouches[0]; if (!touch) return true; const tx = touch.clientX; const ty = touch.clientY; // Agree button if (this._agreeBtn && this._hitTest(tx, ty, this._agreeBtn)) { console.log('[PrivacyPopup] User tapped AGREE'); this._resolve('agree'); return true; } // Decline button if (this._declineBtn && this._hitTest(tx, ty, this._declineBtn)) { console.log('[PrivacyPopup] User tapped DECLINE'); this._resolve('disagree'); return true; } // Privacy policy link if (this._policyLink && this._hitTest(tx, ty, this._policyLink)) { console.log('[PrivacyPopup] User tapped privacy policy link'); this._openPrivacyPolicy(); return true; } return true; // Consume all touches while popup is active } // ============================================================ // Render // ============================================================ render(ctx) { if (!this._active) return; const cw = SCREEN_WIDTH; const ch = SCREEN_HEIGHT; const cx = cw / 2; const cy = ch / 2; // --- Semi-transparent overlay --- ctx.fillStyle = OVERLAY_BG; ctx.fillRect(0, 0, cw, ch); // --- Dialog box --- const dialogW = Math.min(cw * 0.88, 420); const dialogH = Math.min(ch * 0.78, 320); const dialogX = cx - dialogW / 2; const dialogY = cy - dialogH / 2; // Shadow ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; this._roundRect(ctx, dialogX + 4, dialogY + 4, dialogW, dialogH, 12); ctx.fill(); // Background ctx.fillStyle = DIALOG_BG; this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12); ctx.fill(); // Border ctx.strokeStyle = DIALOG_BORDER; ctx.lineWidth = 1.5; this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12); ctx.stroke(); // --- Shield icon (Canvas path) --- const iconY = dialogY + 32; const sw = 22; // shield width const sh = 26; // shield height const sx = cx - sw / 2; const sy = iconY - sh / 2; // Shield body ctx.fillStyle = DIALOG_BORDER; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + sw, sy); ctx.lineTo(sx + sw, sy + sh * 0.55); ctx.quadraticCurveTo(sx + sw, sy + sh * 0.85, cx, sy + sh); ctx.quadraticCurveTo(sx, sy + sh * 0.85, sx, sy + sh * 0.55); ctx.closePath(); ctx.fill(); // Checkmark inside shield ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(cx - 6, iconY - 1); ctx.lineTo(cx - 2, iconY + 4); ctx.lineTo(cx + 7, iconY - 5); ctx.stroke(); // --- Title --- const titleY = iconY + 30; ctx.fillStyle = TITLE_COLOR; ctx.font = 'bold 17px Arial'; ctx.fillText(t('privacy.title'), cx, titleY); // --- Body text --- const bodyY = titleY + 28; const bodyW = dialogW - 50; ctx.fillStyle = TEXT_COLOR; ctx.font = '13px Arial'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const bodyLines = this._wrapText(ctx, t('privacy.body'), bodyW); for (let i = 0; i < bodyLines.length; i++) { ctx.fillText(bodyLines[i], dialogX + 25, bodyY + i * 20); } // --- Privacy policy link --- const linkY = bodyY + bodyLines.length * 20 + 10; const linkText = t('privacy.policyLink'); ctx.fillStyle = LINK_COLOR; ctx.font = 'bold 13px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(linkText, cx, linkY); // Store link hit area const linkMetrics = ctx.measureText(linkText); this._policyLink = { x: cx - linkMetrics.width / 2 - 6, y: linkY - 10, w: linkMetrics.width + 12, h: 20, }; // Underline the link ctx.strokeStyle = LINK_COLOR; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx - linkMetrics.width / 2, linkY + 7); ctx.lineTo(cx + linkMetrics.width / 2, linkY + 7); ctx.stroke(); // --- Buttons --- const btnAreaY = linkY + 30; const btnW = Math.min((dialogW - 60) / 2, 150); const btnH = 38; const btnGap = 16; const agreeX = cx - btnW - btnGap / 2; const declineX = cx + btnGap / 2; // Agree button ctx.fillStyle = AGREE_BG; this._roundRect(ctx, agreeX, btnAreaY, btnW, btnH, 8); ctx.fill(); ctx.fillStyle = AGREE_TEXT; ctx.font = 'bold 15px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('privacy.agree'), agreeX + btnW / 2, btnAreaY + btnH / 2); this._agreeBtn = { x: agreeX, y: btnAreaY, w: btnW, h: btnH }; // Decline button ctx.fillStyle = DECLINE_BG; this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8); ctx.fill(); ctx.strokeStyle = DECLINE_BORDER; ctx.lineWidth = 1; this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8); ctx.stroke(); ctx.fillStyle = DECLINE_TEXT; ctx.font = '14px Arial'; ctx.fillText(t('privacy.decline'), declineX + btnW / 2, btnAreaY + btnH / 2); this._declineBtn = { x: declineX, y: btnAreaY, w: btnW, h: btnH }; // --- Footer hint --- const footerY = btnAreaY + btnH + 16; ctx.fillStyle = '#666666'; ctx.font = '11px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(t('privacy.footer'), cx, footerY); } // ============================================================ // Private // ============================================================ /** * Resolve the privacy authorization request. * @param {'agree'|'disagree'} action */ _resolve(action) { if (!this._resolveCallback) { console.warn('[PrivacyPopup] No resolve callback to call'); this.hide(); return; } try { if (action === 'agree') { console.log('[PrivacyPopup] Resolving with agree'); this._resolveCallback({ event: 'agree' }); } else { console.log('[PrivacyPopup] Resolving with disagree'); // Disagree still needs to resolve, but the API will fail gracefully this._resolveCallback({ event: 'disagree' }); } } catch (e) { console.error('[PrivacyPopup] resolve() threw:', e); } this.hide(); } /** * Open the privacy policy document. * Uses wx.openPrivacyContract if available. */ _openPrivacyPolicy() { try { if (typeof wx !== 'undefined' && typeof wx.openPrivacyContract === 'function') { wx.openPrivacyContract({ success: () => console.log('[PrivacyPopup] Privacy contract opened'), fail: (err) => console.warn('[PrivacyPopup] Failed to open privacy contract:', err), }); } else { console.warn('[PrivacyPopup] wx.openPrivacyContract is not available'); } } catch (e) { console.warn('[PrivacyPopup] Error opening privacy contract:', e); } } /** * Simple hit-test for rectangular area. */ _hitTest(tx, ty, rect) { return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h; } /** * Wrap text into lines that fit within maxWidth. */ _wrapText(ctx, text, maxWidth) { const lines = []; const paragraphs = text.split('\n'); for (const para of paragraphs) { let line = ''; for (let i = 0; i < para.length; i++) { const testLine = line + para[i]; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && line.length > 0) { lines.push(line); line = para[i]; } else { line = testLine; } } if (line) lines.push(line); } return lines; } /** * Draw a rounded rectangle path. */ _roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } } module.exports = PrivacyPopup;