diff --git a/src/api/extAppsApi.js b/src/api/extAppsApi.js
new file mode 100644
index 0000000..0f3d6dc
--- /dev/null
+++ b/src/api/extAppsApi.js
@@ -0,0 +1,324 @@
+/**
+ * External Apps API Client
+ *
+ * Handles communication with apps-gateway for:
+ * - App registry (browse, install)
+ * - Subscriptions (with consent)
+ * - Federated feed (posts from subscribed apps)
+ * - Asset proxy URLs
+ */
+
+import { loadAuth } from "../auth/authStorage";
+
+function apiBase() {
+ const raw = (import.meta?.env?.VITE_API_BASE_URL || "")
+ .toString()
+ .replace(/\/+$/, "");
+ if (raw) return raw;
+ try {
+ if (typeof window !== "undefined") {
+ const host = window.location.hostname || "";
+ if (host.startsWith("www.")) {
+ return `https://${host.replace(/^www\./, "")}`;
+ }
+ }
+ } catch {}
+ return "";
+}
+
+function authHeaders() {
+ try {
+ const auth = loadAuth();
+ const token = auth?.access_token;
+ if (!token) return {};
+ return { Authorization: `Bearer ${token}` };
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * List all available apps in the registry
+ */
+export async function listApps({ category, level, status = "active" } = {}) {
+ const params = new URLSearchParams();
+ if (category) params.set("category", category);
+ if (level) params.set("level", level);
+ if (status) params.set("status", status);
+
+ const res = await fetch(`${apiBase()}/api/registry?${params.toString()}`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to list apps: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Get a single app by ID
+ */
+export async function getApp(appId) {
+ const res = await fetch(`${apiBase()}/api/registry/${appId}`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ if (res.status === 404) return null;
+ throw new Error(`Failed to get app: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Subscribe to an app with consent
+ */
+export async function subscribeToApp(appId, consent, tier = "free") {
+ const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ ...authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ consent, tier }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Failed to subscribe: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Unsubscribe from an app
+ */
+export async function unsubscribeFromApp(appId) {
+ const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
+ method: "DELETE",
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to unsubscribe: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * List user's subscribed apps
+ */
+export async function listSubscriptions() {
+ const res = await fetch(`${apiBase()}/api/subscriptions`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to list subscriptions: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Get subscription details for an app
+ */
+export async function getSubscription(appId) {
+ const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (res.status === 404) return null;
+ if (!res.ok) {
+ throw new Error(`Failed to get subscription: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Update subscription (tier, consent)
+ */
+export async function updateSubscription(appId, updates) {
+ const res = await fetch(`${apiBase()}/api/subscriptions/${appId}`, {
+ method: "PATCH",
+ credentials: "include",
+ headers: {
+ ...authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(updates),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to update subscription: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Get federated feed from subscribed apps
+ */
+export async function getFeed({ bbox, zoom, apps, limit = 50 } = {}) {
+ const params = new URLSearchParams();
+ if (bbox) {
+ params.set("bbox", `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`);
+ }
+ if (zoom) params.set("zoom", zoom);
+ if (apps && apps.length > 0) params.set("apps", apps.join(","));
+ if (limit) params.set("limit", limit);
+
+ const res = await fetch(`${apiBase()}/api/feed?${params.toString()}`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to get feed: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Search across subscribed apps
+ */
+export async function searchApps({ query, location, apps, filters, limit = 20 } = {}) {
+ const res = await fetch(`${apiBase()}/api/feed/search`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ ...authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ query, location, apps, filters, limit }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Failed to search: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Build asset URL with optional resize params
+ */
+export function buildAssetUrl(appId, path, { w, h, q } = {}) {
+ const base = `${apiBase()}/api/assets/${appId}/${path}`;
+ const params = new URLSearchParams();
+ if (w) params.set("w", w);
+ if (h) params.set("h", h);
+ if (q) params.set("q", q);
+ const qs = params.toString();
+ return qs ? `${base}?${qs}` : base;
+}
+
+/**
+ * Upload images for a post
+ */
+export async function uploadPostImages(appId, files) {
+ const formData = new FormData();
+ for (const file of files) {
+ formData.append("images", file);
+ }
+
+ const res = await fetch(`${apiBase()}/api/posts/${appId}/upload`, {
+ method: "POST",
+ credentials: "include",
+ headers: authHeaders(),
+ body: formData,
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Upload failed: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Create a post for an external app
+ */
+export async function createPost(appId, postData) {
+ const res = await fetch(`${apiBase()}/api/posts/${appId}`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ ...authHeaders(),
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(postData),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Create post failed: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Get post status
+ */
+export async function getPostStatus(appId, postId) {
+ const res = await fetch(`${apiBase()}/api/posts/${appId}/${postId}`, {
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ if (res.status === 404) return null;
+ throw new Error(`Get post failed: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Delete a post
+ */
+export async function deletePost(appId, postId) {
+ const res = await fetch(`${apiBase()}/api/posts/${appId}/${postId}`, {
+ method: "DELETE",
+ credentials: "include",
+ headers: authHeaders(),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Delete post failed: ${res.status}`);
+ }
+
+ return res.json();
+}
+
+export default {
+ listApps,
+ getApp,
+ subscribeToApp,
+ unsubscribeFromApp,
+ listSubscriptions,
+ getSubscription,
+ updateSubscription,
+ getFeed,
+ searchApps,
+ buildAssetUrl,
+ uploadPostImages,
+ createPost,
+ getPostStatus,
+ deletePost,
+};
diff --git a/src/components/Apps/AppCard.jsx b/src/components/Apps/AppCard.jsx
new file mode 100644
index 0000000..da60784
--- /dev/null
+++ b/src/components/Apps/AppCard.jsx
@@ -0,0 +1,99 @@
+/**
+ * AppCard - Display an app in the store
+ */
+
+import React from "react";
+import { buildAssetUrl } from "../../api/extAppsApi";
+
+export default function AppCard({ app, subscription, onSubscribe }) {
+ const isSubscribed = !!subscription;
+ const iconUrl = app.icon
+ ? buildAssetUrl(app.app_id, app.icon, { w: 80, h: 80 })
+ : null;
+
+ return (
+
+ {/* Header */}
+
+ {/* Icon */}
+
+ {!iconUrl && (
+
+ )}
+
+
+ {/* Info */}
+
+
{app.name}
+
{app.developer || "Développeur"}
+
+ {app.level === "advanced" && (
+
+ Avancé
+
+ )}
+ {app.category && (
+ {app.category}
+ )}
+
+
+
+
+ {/* Description */}
+
+ {app.description || "Aucune description disponible"}
+
+
+ {/* Stats */}
+ {app.stats && (
+
+
+
+ {app.stats.subscribers || 0}
+
+ {app.stats.posts && (
+
+
+ {app.stats.posts}
+
+ )}
+
+ )}
+
+ {/* Actions */}
+
+ {isSubscribed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/Apps/AppStore.jsx b/src/components/Apps/AppStore.jsx
new file mode 100644
index 0000000..739d2d9
--- /dev/null
+++ b/src/components/Apps/AppStore.jsx
@@ -0,0 +1,162 @@
+/**
+ * App Store - Browse and subscribe to external apps
+ */
+
+import React, { useState, useEffect, useCallback } from "react";
+import { listApps, subscribeToApp, listSubscriptions } from "../../api/extAppsApi";
+import AppCard from "./AppCard";
+import SubscribeModal from "./SubscribeModal";
+
+const CATEGORIES = [
+ { id: "all", label: "Tout", icon: "fa-globe" },
+ { id: "marketplace", label: "Marché", icon: "fa-store" },
+ { id: "services", label: "Services", icon: "fa-wrench" },
+ { id: "social", label: "Social", icon: "fa-users" },
+ { id: "info", label: "Info", icon: "fa-info-circle" },
+ { id: "entertainment", label: "Divertissement", icon: "fa-gamepad" },
+];
+
+export default function AppStore({ onClose }) {
+ const [apps, setApps] = useState([]);
+ const [subscriptions, setSubscriptions] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [category, setCategory] = useState("all");
+ const [selectedApp, setSelectedApp] = useState(null);
+ const [showSubscribeModal, setShowSubscribeModal] = useState(false);
+
+ const loadApps = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const [appsResult, subsResult] = await Promise.all([
+ listApps({ category: category === "all" ? undefined : category }),
+ listSubscriptions(),
+ ]);
+
+ setApps(appsResult.apps || []);
+
+ // Build subscriptions lookup
+ const subsMap = {};
+ for (const sub of subsResult.subscriptions || []) {
+ subsMap[sub.app_id] = sub;
+ }
+ setSubscriptions(subsMap);
+ } catch (err) {
+ console.error("Failed to load apps:", err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [category]);
+
+ useEffect(() => {
+ loadApps();
+ }, [loadApps]);
+
+ const handleSubscribe = (app) => {
+ setSelectedApp(app);
+ setShowSubscribeModal(true);
+ };
+
+ const handleConfirmSubscribe = async (consent, tier) => {
+ if (!selectedApp) return;
+
+ try {
+ await subscribeToApp(selectedApp.app_id, consent, tier);
+ setShowSubscribeModal(false);
+ setSelectedApp(null);
+ await loadApps(); // Refresh
+ } catch (err) {
+ console.error("Subscribe failed:", err);
+ alert(err.message);
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ App Store
+
+
+
+
+ {/* Categories */}
+
+ {CATEGORIES.map((cat) => (
+
+ ))}
+
+
+ {/* Content */}
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+
{error}
+
+
+ ) : apps.length === 0 ? (
+
+
+
Aucune app disponible dans cette catégorie
+
+ ) : (
+
+ {apps.map((app) => (
+
handleSubscribe(app)}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* Subscribe Modal */}
+ {showSubscribeModal && selectedApp && (
+
{
+ setShowSubscribeModal(false);
+ setSelectedApp(null);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/Apps/CreateAppPost.jsx b/src/components/Apps/CreateAppPost.jsx
new file mode 100644
index 0000000..4d365d5
--- /dev/null
+++ b/src/components/Apps/CreateAppPost.jsx
@@ -0,0 +1,390 @@
+/**
+ * CreateAppPost - Create a post for an external app
+ *
+ * User selects which app to post to (from subscribed apps),
+ * fills out form with images, and submits through Sociowire.
+ */
+
+import React, { useState, useEffect, useRef } from "react";
+import { listSubscriptions, uploadPostImages, createPost, buildAssetUrl } from "../../api/extAppsApi";
+
+export default function CreateAppPost({ onClose, onSuccess, initialLocation }) {
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [selectedApp, setSelectedApp] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Form state
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [category, setCategory] = useState("");
+ const [price, setPrice] = useState("");
+ const [images, setImages] = useState([]);
+ const [imagePreviews, setImagePreviews] = useState([]);
+ const [location, setLocation] = useState(initialLocation || null);
+
+ const fileInputRef = useRef(null);
+
+ // Load subscribed apps
+ useEffect(() => {
+ loadSubscriptions();
+ }, []);
+
+ const loadSubscriptions = async () => {
+ try {
+ setLoading(true);
+ const result = await listSubscriptions();
+ const apps = (result.subscriptions || []).filter(
+ (sub) => sub.capabilities?.includes("posts")
+ );
+ setSubscriptions(apps);
+ if (apps.length === 1) {
+ setSelectedApp(apps[0]);
+ }
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleImageSelect = (e) => {
+ const files = Array.from(e.target.files);
+ if (files.length === 0) return;
+
+ // Limit to 5 images
+ const newImages = [...images, ...files].slice(0, 5);
+ setImages(newImages);
+
+ // Generate previews
+ const newPreviews = newImages.map((file) => URL.createObjectURL(file));
+ // Clean up old previews
+ imagePreviews.forEach((url) => URL.revokeObjectURL(url));
+ setImagePreviews(newPreviews);
+ };
+
+ const removeImage = (index) => {
+ const newImages = images.filter((_, i) => i !== index);
+ setImages(newImages);
+
+ URL.revokeObjectURL(imagePreviews[index]);
+ const newPreviews = imagePreviews.filter((_, i) => i !== index);
+ setImagePreviews(newPreviews);
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!selectedApp) {
+ setError("Sélectionnez une app");
+ return;
+ }
+
+ if (!title.trim()) {
+ setError("Le titre est requis");
+ return;
+ }
+
+ if (!location) {
+ setError("La localisation est requise");
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ // 1. Upload images
+ let uploadedImages = [];
+ if (images.length > 0) {
+ const uploadResult = await uploadPostImages(selectedApp.app_id, images);
+ uploadedImages = uploadResult.files.map((f) => f.path);
+ }
+
+ // 2. Create post
+ const postData = {
+ title: title.trim(),
+ description: description.trim(),
+ images: uploadedImages,
+ location: {
+ lat: location.lat,
+ lon: location.lon || location.lng,
+ address: location.address
+ },
+ category: category || undefined,
+ content: {}
+ };
+
+ // Add app-specific fields
+ if (price) {
+ postData.content.price = parseFloat(price);
+ }
+
+ const result = await createPost(selectedApp.app_id, postData);
+
+ onSuccess?.(result);
+ onClose?.();
+
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (subscriptions.length === 0) {
+ return (
+
+
+
+
+ Vous n'êtes abonné à aucune app qui accepte les posts
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Créer un post
+
+
+
+
+ {/* Form */}
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Apps/ExtAppPostCard.jsx b/src/components/Apps/ExtAppPostCard.jsx
new file mode 100644
index 0000000..467696b
--- /dev/null
+++ b/src/components/Apps/ExtAppPostCard.jsx
@@ -0,0 +1,182 @@
+/**
+ * ExtAppPostCard - Render posts from external apps
+ *
+ * Supports two rendering modes:
+ * - Level 1: JSON config (uses CardRenderer)
+ * - Level 3: Pre-rendered HTML from sandbox (uses dangerouslySetInnerHTML)
+ */
+
+import React, { useMemo } from "react";
+import CardRenderer from "../Cards/CardRenderer";
+import { buildAssetUrl } from "../../api/extAppsApi";
+
+// Default theme tokens for Level 1 cards
+const DEFAULT_TOKENS = {
+ colors: {
+ surface: "#1a1a1a",
+ primary: "#8b5cf6",
+ text: "#ffffff",
+ textSecondary: "#a1a1aa",
+ accent: "#22c55e",
+ },
+ typography: {
+ title: { fontSize: 16, fontWeight: 600, color: "#ffffff" },
+ subtitle: { fontSize: 14, fontWeight: 400, color: "#a1a1aa" },
+ body: { fontSize: 13, color: "#d4d4d8" },
+ caption: { fontSize: 11, color: "#71717a" },
+ price: { fontSize: 18, fontWeight: 700, color: "#22c55e" },
+ },
+ chips: {
+ default: { background: "#27272a", color: "#d4d4d8", borderRadius: 12 },
+ primary: { background: "#8b5cf6", color: "#ffffff", borderRadius: 12 },
+ success: { background: "#166534", color: "#86efac", borderRadius: 12 },
+ },
+ buttons: {
+ primary: { background: "#8b5cf6", color: "#ffffff", borderRadius: 8 },
+ secondary: { background: "#27272a", color: "#d4d4d8", borderRadius: 8 },
+ },
+ radius: { card: 16 },
+ shadow: { card: "0 4px 12px rgba(0,0,0,0.3)" },
+};
+
+/**
+ * Process image URLs in post data to use asset proxy
+ */
+function processAssetUrls(post, appId) {
+ const processed = { ...post };
+
+ const processUrl = (url) => {
+ if (!url || typeof url !== "string") return url;
+ // If it's an app-relative path (not http)
+ if (!url.startsWith("http") && !url.startsWith("/api/assets")) {
+ return buildAssetUrl(appId, url, { w: 400 });
+ }
+ return url;
+ };
+
+ if (processed.image) processed.image = processUrl(processed.image);
+ if (processed.image_small) processed.image_small = processUrl(processed.image_small);
+ if (processed.image_medium) processed.image_medium = processUrl(processed.image_medium);
+ if (processed.thumbnail) processed.thumbnail = processUrl(processed.thumbnail);
+
+ return processed;
+}
+
+/**
+ * Default card spec for apps without custom card_style
+ */
+const DEFAULT_CARD_SPEC = {
+ size: { w: 280, h: 180 },
+ radius: 16,
+ background: { type: "image", bind: "data.image" },
+ layers: [
+ {
+ id: "gradient",
+ type: "rect",
+ x: 0,
+ y: 80,
+ w: 280,
+ h: 100,
+ style: "gradients.bottom",
+ },
+ {
+ id: "title",
+ type: "text",
+ x: 12,
+ y: 120,
+ w: 256,
+ bind: "data.title",
+ style: "typography.title",
+ maxLines: 2,
+ },
+ {
+ id: "source",
+ type: "text",
+ x: 12,
+ y: 155,
+ w: 200,
+ bind: "data.source_name",
+ style: "typography.caption",
+ },
+ ],
+};
+
+export default function ExtAppPostCard({
+ post,
+ appId,
+ cardStyle,
+ onAction,
+ className = "",
+}) {
+ // Process asset URLs
+ const processedPost = useMemo(
+ () => processAssetUrls(post, appId),
+ [post, appId]
+ );
+
+ // If post has pre-rendered content from Level 3 sandbox
+ if (post.rendered && post.rendered.html) {
+ return (
+ onAction?.({ type: "open", bind: "post_id" }, post.post_id)}
+ />
+ );
+ }
+
+ // Level 1: Use CardRenderer with JSON spec
+ const spec = cardStyle || DEFAULT_CARD_SPEC;
+
+ return (
+
onAction?.(action, value)}
+ className={`ext-app-card ${className}`}
+ />
+ );
+}
+
+/**
+ * Small version of the card for map markers
+ */
+export function ExtAppPostMarker({ post, appId, onClick }) {
+ const imageUrl = post.image_small || post.thumbnail || post.image;
+ const processedUrl = imageUrl && !imageUrl.startsWith("http")
+ ? buildAssetUrl(appId, imageUrl, { w: 80, h: 80 })
+ : imageUrl;
+
+ return (
+
+ );
+}
diff --git a/src/components/Apps/MySubscriptions.jsx b/src/components/Apps/MySubscriptions.jsx
new file mode 100644
index 0000000..7f32419
--- /dev/null
+++ b/src/components/Apps/MySubscriptions.jsx
@@ -0,0 +1,162 @@
+/**
+ * MySubscriptions - Manage user's app subscriptions
+ */
+
+import React, { useState, useEffect, useCallback } from "react";
+import { listSubscriptions, unsubscribeFromApp, updateSubscription } from "../../api/extAppsApi";
+
+export default function MySubscriptions({ onOpenStore }) {
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadSubscriptions = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const result = await listSubscriptions();
+ setSubscriptions(result.subscriptions || []);
+ } catch (err) {
+ console.error("Failed to load subscriptions:", err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadSubscriptions();
+ }, [loadSubscriptions]);
+
+ const handleUnsubscribe = async (appId, appName) => {
+ if (!confirm(`Voulez-vous vraiment vous désabonner de ${appName}?`)) {
+ return;
+ }
+
+ try {
+ await unsubscribeFromApp(appId);
+ await loadSubscriptions();
+ } catch (err) {
+ console.error("Unsubscribe failed:", err);
+ alert(err.message);
+ }
+ };
+
+ const handleToggleConsent = async (sub, permission) => {
+ try {
+ const newConsent = {
+ ...sub.consent,
+ [permission]: !sub.consent[permission],
+ };
+ await updateSubscription(sub.app_id, { consent: newConsent });
+ await loadSubscriptions();
+ } catch (err) {
+ console.error("Update consent failed:", err);
+ alert(err.message);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (subscriptions.length === 0) {
+ return (
+
+
+
Vous n'êtes abonné à aucune app
+
+
+ );
+ }
+
+ return (
+
+ {subscriptions.map((sub) => (
+
+ {/* Header */}
+
+
+
{sub.app_name}
+
+ Abonné depuis {new Date(sub.subscribed_at).toLocaleDateString("fr-CA")}
+
+
+
+ {sub.tier === "premium" && (
+
+ Premium
+
+ )}
+
+
+
+ {/* Description */}
+ {sub.app_description && (
+
+ {sub.app_description}
+
+ )}
+
+ {/* Consent toggles */}
+
+ {Object.entries(sub.consent || {}).map(([perm, enabled]) => (
+
+ ))}
+
+
+ {/* Actions */}
+
+
+
+
+ ))}
+
+ {/* Add more apps button */}
+
+
+ );
+}
diff --git a/src/components/Apps/SubscribeModal.jsx b/src/components/Apps/SubscribeModal.jsx
new file mode 100644
index 0000000..9bf824c
--- /dev/null
+++ b/src/components/Apps/SubscribeModal.jsx
@@ -0,0 +1,195 @@
+/**
+ * SubscribeModal - Consent flow for subscribing to an app
+ */
+
+import React, { useState } from "react";
+
+const PERMISSIONS = {
+ username: {
+ label: "Nom d'utilisateur",
+ description: "L'app peut voir votre nom d'utilisateur public",
+ icon: "fa-user",
+ },
+ location: {
+ label: "Localisation",
+ description: "L'app peut voir votre position approximative",
+ icon: "fa-map-marker-alt",
+ },
+ profile: {
+ label: "Profil",
+ description: "L'app peut accéder à votre profil public",
+ icon: "fa-id-card",
+ },
+};
+
+export default function SubscribeModal({ app, onConfirm, onCancel }) {
+ const required = app.permissions?.required || ["username", "location"];
+ const optional = app.permissions?.optional || [];
+
+ const [consent, setConsent] = useState(() => {
+ const initial = {};
+ for (const perm of required) {
+ initial[perm] = true;
+ }
+ for (const perm of optional) {
+ initial[perm] = false;
+ }
+ return initial;
+ });
+
+ const [tier, setTier] = useState("free");
+ const [loading, setLoading] = useState(false);
+
+ const handleToggle = (perm) => {
+ if (required.includes(perm)) return; // Can't toggle required
+ setConsent((prev) => ({ ...prev, [perm]: !prev[perm] }));
+ };
+
+ const handleSubmit = async () => {
+ setLoading(true);
+ try {
+ await onConfirm(consent, tier);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const allRequiredConsented = required.every((p) => consent[p]);
+
+ return (
+
+
+ {/* Header */}
+
+
+ S'abonner à {app.name}
+
+
+ Cette app requiert votre consentement pour accéder à certaines informations.
+
+
+
+ {/* Permissions */}
+
+ {/* Required */}
+ {required.length > 0 && (
+
+
+ Requis
+
+ {required.map((perm) => {
+ const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
+ return (
+
+
+
+
+
+
{info.label}
+
{info.description}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Optional */}
+ {optional.length > 0 && (
+
+
+ Optionnel
+
+ {optional.map((perm) => {
+ const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
+ const enabled = consent[perm];
+ return (
+
+ );
+ })}
+
+ )}
+
+ {/* Tier selection (if app has premium) */}
+ {app.tiers && app.tiers.includes("premium") && (
+
+
+ Abonnement
+
+
+
+
+
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Apps/index.js b/src/components/Apps/index.js
new file mode 100644
index 0000000..f248126
--- /dev/null
+++ b/src/components/Apps/index.js
@@ -0,0 +1,11 @@
+/**
+ * Apps Components - External apps integration
+ */
+
+export { default as AppStore } from "./AppStore";
+export { default as AppCard } from "./AppCard";
+export { default as SubscribeModal } from "./SubscribeModal";
+export { default as ExtAppPostCard, ExtAppPostMarker } from "./ExtAppPostCard";
+export { default as MySubscriptions } from "./MySubscriptions";
+export { default as useExtAppFeed } from "./useExtAppFeed";
+export { default as CreateAppPost } from "./CreateAppPost";
diff --git a/src/components/Apps/useExtAppFeed.js b/src/components/Apps/useExtAppFeed.js
new file mode 100644
index 0000000..7eebf08
--- /dev/null
+++ b/src/components/Apps/useExtAppFeed.js
@@ -0,0 +1,120 @@
+/**
+ * useExtAppFeed - Hook for fetching posts from external apps
+ *
+ * Fetches posts from subscribed apps based on current map viewport.
+ */
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import { getFeed, listSubscriptions } from "../../api/extAppsApi";
+
+const CACHE_TTL = 30000; // 30 seconds
+const DEBOUNCE_MS = 500;
+
+export default function useExtAppFeed({ bbox, zoom, enabled = true }) {
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasSubscriptions, setHasSubscriptions] = useState(false);
+
+ const cacheRef = useRef(new Map());
+ const debounceRef = useRef(null);
+ const lastFetchRef = useRef(null);
+
+ // Check if user has any subscriptions
+ useEffect(() => {
+ if (!enabled) return;
+
+ listSubscriptions()
+ .then((result) => {
+ setHasSubscriptions((result.subscriptions || []).length > 0);
+ })
+ .catch((err) => {
+ console.warn("Failed to check subscriptions:", err);
+ setHasSubscriptions(false);
+ });
+ }, [enabled]);
+
+ const fetchPosts = useCallback(async () => {
+ if (!bbox || !hasSubscriptions) {
+ setPosts([]);
+ return;
+ }
+
+ // Build cache key
+ const cacheKey = `${bbox.west.toFixed(4)},${bbox.south.toFixed(4)},${bbox.east.toFixed(4)},${bbox.north.toFixed(4)}:${zoom}`;
+
+ // Check cache
+ const cached = cacheRef.current.get(cacheKey);
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
+ setPosts(cached.posts);
+ return;
+ }
+
+ // Prevent duplicate fetches
+ if (lastFetchRef.current === cacheKey) return;
+ lastFetchRef.current = cacheKey;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const result = await getFeed({ bbox, zoom });
+
+ const fetchedPosts = result.posts || [];
+ setPosts(fetchedPosts);
+
+ // Cache result
+ cacheRef.current.set(cacheKey, {
+ posts: fetchedPosts,
+ timestamp: Date.now(),
+ });
+
+ // Clean old cache entries
+ const now = Date.now();
+ for (const [key, value] of cacheRef.current.entries()) {
+ if (now - value.timestamp > CACHE_TTL * 2) {
+ cacheRef.current.delete(key);
+ }
+ }
+ } catch (err) {
+ console.error("Failed to fetch ext app feed:", err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ lastFetchRef.current = null;
+ }
+ }, [bbox, zoom, hasSubscriptions]);
+
+ // Debounced fetch on bbox/zoom change
+ useEffect(() => {
+ if (!enabled || !hasSubscriptions) return;
+
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+
+ debounceRef.current = setTimeout(() => {
+ fetchPosts();
+ }, DEBOUNCE_MS);
+
+ return () => {
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ }
+ };
+ }, [fetchPosts, enabled, hasSubscriptions]);
+
+ const refresh = useCallback(() => {
+ // Clear cache and refetch
+ cacheRef.current.clear();
+ fetchPosts();
+ }, [fetchPosts]);
+
+ return {
+ posts,
+ loading,
+ error,
+ hasSubscriptions,
+ refresh,
+ };
+}