From b2bd855de329c648e0af1a79d10542f8a81b04f7 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 11 Aug 2026 13:47:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20iOS=20install=20=E2=86=92=20pro?= =?UTF-8?q?mpt=20notification=20permission;=20phone=20input=20+=20localSto?= =?UTF-8?q?rage=20fallback=20on=20/client=20(no=20token)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/PushSubscriptionBanner.jsx | 89 +++++++++++++++++-- src/pages/ClientDeliveryPage.jsx | 4 + 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/components/client/PushSubscriptionBanner.jsx b/src/components/client/PushSubscriptionBanner.jsx index cc4b705..09e2551 100644 --- a/src/components/client/PushSubscriptionBanner.jsx +++ b/src/components/client/PushSubscriptionBanner.jsx @@ -1,10 +1,12 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; const VAPID_PUBLIC_KEY = "BDLNGyVp7hDzRujZBncNQ0qyz4zVFAhAWX1nsCMRC2tagmer46Sy6exZOymsGVk-TDb1bhkkXXth9Jkzdv4SdJg"; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || ""; const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; +const PHONE_STORAGE_KEY = "supersam_phone"; + const getEndpoint = (action) => `${SUPABASE_URL}/functions/v1/${action}`; const apiHeaders = { "Content-Type": "application/json", @@ -19,6 +21,14 @@ const normalizePhone = (phone) => { return clean; }; +const readStoredPhone = () => { + try { + return window.localStorage.getItem(PHONE_STORAGE_KEY) || ""; + } catch { + return ""; + } +}; + const urlBase64ToUint8Array = (base64String) => { const padding = "=".repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); @@ -33,7 +43,25 @@ const urlBase64ToUint8Array = (base64String) => { export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { const [status, setStatus] = useState("idle"); // idle | prompting | subscribed | denied | unsupported const [dismissed, setDismissed] = useState(false); - const phoneNorm = normalizePhone(phone); + const [phoneInput, setPhoneInput] = useState(""); + + // Persist a known phone so the installed app (which opens without a token) + // can still subscribe. Prefer prop, fall back to stored value. + const propPhone = normalizePhone(phone); + const storedPhone = propPhone || readStoredPhone(); + + useEffect(() => { + if (propPhone) { + try { + window.localStorage.setItem(PHONE_STORAGE_KEY, propPhone); + } catch (e) { + // localStorage unavailable (private mode) — ignore + void e; + } + } + }, [propPhone]); + + const phoneNorm = storedPhone || normalizePhone(phoneInput); // Detect iOS Safari (not in standalone mode = not added to Home Screen) const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; @@ -57,10 +85,23 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { if (existing) { setStatus("subscribed"); } - }).catch(() => {}); + }).catch(() => undefined); }, []); const subscribe = async () => { + const resolvedPhone = normalizePhone(phoneInput) || storedPhone; + if (!resolvedPhone) { + setStatus("need_phone"); + return; + } + if (resolvedPhone !== storedPhone) { + try { + window.localStorage.setItem(PHONE_STORAGE_KEY, resolvedPhone); + } catch (e) { + // localStorage unavailable — ignore + void e; + } + } try { setStatus("prompting"); const reg = await navigator.serviceWorker.ready; @@ -78,7 +119,7 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { method: "POST", headers: apiHeaders, body: JSON.stringify({ - phone_normalized: phoneNorm, + phone_normalized: resolvedPhone, endpoint: sub.endpoint, p256dh: sub.keys?.p256dh || "", auth: sub.keys?.auth || "", @@ -126,7 +167,7 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { }; // Don't render if unsupported, dismissed, or no phone - if (status === "unsupported" || !phoneNorm) return null; + if (status === "unsupported") return null; if (dismissed && status !== "subscribed" && status !== "denied") return null; // iOS not installed — show instruction to add to Home Screen @@ -209,6 +250,42 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { ); } + // Need a phone number to subscribe (opened the installed app without a token) + if (status === "need_phone" || (!phoneNorm && !isIOSNotInstalled)) { + return ( +
+
+ 🔔 +
+

+ Включить уведомления о доставке +

+

+ Введите номер телефона, чтобы получать push-уведомления о заказе. +

+
+ setPhoneInput(e.target.value)} + className="min-w-0 flex-1 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]" + /> + +
+
+
+
+ ); + } + // Idle — show subscription banner return (
@@ -245,4 +322,4 @@ export const PushSubscriptionBanner = ({ phone, orderGroupId }) => { ); }; -export default PushSubscriptionBanner; \ No newline at end of file +export default PushSubscriptionBanner; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 5b38f3a..50683e9 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -362,6 +362,10 @@ export const ClientDeliveryPage = () => { Она приходит в сообщении от «СуперСам». Данные заказа без персональной ссылки не отображаются.

+