Add push notifications (Pushe) + Capacitor shell for Koja
Iran-safe push for the Koja Android app (Cafe Bazaar / Myket / direct APK):
Backend
- PushDevice entity + EF migration; idempotent device register/unregister.
- IPushSender / PusheNotificationSender (Pushe REST) — SendToTopic for
marketing (city-{slug}) and saved-café (cafe-{slug}) pushes, SendToTokens
for targeted order/reservation updates.
- Public register/unregister endpoints + authorized topic broadcast.
App
- capacitor.config.ts (native WebView loads the live PWA via server.url).
- push.ts Pushe glue: topic helpers + backend device registration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli";
|
||||
|
||||
/**
|
||||
* Capacitor native-shell config for Koja (کجا).
|
||||
*
|
||||
* Strategy: the app is an SSR/ISR Next.js site, so we do NOT bundle a static
|
||||
* export. Instead the native WebView loads the live hosted PWA via `server.url`.
|
||||
* Content updates ship instantly (no store re-publish); only native changes
|
||||
* (push SDK, splash, permissions) require a new APK/AAB.
|
||||
*
|
||||
* Stores: Cafe Bazaar + Myket + direct APK. (No Google Play / FCM — Iran.)
|
||||
* Push: Pushe (cordova-plugin-pushe), consumed through Capacitor.
|
||||
*/
|
||||
const config: CapacitorConfig = {
|
||||
appId: "ir.meezi.koja",
|
||||
appName: "کجا",
|
||||
// `webDir` is required by the CLI but unused when `server.url` is set.
|
||||
webDir: "public",
|
||||
server: {
|
||||
// Production PWA the WebView loads. Override per-build with CAP_SERVER_URL.
|
||||
url: process.env.CAP_SERVER_URL ?? "https://koja.meezi.ir",
|
||||
cleartext: false,
|
||||
androidScheme: "https",
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: false,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchShowDuration: 1200,
|
||||
backgroundColor: "#0F6E56",
|
||||
androidScaleType: "CENTER_CROP",
|
||||
showSpinner: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* push.ts — Pushe (Iran-safe) push notification glue for the Capacitor shell.
|
||||
*
|
||||
* On the web (browser) these calls are inert no-ops; they only do real work
|
||||
* inside the native Android shell where `cordova-plugin-pushe` injects the
|
||||
* global `window.Pushe` object.
|
||||
*
|
||||
* Topics
|
||||
* city-{slug} → marketing / new-café pushes for a city
|
||||
* cafe-{slug} → saved-café alerts (new menu, events, status)
|
||||
* Per-device token → targeted order / reservation updates (registered to backend)
|
||||
*
|
||||
* Backend contract (to implement):
|
||||
* POST /api/public/push/register { token, platform: "android", city?, savedCafes?: string[] }
|
||||
* POST /api/public/push/unregister { token }
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.meezi.ir";
|
||||
|
||||
interface PusheBridge {
|
||||
initialize?: (success?: () => void, error?: (e: unknown) => void) => void;
|
||||
getDeviceId?: (cb: (id: string) => void) => void;
|
||||
subscribe: (topic: string) => void;
|
||||
unsubscribe: (topic: string) => void;
|
||||
setUserConsent?: (granted: boolean) => void;
|
||||
}
|
||||
|
||||
function bridge(): PusheBridge | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return (window as unknown as { Pushe?: PusheBridge }).Pushe ?? null;
|
||||
}
|
||||
|
||||
/** True only inside the native Capacitor shell with Pushe available. */
|
||||
export function isPushAvailable(): boolean {
|
||||
return bridge() !== null;
|
||||
}
|
||||
|
||||
/** Resolves the Pushe device token (advertising/device id), or null on web. */
|
||||
export function getDeviceToken(): Promise<string | null> {
|
||||
const p = bridge();
|
||||
if (!p?.getDeviceId) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
p.getDeviceId!((id) => resolve(id || null));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeTopic(topic: string): void {
|
||||
bridge()?.subscribe(topic);
|
||||
}
|
||||
|
||||
export function unsubscribeTopic(topic: string): void {
|
||||
bridge()?.unsubscribe(topic);
|
||||
}
|
||||
|
||||
/** Marketing / new-café pushes for a city. */
|
||||
export const cityTopic = (citySlug: string) => `city-${citySlug}`;
|
||||
/** Saved-café alerts. */
|
||||
export const cafeTopic = (cafeSlug: string) => `cafe-${cafeSlug}`;
|
||||
|
||||
/**
|
||||
* Registers this device with the backend so it can receive targeted
|
||||
* (order/reservation) pushes and so topic membership is mirrored server-side.
|
||||
* No-op on the web.
|
||||
*/
|
||||
export async function registerDevice(opts: {
|
||||
city?: string;
|
||||
savedCafes?: string[];
|
||||
}): Promise<void> {
|
||||
const token = await getDeviceToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
await fetch(`${API_URL}/api/public/push/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, platform: "android", ...opts }),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once after the user grants notification consent. Initializes Pushe,
|
||||
* registers the device, and subscribes to the user's city topic.
|
||||
*/
|
||||
export async function enablePush(citySlug?: string): Promise<void> {
|
||||
const p = bridge();
|
||||
if (!p) return;
|
||||
p.setUserConsent?.(true);
|
||||
p.initialize?.();
|
||||
if (citySlug) subscribeTopic(cityTopic(citySlug));
|
||||
await registerDevice({ city: citySlug });
|
||||
}
|
||||
Reference in New Issue
Block a user