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 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-28 19:04:08 +00:00
parent 6d92a984ca
commit b3fc07b001
9 changed files with 1645 additions and 0 deletions

324
src/api/extAppsApi.js Normal file
View File

@ -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,
};

View File

@ -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 (
<div className="bg-zinc-800/50 rounded-xl p-4 border border-zinc-700/50 hover:border-purple-500/50 transition-colors">
{/* Header */}
<div className="flex items-start gap-3 mb-3">
{/* Icon */}
<div
className="w-14 h-14 rounded-xl bg-zinc-700 flex items-center justify-center flex-shrink-0 overflow-hidden"
style={
iconUrl
? {
backgroundImage: `url(${iconUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: {}
}
>
{!iconUrl && (
<i className={`fas ${app.fa_icon || "fa-puzzle-piece"} text-xl text-purple-400`} />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate">{app.name}</h3>
<p className="text-sm text-zinc-400 truncate">{app.developer || "Développeur"}</p>
<div className="flex items-center gap-2 mt-1">
{app.level === "advanced" && (
<span className="text-xs px-2 py-0.5 bg-purple-600/20 text-purple-400 rounded">
Avancé
</span>
)}
{app.category && (
<span className="text-xs text-zinc-500">{app.category}</span>
)}
</div>
</div>
</div>
{/* Description */}
<p className="text-sm text-zinc-400 mb-4 line-clamp-2">
{app.description || "Aucune description disponible"}
</p>
{/* Stats */}
{app.stats && (
<div className="flex items-center gap-4 mb-4 text-xs text-zinc-500">
<span>
<i className="fas fa-users mr-1" />
{app.stats.subscribers || 0}
</span>
{app.stats.posts && (
<span>
<i className="fas fa-file-alt mr-1" />
{app.stats.posts}
</span>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{isSubscribed ? (
<button
className="flex-1 py-2 px-4 bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
disabled
>
<i className="fas fa-check mr-2" />
Abonné
</button>
) : (
<button
onClick={onSubscribe}
className="flex-1 py-2 px-4 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm font-medium transition-colors"
>
S'abonner
</button>
)}
<button className="p-2 bg-zinc-700 hover:bg-zinc-600 rounded-lg transition-colors">
<i className="fas fa-ellipsis-v text-zinc-400" />
</button>
</div>
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-zinc-900 rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
<h1 className="text-xl font-semibold text-white">
<i className="fas fa-puzzle-piece mr-2 text-purple-400" />
App Store
</h1>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-800 rounded-full transition-colors"
>
<i className="fas fa-times text-zinc-400" />
</button>
</div>
{/* Categories */}
<div className="flex gap-2 p-4 overflow-x-auto border-b border-zinc-800">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setCategory(cat.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm whitespace-nowrap transition-colors ${
category === cat.id
? "bg-purple-600 text-white"
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
}`}
>
<i className={`fas ${cat.icon}`} />
{cat.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-48">
<i className="fas fa-spinner fa-spin text-2xl text-purple-400" />
</div>
) : error ? (
<div className="text-center text-red-400 py-8">
<i className="fas fa-exclamation-triangle text-2xl mb-2" />
<p>{error}</p>
<button
onClick={loadApps}
className="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700"
>
Réessayer
</button>
</div>
) : apps.length === 0 ? (
<div className="text-center text-zinc-500 py-8">
<i className="fas fa-box-open text-4xl mb-4" />
<p>Aucune app disponible dans cette catégorie</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{apps.map((app) => (
<AppCard
key={app.app_id}
app={app}
subscription={subscriptions[app.app_id]}
onSubscribe={() => handleSubscribe(app)}
/>
))}
</div>
)}
</div>
</div>
{/* Subscribe Modal */}
{showSubscribeModal && selectedApp && (
<SubscribeModal
app={selectedApp}
onConfirm={handleConfirmSubscribe}
onCancel={() => {
setShowSubscribeModal(false);
setSelectedApp(null);
}}
/>
)}
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-zinc-900 rounded-2xl p-8">
<i className="fas fa-spinner fa-spin text-2xl text-purple-400" />
</div>
</div>
);
}
if (subscriptions.length === 0) {
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-zinc-900 rounded-2xl p-6 max-w-sm text-center">
<i className="fas fa-puzzle-piece text-4xl text-zinc-600 mb-4" />
<p className="text-zinc-400 mb-4">
Vous n'êtes abonné à aucune app qui accepte les posts
</p>
<button
onClick={onClose}
className="px-6 py-2 bg-zinc-800 text-zinc-300 rounded-lg"
>
Fermer
</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-zinc-900 rounded-2xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
<h1 className="text-lg font-semibold text-white">
<i className="fas fa-plus-circle mr-2 text-purple-400" />
Créer un post
</h1>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-800 rounded-full transition-colors"
>
<i className="fas fa-times text-zinc-400" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
{/* App selector */}
{subscriptions.length > 1 && (
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Publier sur
</label>
<div className="grid grid-cols-2 gap-2">
{subscriptions.map((sub) => (
<button
key={sub.app_id}
type="button"
onClick={() => setSelectedApp(sub)}
className={`p-3 rounded-lg border text-left transition-colors ${
selectedApp?.app_id === sub.app_id
? "border-purple-500 bg-purple-600/20"
: "border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800"
}`}
>
<div className="font-medium text-white text-sm">{sub.app_name}</div>
</button>
))}
</div>
</div>
)}
{selectedApp && subscriptions.length === 1 && (
<div className="flex items-center gap-2 p-3 bg-zinc-800/50 rounded-lg">
<i className="fas fa-puzzle-piece text-purple-400" />
<span className="text-white">{selectedApp.app_name}</span>
</div>
)}
{/* Title */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Titre *
</label>
<input
type="text"
value={title}
onChange={(e) => 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}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez votre article..."
rows={3}
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 resize-none"
maxLength={2000}
/>
</div>
{/* Price (if marketplace-like app) */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Prix
</label>
<div className="relative">
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
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 pr-12"
min="0"
step="0.01"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
</div>
</div>
{/* Images */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Photos ({images.length}/5)
</label>
<div className="flex flex-wrap gap-2">
{imagePreviews.map((url, i) => (
<div
key={i}
className="relative w-20 h-20 rounded-lg overflow-hidden bg-zinc-800"
>
<img
src={url}
alt=""
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute top-1 right-1 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center"
>
<i className="fas fa-times text-xs text-white" />
</button>
</div>
))}
{images.length < 5 && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-20 h-20 rounded-lg border-2 border-dashed border-zinc-700 flex items-center justify-center hover:border-purple-500 transition-colors"
>
<i className="fas fa-plus text-zinc-500" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleImageSelect}
className="hidden"
/>
</div>
{/* Location */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Localisation *
</label>
{location ? (
<div className="flex items-center gap-2 p-3 bg-zinc-800/50 rounded-lg">
<i className="fas fa-map-marker-alt text-green-400" />
<span className="text-white text-sm flex-1 truncate">
{location.address || `${location.lat.toFixed(4)}, ${(location.lon || location.lng).toFixed(4)}`}
</span>
<button
type="button"
onClick={() => setLocation(null)}
className="text-zinc-500 hover:text-zinc-300"
>
<i className="fas fa-times" />
</button>
</div>
) : (
<button
type="button"
onClick={() => {
// TODO: Open map picker or use current location
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocation({
lat: pos.coords.latitude,
lon: pos.coords.longitude
});
},
() => setError("Impossible d'obtenir la localisation")
);
}
}}
className="w-full p-3 border-2 border-dashed border-zinc-700 rounded-lg text-zinc-500 hover:border-purple-500 hover:text-purple-400 transition-colors"
>
<i className="fas fa-map-marker-alt mr-2" />
Définir la localisation
</button>
)}
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-600/20 border border-red-600/50 rounded-lg text-red-400 text-sm">
<i className="fas fa-exclamation-triangle mr-2" />
{error}
</div>
)}
</form>
{/* Footer */}
<div className="p-4 border-t border-zinc-800 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 px-4 bg-zinc-800 text-zinc-300 rounded-lg font-medium hover:bg-zinc-700 transition-colors"
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={submitting || !selectedApp || !title.trim() || !location}
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<i className="fas fa-spinner fa-spin" />
) : (
<>
<i className="fas fa-paper-plane mr-2" />
Publier
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div
className={`ext-app-card ext-app-card--rendered ${className}`}
style={{
width: post.rendered.width || 280,
height: post.rendered.height || 180,
borderRadius: 16,
overflow: "hidden",
boxShadow: DEFAULT_TOKENS.shadow.card,
}}
dangerouslySetInnerHTML={{ __html: post.rendered.html }}
onClick={() => onAction?.({ type: "open", bind: "post_id" }, post.post_id)}
/>
);
}
// Level 1: Use CardRenderer with JSON spec
const spec = cardStyle || DEFAULT_CARD_SPEC;
return (
<CardRenderer
spec={spec}
data={processedPost}
themeTokens={{
...DEFAULT_TOKENS,
gradients: {
bottom: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.8))",
},
}}
onAction={(action, value) => 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 (
<button
onClick={onClick}
className="w-12 h-12 rounded-full bg-zinc-800 border-2 border-purple-500 overflow-hidden shadow-lg hover:scale-110 transition-transform"
style={
processedUrl
? {
backgroundImage: `url(${processedUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: {}
}
>
{!processedUrl && (
<i className="fas fa-map-marker-alt text-purple-400" />
)}
</button>
);
}

View File

@ -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 (
<div className="p-4 text-center">
<i className="fas fa-spinner fa-spin text-xl text-purple-400" />
</div>
);
}
if (error) {
return (
<div className="p-4 text-center text-red-400">
<i className="fas fa-exclamation-triangle mb-2" />
<p>{error}</p>
</div>
);
}
if (subscriptions.length === 0) {
return (
<div className="p-6 text-center">
<i className="fas fa-puzzle-piece text-4xl text-zinc-600 mb-4" />
<p className="text-zinc-400 mb-4">Vous n'êtes abonné à aucune app</p>
<button
onClick={onOpenStore}
className="px-6 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg transition-colors"
>
Découvrir les apps
</button>
</div>
);
}
return (
<div className="space-y-3 p-4">
{subscriptions.map((sub) => (
<div
key={sub.app_id}
className="bg-zinc-800/50 rounded-xl p-4 border border-zinc-700/50"
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-white">{sub.app_name}</h3>
<p className="text-xs text-zinc-500">
Abonné depuis {new Date(sub.subscribed_at).toLocaleDateString("fr-CA")}
</p>
</div>
<div className="flex items-center gap-2">
{sub.tier === "premium" && (
<span className="text-xs px-2 py-1 bg-yellow-600/20 text-yellow-400 rounded">
Premium
</span>
)}
</div>
</div>
{/* Description */}
{sub.app_description && (
<p className="text-sm text-zinc-400 mb-3 line-clamp-2">
{sub.app_description}
</p>
)}
{/* Consent toggles */}
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(sub.consent || {}).map(([perm, enabled]) => (
<button
key={perm}
onClick={() => handleToggleConsent(sub, perm)}
className={`text-xs px-2 py-1 rounded flex items-center gap-1 transition-colors ${
enabled
? "bg-purple-600/20 text-purple-400"
: "bg-zinc-700 text-zinc-500"
}`}
>
<i className={`fas ${enabled ? "fa-check" : "fa-times"}`} />
{perm}
</button>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-zinc-700/50">
<button
onClick={() => handleUnsubscribe(sub.app_id, sub.app_name)}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Se désabonner
</button>
</div>
</div>
))}
{/* Add more apps button */}
<button
onClick={onOpenStore}
className="w-full py-3 border border-dashed border-zinc-700 rounded-xl text-zinc-500 hover:text-zinc-400 hover:border-zinc-600 transition-colors"
>
<i className="fas fa-plus mr-2" />
Ajouter une app
</button>
</div>
);
}

View File

@ -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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<div className="bg-zinc-900 rounded-2xl w-full max-w-md overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-zinc-800">
<h2 className="text-lg font-semibold text-white">
S'abonner à {app.name}
</h2>
<p className="text-sm text-zinc-400 mt-1">
Cette app requiert votre consentement pour accéder à certaines informations.
</p>
</div>
{/* Permissions */}
<div className="p-4 space-y-3 max-h-[50vh] overflow-y-auto">
{/* Required */}
{required.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
Requis
</h3>
{required.map((perm) => {
const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
return (
<div
key={perm}
className="flex items-center gap-3 p-3 bg-zinc-800/50 rounded-lg mb-2"
>
<div className="w-10 h-10 rounded-full bg-purple-600/20 flex items-center justify-center">
<i className={`fas ${info.icon} text-purple-400`} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-white">{info.label}</p>
<p className="text-xs text-zinc-500">{info.description}</p>
</div>
<div className="w-10 h-6 bg-purple-600 rounded-full flex items-center justify-end pr-1">
<div className="w-4 h-4 bg-white rounded-full" />
</div>
</div>
);
})}
</div>
)}
{/* Optional */}
{optional.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
Optionnel
</h3>
{optional.map((perm) => {
const info = PERMISSIONS[perm] || { label: perm, icon: "fa-question" };
const enabled = consent[perm];
return (
<button
key={perm}
onClick={() => handleToggle(perm)}
className="flex items-center gap-3 p-3 bg-zinc-800/50 rounded-lg mb-2 w-full text-left hover:bg-zinc-800 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-zinc-700 flex items-center justify-center">
<i className={`fas ${info.icon} text-zinc-400`} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-white">{info.label}</p>
<p className="text-xs text-zinc-500">{info.description}</p>
</div>
<div
className={`w-10 h-6 rounded-full flex items-center transition-colors ${
enabled ? "bg-purple-600 justify-end pr-1" : "bg-zinc-700 justify-start pl-1"
}`}
>
<div className="w-4 h-4 bg-white rounded-full" />
</div>
</button>
);
})}
</div>
)}
{/* Tier selection (if app has premium) */}
{app.tiers && app.tiers.includes("premium") && (
<div className="mt-4">
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-2">
Abonnement
</h3>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setTier("free")}
className={`p-3 rounded-lg border text-sm transition-colors ${
tier === "free"
? "border-purple-500 bg-purple-600/20 text-white"
: "border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800"
}`}
>
<div className="font-medium">Gratuit</div>
<div className="text-xs opacity-70">Fonctions de base</div>
</button>
<button
onClick={() => setTier("premium")}
className={`p-3 rounded-lg border text-sm transition-colors ${
tier === "premium"
? "border-purple-500 bg-purple-600/20 text-white"
: "border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800"
}`}
>
<div className="font-medium">Premium</div>
<div className="text-xs opacity-70">{app.premium_price || "4.99$"}/mois</div>
</button>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-4 border-t border-zinc-800 flex gap-3">
<button
onClick={onCancel}
className="flex-1 py-3 px-4 bg-zinc-800 text-zinc-300 rounded-lg font-medium hover:bg-zinc-700 transition-colors"
>
Annuler
</button>
<button
onClick={handleSubmit}
disabled={!allRequiredConsented || loading}
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<i className="fas fa-spinner fa-spin" />
) : (
"Confirmer"
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -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";

View File

@ -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,
};
}