From b3fc07b0012ddf39df84508dcbb3b0ba9398030d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 Jan 2026 19:04:08 +0000 Subject: [PATCH] feat(frontend): add external apps UI components - AppStore for browsing and subscribing to apps - SubscribeModal with consent flow - ExtAppPostCard for rendering app posts - CreateAppPost for posting to subscribed apps - MySubscriptions for managing subscriptions - useExtAppFeed hook for fetching app posts - extAppsApi client for all API calls Co-Authored-By: Claude Opus 4.5 --- src/api/extAppsApi.js | 324 ++++++++++++++++++++ src/components/Apps/AppCard.jsx | 99 ++++++ src/components/Apps/AppStore.jsx | 162 ++++++++++ src/components/Apps/CreateAppPost.jsx | 390 ++++++++++++++++++++++++ src/components/Apps/ExtAppPostCard.jsx | 182 +++++++++++ src/components/Apps/MySubscriptions.jsx | 162 ++++++++++ src/components/Apps/SubscribeModal.jsx | 195 ++++++++++++ src/components/Apps/index.js | 11 + src/components/Apps/useExtAppFeed.js | 120 ++++++++ 9 files changed, 1645 insertions(+) create mode 100644 src/api/extAppsApi.js create mode 100644 src/components/Apps/AppCard.jsx create mode 100644 src/components/Apps/AppStore.jsx create mode 100644 src/components/Apps/CreateAppPost.jsx create mode 100644 src/components/Apps/ExtAppPostCard.jsx create mode 100644 src/components/Apps/MySubscriptions.jsx create mode 100644 src/components/Apps/SubscribeModal.jsx create mode 100644 src/components/Apps/index.js create mode 100644 src/components/Apps/useExtAppFeed.js 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 */} +
+ {/* App selector */} + {subscriptions.length > 1 && ( +
+ +
+ {subscriptions.map((sub) => ( + + ))} +
+
+ )} + + {selectedApp && subscriptions.length === 1 && ( +
+ + {selectedApp.app_name} +
+ )} + + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Ex: iPhone 14 Pro Max 256GB" + className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:border-purple-500 focus:outline-none" + maxLength={200} + /> +
+ + {/* Description */} +
+ +