diff --git a/.env.example b/.env.example
index e69de29..e4fd78d 100644
--- a/.env.example
+++ b/.env.example
@@ -0,0 +1,3 @@
+NEXT_PUBLIC_VAPID_PUBLIC_KEY=BBVuTTKBNkLp2WCh0mXH-Biy8A50XlAvCcHU5tEJFfuUIDEdKg4gw_evU08KLAGJzgC8iurciHX8Ac_VclRDrJE
+VAPID_PRIVATE_KEY=7RHcigeLtd48aZReOYVGrQ-KCin1tWMtIwDSqNuM7F8
+VAPID_SUBJECT_EMAIL="mailto:witel@info.com"
\ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index 9027e79..f496203 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,16 +1,6 @@
-// next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
-// @ts-ignore – no types for next-pwa
-const withPWA = require("next-pwa")({
- dest: "public",
- disable: process.env.NODE_ENV === "development",
- register: true,
- skipWaiting: true,
- navigateFallback: "/offline.html", // fallback page
-});
-
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
@@ -22,4 +12,4 @@ const nextConfig: NextConfig = {
const configWithIntl = withNextIntl(nextConfig);
-export default withPWA(configWithIntl);
+export default configWithIntl;
diff --git a/package.json b/package.json
index 9b32e57..438764d 100644
--- a/package.json
+++ b/package.json
@@ -1,48 +1,50 @@
-{
- "name": "witelflix",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "format": "npx prettier . --write"
- },
- "dependencies": {
- "@types/js-cookie": "^3.0.6",
- "axios": "^1.15.0",
- "clsx": "^2.1.1",
- "date-fns": "^4.1.0",
- "date-fns-jalali": "4.1.0-0",
- "embla-carousel": "^8.6.0",
- "embla-carousel-autoplay": "^8.6.0",
- "embla-carousel-react": "^8.6.0",
- "framer-motion": "^12.38.0",
- "js-cookie": "^3.0.5",
- "lucide-react": "^1.8.0",
- "next": "16.1.6",
- "next-intl": "^4.9.1",
- "next-pwa": "^5.6.0",
- "next-sitemap": "^4.2.3",
- "next-themes": "^0.4.6",
- "nextjs-toploader": "^3.9.17",
- "prettier": "^3.8.2",
- "prettier-plugin-tailwindcss": "^0.7.2",
- "radix-ui": "^1.4.3",
- "react": "19.2.4",
- "react-dom": "19.2.4",
- "react-hook-form": "^7.72.1",
- "react-hot-toast": "^2.6.0",
- "tailwind-merge": "^3.5.0",
- "tailwind-scrollbar": "^4.0.2",
- "zustand": "^5.0.12"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4.1.18",
- "@types/node": "25.0.10",
- "@types/react": "19.2.10",
- "@types/react-dom": "19.2.3",
- "postcss": "8.5.6",
- "tailwindcss": "4.1.18",
- "typescript": "5.9.3"
- }
-}
+{
+ "name": "witelflix",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "format": "npx prettier . --write"
+ },
+ "dependencies": {
+ "@types/js-cookie": "^3.0.6",
+ "@types/web-push": "^3.6.4",
+ "axios": "^1.15.0",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "date-fns-jalali": "4.1.0-0",
+ "embla-carousel": "^8.6.0",
+ "embla-carousel-autoplay": "^8.6.0",
+ "embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.38.0",
+ "js-cookie": "^3.0.5",
+ "lucide-react": "^1.8.0",
+ "next": "16.1.6",
+ "next-intl": "^4.9.1",
+ "next-pwa": "^5.6.0",
+ "next-sitemap": "^4.2.3",
+ "next-themes": "^0.4.6",
+ "nextjs-toploader": "^3.9.17",
+ "prettier": "^3.8.2",
+ "prettier-plugin-tailwindcss": "^0.7.2",
+ "radix-ui": "^1.4.3",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "react-hook-form": "^7.72.1",
+ "react-hot-toast": "^2.6.0",
+ "tailwind-merge": "^3.5.0",
+ "tailwind-scrollbar": "^4.0.2",
+ "web-push": "^3.6.7",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.18",
+ "@types/node": "25.0.10",
+ "@types/react": "19.2.10",
+ "@types/react-dom": "19.2.3",
+ "postcss": "8.5.6",
+ "tailwindcss": "4.1.18",
+ "typescript": "5.9.3"
+ }
+}
diff --git a/public/manifest.json b/public/manifest.webmanifest
similarity index 84%
rename from public/manifest.json
rename to public/manifest.webmanifest
index 426125a..c512053 100644
--- a/public/manifest.json
+++ b/public/manifest.webmanifest
@@ -1,8 +1,8 @@
{
"id": "/",
- "name": "Theater of dreams",
- "short_name": "Theater of dreams",
- "description": "Theater of dreams",
+ "name": "Ru'ya",
+ "short_name": "Ru'ya",
+ "description": "Ru'ya",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
diff --git a/public/offline.html b/public/offline.html
new file mode 100644
index 0000000..0ac3cbd
--- /dev/null
+++ b/public/offline.html
@@ -0,0 +1,10 @@
+
+
+
+ Offline
+
+
+ You are offline
+ Please check your connection.
+
+
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..7ff9098
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,77 @@
+const CACHE_NAME = "141-cache-v2";
+
+const STATIC_ASSETS = ["/", "/manifest.webmanifest", "/logo/192px.png", "/logo/512px.png"];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then(async (cache) => {
+ await Promise.allSettled(
+ STATIC_ASSETS.map(async (url) => {
+ try {
+ const response = await fetch(url);
+ if (response.ok) {
+ await cache.put(url, response);
+ }
+ } catch (e) {
+ console.warn("❌ Failed to cache:", url);
+ }
+ })
+ );
+ })
+ );
+
+ self.skipWaiting();
+});
+
+const sendDeliveryReportAction = () => {
+ console.log("Web push delivered.");
+};
+
+self.addEventListener("push", function (event) {
+ if (event.data) {
+ try {
+ // Try to parse as JSON first
+ let data;
+ try {
+ data = event.data.json();
+ } catch (jsonError) {
+ // If JSON parsing fails, treat it as plain text
+ const text = event.data.text();
+ data = {
+ title: "Notification",
+ body: text,
+ icon: "/icon.png",
+ };
+ }
+
+ const options = {
+ body: data.body || "Default notification body",
+ icon: data.icon || "/logo/192px.png",
+ badge: "/badge.png",
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ primaryKey: "2",
+ url: data.url || "/",
+ },
+ };
+ event.waitUntil(
+ self.registration.showNotification(data.title, options).then(() => {
+ sendDeliveryReportAction();
+ })
+ );
+ } catch (error) {
+ console.error("Error processing push notification:", error);
+ }
+ }
+});
+self.addEventListener("notificationclick", function (event) {
+ event.notification.close();
+ event.waitUntil(clients.openWindow(event.notification.data.url));
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data && event.data.type === "SKIP_WAITING") {
+ self.skipWaiting();
+ }
+});
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
new file mode 100644
index 0000000..91adf6b
--- /dev/null
+++ b/src/app/api/notifications/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from "next/server";
+
+let notifications = [
+ {
+ id: 1,
+ title: "خوش آمدید",
+ body: "این یک نوتیفیکیشن تستی است",
+ type: "info",
+ created_at: new Date().toISOString(),
+ },
+ {
+ id: 2,
+ title: "پرداخت موفق",
+ body: "پرداخت شما با موفقیت انجام شد",
+ type: "success",
+ created_at: new Date().toISOString(),
+ },
+];
+
+export async function GET() {
+ return NextResponse.json(notifications);
+}
diff --git a/src/app/api/notifications/sse/route.ts b/src/app/api/notifications/sse/route.ts
new file mode 100644
index 0000000..4394e18
--- /dev/null
+++ b/src/app/api/notifications/sse/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest } from "next/server";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ start(controller) {
+ let counter = 3;
+
+ const interval = setInterval(() => {
+ const data = {
+ id: counter,
+ title: `نوتیفیکیشن جدید ${counter}`,
+ body: "این پیام از SSE اومده",
+ type: ["info", "success", "warning", "error"][counter % 4],
+ created_at: new Date().toISOString(),
+ };
+
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
+
+ controller.enqueue(encoder.encode(payload));
+ counter++;
+ }, 5000);
+
+ req.signal.addEventListener("abort", () => {
+ clearInterval(interval);
+ controller.close();
+ });
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/src/app/api/push-notifications/route.ts b/src/app/api/push-notifications/route.ts
new file mode 100644
index 0000000..d151a9f
--- /dev/null
+++ b/src/app/api/push-notifications/route.ts
@@ -0,0 +1,175 @@
+import { NextRequest, NextResponse } from "next/server";
+import webpush from "web-push";
+
+// Initialize VAPID keys
+webpush.setVapidDetails(
+ process.env.VAPID_SUBJECT_EMAIL!,
+ process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
+ process.env.VAPID_PRIVATE_KEY!
+);
+
+// Define the subscription type that matches what web-push expects
+interface WebPushSubscription {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+}
+
+// Store subscriptions in memory with user IDs
+// In production, replace this with Redis or a database
+const userSubscriptions: Map = new Map();
+
+// Save subscription for a specific user
+async function saveUserSubscription(userId: string, subscription: WebPushSubscription) {
+ userSubscriptions.set(userId, subscription);
+ // In production: save to Redis/Database
+ // await redis.set(`push:${userId}`, JSON.stringify(subscription));
+}
+
+// Get subscription for a specific user
+async function getUserSubscription(userId: string): Promise {
+ return userSubscriptions.get(userId) || null;
+ // In production: get from Redis/Database
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+
+ // Handle different actions
+ const { action, userId, subscription, title, message, icon, url } = body;
+
+ // Action 1: Register/update subscription for a user
+ if (action === "register") {
+ if (!userId || !subscription) {
+ return NextResponse.json({ error: "userId and subscription are required" }, { status: 400 });
+ }
+
+ // Validate subscription has required fields
+ if (!subscription.endpoint || !subscription.keys || !subscription.keys.p256dh || !subscription.keys.auth) {
+ return NextResponse.json(
+ { error: "Invalid subscription format - missing endpoint or keys" },
+ { status: 400 }
+ );
+ }
+
+ const webPushSubscription: WebPushSubscription = {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: subscription.keys.p256dh,
+ auth: subscription.keys.auth,
+ },
+ };
+
+ await saveUserSubscription(userId, webPushSubscription);
+
+ return NextResponse.json({
+ success: true,
+ message: `Subscription registered for user ${userId}`,
+ });
+ }
+
+ // Action 2: Send notification to a specific user
+ if (action === "send") {
+ if (!userId) {
+ return NextResponse.json({ error: "userId is required" }, { status: 400 });
+ }
+
+ const userSubscription = await getUserSubscription(userId);
+
+ if (!userSubscription) {
+ return NextResponse.json({ error: `No subscription found for user ${userId}` }, { status: 404 });
+ }
+
+ const payload = JSON.stringify({
+ title: title || "Notification",
+ body: message || "You have a new notification",
+ icon: icon || "/logo/192px.png",
+ badge: "/badge.png",
+ vibrate: [100, 50, 100],
+ data: {
+ url: url || "/",
+ dateOfArrival: Date.now(),
+ },
+ });
+
+ // Send notification using web-push
+ await webpush.sendNotification(userSubscription, payload);
+
+ return NextResponse.json({
+ success: true,
+ message: `Notification sent to user ${userId}`,
+ });
+ }
+
+ // Action 3: Send to multiple users
+ if (action === "send-multiple") {
+ const { userIds, title, message, icon, url } = body;
+
+ if (!userIds || !Array.isArray(userIds)) {
+ return NextResponse.json({ error: "userIds array is required" }, { status: 400 });
+ }
+
+ const results = [];
+
+ for (const uid of userIds) {
+ const userSubscription = await getUserSubscription(uid);
+
+ if (userSubscription) {
+ try {
+ const payload = JSON.stringify({
+ title: title || "Notification",
+ body: message || "You have a new notification",
+ icon: icon || "/logo/192px.png",
+ badge: "/badge.png",
+ data: { url: url || "/" },
+ });
+
+ await webpush.sendNotification(userSubscription, payload);
+ results.push({ userId: uid, status: "success" });
+ } catch (error) {
+ results.push({
+ userId: uid,
+ status: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ } else {
+ results.push({ userId: uid, status: "no_subscription" });
+ }
+ }
+
+ return NextResponse.json({
+ success: true,
+ results,
+ });
+ }
+
+ return NextResponse.json({ error: "Invalid action. Use: register, send, or send-multiple" }, { status: 400 });
+ } catch (error) {
+ console.error("Error in push API:", error);
+ return NextResponse.json(
+ { error: "Internal server error", details: error instanceof Error ? error.message : String(error) },
+ { status: 500 }
+ );
+ }
+}
+
+// GET endpoint to check if a user is subscribed
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const userId = searchParams.get("userId");
+
+ if (!userId) {
+ return NextResponse.json({ error: "userId required" }, { status: 400 });
+ }
+
+ const subscription = await getUserSubscription(userId);
+
+ return NextResponse.json({
+ userId,
+ isSubscribed: !!subscription,
+ });
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e06c8d3..3d45b2c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,16 +6,22 @@ import { NextIntlClientProvider } from "next-intl";
export const metadata: Metadata = {
title: {
- template: "%s | Theater of dreams",
- default: "Theater of dreams",
+ template: "%s | Ru'ya",
+ default: "Ru'ya",
},
- description: "Theater of dreams",
+ description: "Ru'ya",
+ appleWebApp: {
+ capable: true, // 🔴 REQUIRED for iOS standalone mode
+ statusBarStyle: "black-translucent",
+ title: "Ru'ya",
+ },
+ applicationName: "Ru'ya",
authors: {
name: "شرکت دانش بنیان ویرا ارتباطات یکتا تلفن (وایتل)",
url: "https://witel.ir",
},
- manifest: "/manifest.json",
+ manifest: "/manifest.webmanifest",
icons: {
icon: "/logo/192px.png",
apple: "/logo/512px.png",
diff --git a/src/components/PWAPush/PushNotificationManager.tsx b/src/components/PWAPush/PushNotificationManager.tsx
new file mode 100644
index 0000000..39ead4f
--- /dev/null
+++ b/src/components/PWAPush/PushNotificationManager.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useEffect } from "react";
+
+function urlBase64ToUint8Array(base64String: string) {
+ // Add padding if needed
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+function getUserId(): string {
+ let userId = localStorage.getItem("push_user_id");
+ if (!userId) {
+ userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ localStorage.setItem("push_user_id", userId);
+ }
+ return userId;
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
+ if (!buffer) return "";
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+export default function AutoPushManager() {
+ useEffect(() => {
+ const initPush = async () => {
+ if (!("serviceWorker" in navigator && "PushManager" in window)) {
+ console.log("Push not supported");
+ return;
+ }
+
+ try {
+ // Register service worker
+ const registration = await navigator.serviceWorker.register("/sw.js", {
+ scope: "/",
+ updateViaCache: "none",
+ });
+
+ await navigator.serviceWorker.ready;
+ console.log("Service worker ready");
+
+ // Check existing subscription
+ let subscription = await registration.pushManager.getSubscription();
+
+ if (subscription) {
+ console.log("Already subscribed");
+ // Still register with backend
+ const subscriptionObj = {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: arrayBufferToBase64(subscription.getKey("p256dh")),
+ auth: arrayBufferToBase64(subscription.getKey("auth")),
+ },
+ };
+
+ await fetch("/api/push-notifications", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ action: "register",
+ userId: getUserId(),
+ subscription: subscriptionObj,
+ }),
+ });
+
+ console.log("Push notifications already ready!");
+ return;
+ }
+
+ // Check permission status
+ console.log("Current permission:", Notification.permission);
+
+ if (Notification.permission === "denied") {
+ console.log("Notifications are blocked. Please enable them in browser settings.");
+ return;
+ }
+
+ // Request permission (this shows the browser prompt)
+ console.log("Requesting permission...");
+ const permission = await Notification.requestPermission();
+ console.log("Permission result:", permission);
+
+ if (permission === "granted") {
+ console.log("Creating subscription...");
+ // This might still take a moment, but should complete
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
+ });
+
+ console.log("Subscription created:", subscription);
+
+ const subscriptionObj = {
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: arrayBufferToBase64(subscription.getKey("p256dh")),
+ auth: arrayBufferToBase64(subscription.getKey("auth")),
+ },
+ };
+
+ await fetch("/api/push-notifications", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ action: "register",
+ userId: getUserId(),
+ subscription: subscriptionObj,
+ }),
+ });
+
+ console.log("Push notifications ready! User ID:", getUserId());
+ } else {
+ console.log("Notification permission not granted:", permission);
+ }
+ } catch (error) {
+ console.error("Push error:", error);
+ }
+ };
+
+ // Add a small delay to ensure everything is loaded
+ setTimeout(initPush, 1000);
+ }, []);
+
+ return null;
+}
diff --git a/src/providers/ClientAppProvider.tsx b/src/providers/ClientAppProvider.tsx
index a43ed8f..d155e40 100644
--- a/src/providers/ClientAppProvider.tsx
+++ b/src/providers/ClientAppProvider.tsx
@@ -7,6 +7,26 @@ import { useEffect, useState } from "react";
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ if ("serviceWorker" in navigator) {
+ const registerSW = async () => {
+ try {
+ const registration = await navigator.serviceWorker.register("/sw.js", {
+ scope: "/",
+ updateViaCache: "none",
+ });
+ console.log("Service Worker registered successfully:", registration);
+ } catch (error) {
+ console.error("Service Worker registration failed:", error);
+ }
+ };
+
+ // Register on page load and after navigation
+ registerSW();
+ window.addEventListener("load", registerSW);
+ }
+ }, []);
+
// wait for hydration
useEffect(() => {
setMounted(true);