auth.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { defineStore } from "pinia";
  2. import { ref } from "vue";
  3. import { getCurrentUser } from "@/lib/api";
  4. import { toast } from "vue-sonner";
  5. export const useAuthStore = defineStore("auth", () => {
  6. const user = ref<any>(null);
  7. const isLoading = ref(true);
  8. const showCompleteProfile = ref(false);
  9. let initialized = false;
  10. const wsAuthFailed = ref(false);
  11. async function refreshUser() {
  12. try {
  13. const userData = await getCurrentUser();
  14. user.value = userData;
  15. wsAuthFailed.value = false; // Reset on successful auth
  16. showCompleteProfile.value = !!(
  17. userData && (!userData.phone || !userData.shipping_address)
  18. );
  19. } catch {
  20. user.value = null;
  21. wsAuthFailed.value = true;
  22. showCompleteProfile.value = false;
  23. stopPing();
  24. } finally {
  25. isLoading.value = false;
  26. if (user.value && !pingInterval && !wsAuthFailed.value) startPing();
  27. }
  28. }
  29. let pingInterval: number | null = null;
  30. const unreadMessagesCount = ref(0);
  31. let globalWs: WebSocket | null = null;
  32. let reconnectTimer: number | null = null;
  33. const WS_BASE_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8000";
  34. // ── Audio ──────────────────────────────────────────────────────────────────
  35. // Single shared AudioContext, created lazily on first user gesture.
  36. let sharedAudioCtx: AudioContext | null = null;
  37. function getAudioCtx(): AudioContext | null {
  38. const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
  39. if (!AudioCtx) return null;
  40. if (!sharedAudioCtx) sharedAudioCtx = new AudioCtx();
  41. return sharedAudioCtx;
  42. }
  43. // Unlock AudioContext as soon as the user interacts with the page
  44. function unlockAudio() {
  45. const ctx = getAudioCtx();
  46. if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => {});
  47. }
  48. window.addEventListener('click', unlockAudio, { passive: true });
  49. window.addEventListener('keydown', unlockAudio, { passive: true });
  50. window.addEventListener('touchstart', unlockAudio, { passive: true });
  51. async function playUpdateSound() {
  52. try {
  53. const ctx = getAudioCtx();
  54. if (!ctx) return;
  55. if (ctx.state === 'suspended') await ctx.resume();
  56. const osc = ctx.createOscillator();
  57. const gainNode = ctx.createGain();
  58. osc.type = 'sine';
  59. osc.frequency.setValueAtTime(180, ctx.currentTime);
  60. osc.frequency.exponentialRampToValueAtTime(140, ctx.currentTime + 0.3);
  61. gainNode.gain.setValueAtTime(0, ctx.currentTime);
  62. gainNode.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.05);
  63. gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.35);
  64. osc.connect(gainNode);
  65. gainNode.connect(ctx.destination);
  66. osc.start();
  67. osc.stop(ctx.currentTime + 0.35);
  68. } catch (e) {
  69. console.warn("Audio update sound error", e);
  70. }
  71. }
  72. async function playNotificationSound() {
  73. try {
  74. const ctx = getAudioCtx();
  75. if (!ctx) return;
  76. if (ctx.state === 'suspended') await ctx.resume();
  77. const osc = ctx.createOscillator();
  78. const gainNode = ctx.createGain();
  79. osc.type = 'sine';
  80. osc.frequency.setValueAtTime(880, ctx.currentTime); // A5
  81. osc.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.1); // Up to A6
  82. gainNode.gain.setValueAtTime(0, ctx.currentTime);
  83. gainNode.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.05);
  84. gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.2);
  85. osc.connect(gainNode);
  86. gainNode.connect(ctx.destination);
  87. osc.start();
  88. osc.stop(ctx.currentTime + 0.2);
  89. } catch (e) {
  90. console.warn("Audio notification sound error", e);
  91. }
  92. }
  93. async function playChatSound() {
  94. try {
  95. const ctx = getAudioCtx();
  96. if (!ctx) return;
  97. if (ctx.state === 'suspended') await ctx.resume();
  98. const osc = ctx.createOscillator();
  99. const gainNode = ctx.createGain();
  100. osc.type = "sine";
  101. osc.frequency.setValueAtTime(880, ctx.currentTime);
  102. osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
  103. gainNode.gain.setValueAtTime(0, ctx.currentTime);
  104. gainNode.gain.linearRampToValueAtTime(0.3, ctx.currentTime + 0.05);
  105. gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
  106. osc.connect(gainNode);
  107. gainNode.connect(ctx.destination);
  108. osc.start();
  109. osc.stop(ctx.currentTime + 0.3);
  110. } catch (e) {
  111. console.warn("Audio chat sound error", e);
  112. }
  113. }
  114. // ───────────────────────────────────────────────────────────────────────────
  115. function startPing() {
  116. stopPing(); // Ensure fresh start
  117. const token = localStorage.getItem("token");
  118. if (!token || wsAuthFailed.value) return;
  119. globalWs = new WebSocket(`${WS_BASE_URL}/global?token=${encodeURIComponent(token)}`);
  120. globalWs.onopen = () => {
  121. // Send ping every 25 seconds to keep connection alive and update online status in Redis
  122. pingInterval = window.setInterval(() => {
  123. if (globalWs && globalWs.readyState === WebSocket.OPEN) {
  124. globalWs.send("ping");
  125. }
  126. }, 25000);
  127. };
  128. globalWs.onmessage = (event) => {
  129. try {
  130. const msg = JSON.parse(event.data);
  131. if (msg.type === "unread_count" && msg.count !== undefined) {
  132. if (msg.count > unreadMessagesCount.value) {
  133. playNotificationSound();
  134. }
  135. unreadMessagesCount.value = msg.count;
  136. } else if (msg.type === "new_chat_message") {
  137. toast(`Message for Order #${msg.order_id}`, {
  138. description: msg.text,
  139. action: {
  140. label: "View",
  141. onClick: () => {
  142. window.location.href = `/admin?order=${msg.order_id}`;
  143. }
  144. }
  145. });
  146. } else if (msg.type === "account_suspended") {
  147. toast.error("Your account has been suspended by an administrator.", { duration: 10000 });
  148. logout();
  149. } else if (msg.type === "order_read") {
  150. window.dispatchEvent(new CustomEvent("radionica:order_read", { detail: { order_id: msg.order_id } }));
  151. } else if (msg.type === "order_updated") {
  152. playUpdateSound();
  153. window.dispatchEvent(new CustomEvent("radionica:order_updated", { detail: { order_id: msg.order_id } }));
  154. }
  155. } catch (e) {
  156. console.error("WS Parse error", e);
  157. }
  158. };
  159. globalWs.onclose = (event) => {
  160. if (pingInterval) clearInterval(pingInterval);
  161. // 4001: Auth fail, 4003: Suspended, 1008: Policy
  162. if (event.code === 4001 || event.code === 1008 || event.code === 4003) {
  163. console.warn(`WS Connection terminated (code ${event.code}).`);
  164. wsAuthFailed.value = true;
  165. if (event.code === 4003) {
  166. user.value = null; // Instant clear
  167. localStorage.removeItem("token");
  168. }
  169. return;
  170. }
  171. if (user.value && !wsAuthFailed.value) { // If still logged in and no auth error, try reconnecting
  172. reconnectTimer = window.setTimeout(startPing, 5000);
  173. }
  174. };
  175. globalWs.onerror = () => {
  176. // On error, the close event will follow
  177. console.error("WS Connection error occurred");
  178. };
  179. }
  180. function stopPing() {
  181. if (pingInterval) {
  182. clearInterval(pingInterval);
  183. pingInterval = null;
  184. }
  185. if (reconnectTimer) {
  186. clearTimeout(reconnectTimer);
  187. reconnectTimer = null;
  188. }
  189. if (globalWs) {
  190. globalWs.onclose = null; // Disable auto reconnect
  191. globalWs.onerror = null;
  192. globalWs.close();
  193. globalWs = null;
  194. }
  195. }
  196. let initPromise: Promise<void> | null = null;
  197. function init(): Promise<void> {
  198. if (!initPromise) {
  199. initPromise = refreshUser();
  200. }
  201. return initPromise;
  202. }
  203. function setUser(u: any) {
  204. user.value = u;
  205. wsAuthFailed.value = false;
  206. if (u && !pingInterval) startPing();
  207. }
  208. function onProfileComplete() {
  209. showCompleteProfile.value = false;
  210. refreshUser();
  211. }
  212. async function logout() {
  213. const { logoutUser } = await import("@/lib/api");
  214. try {
  215. await logoutUser();
  216. } catch (e) {
  217. console.error("Logout API call failed, continuing local cleanup", e);
  218. }
  219. localStorage.removeItem("token");
  220. user.value = null;
  221. unreadMessagesCount.value = 0;
  222. stopPing();
  223. }
  224. async function refreshUnreadCount() {
  225. // Left empty or removed, as WS handles updates now.
  226. // If you need manual force, you could re-trigger connect or add an endpoint,
  227. // but the WS pushes update automatically.
  228. }
  229. return {
  230. user,
  231. isLoading,
  232. showCompleteProfile,
  233. unreadMessagesCount,
  234. init,
  235. setUser,
  236. refreshUser,
  237. onProfileComplete,
  238. logout,
  239. refreshUnreadCount,
  240. playChatSound
  241. };
  242. });