chore: update

This commit is contained in:
2026-03-08 16:33:50 +01:00
parent 712d667d86
commit dc9e812391
5 changed files with 476 additions and 66 deletions
+35 -10
View File
@@ -562,7 +562,6 @@ export const deleteProductMediaAdmin = async (
return { success: true, message: data.message };
};
// ============================================
// ============================================
// ADDRESSES
// ============================================
@@ -711,31 +710,48 @@ export interface AppSettings {
points_enabled: boolean;
points_categories_weed: string[];
points_categories_zipette: string[];
points_categories_total: string[];
points_separated: boolean;
points_weed_tiers: PointsTier[];
points_zipette_tiers: PointsTier[];
points_total_tiers: PointsTier[];
referral_enabled: boolean;
}
export const getSettings = async (): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
export const getSettings = async (): Promise<{
success: boolean;
settings?: AppSettings;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${V2}/admin/protected/settings`);
return { success: true, settings: data.settings };
} catch (error: any) {
return { success: false, error: error.response?.data?.error || "Erreur" };
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
export const registerAdminPushToken = async (pushToken: string): Promise<void> => {
export const registerAdminPushToken = async (
pushToken: string,
): Promise<void> => {
try {
await apiClient.post(`${V2}/admin/protected/push-token`, { push_token: pushToken });
} catch { /* ignore */ }
await apiClient.post(`${V2}/admin/protected/push-token`, {
push_token: pushToken,
});
} catch {
/* ignore */
}
};
export const unregisterAdminPushToken = async (): Promise<void> => {
try {
await apiClient.delete(`${V2}/admin/protected/push-token`);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
export interface AppNotification {
@@ -746,7 +762,10 @@ export interface AppNotification {
read: boolean;
}
export const getAdminNotifications = async (): Promise<{ notifications: AppNotification[]; unread_count: number }> => {
export const getAdminNotifications = async (): Promise<{
notifications: AppNotification[];
unread_count: number;
}> => {
const { data } = await apiClient.get(`${V2}/admin/protected/notifications`);
return data;
};
@@ -759,9 +778,15 @@ export const updateSettings = async (
settings: AppSettings,
): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => {
try {
const { data } = await apiClient.put(`${V2}/admin/protected/settings`, settings);
const { data } = await apiClient.put(
`${V2}/admin/protected/settings`,
settings,
);
return { success: true, settings: data.settings };
} catch (error: any) {
return { success: false, error: error.response?.data?.error || "Erreur" };
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
+60 -6
View File
@@ -351,16 +351,22 @@ export interface PublicSettings {
points_separated: boolean;
}
export const registerCabinePushToken = async (pushToken: string): Promise<void> => {
export const registerCabinePushToken = async (
pushToken: string,
): Promise<void> => {
try {
await apiClient.post(`${API}/push-token`, { push_token: pushToken });
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
export const unregisterCabinePushToken = async (): Promise<void> => {
try {
await apiClient.delete(`${API}/push-token`);
} catch { /* ignore */ }
} catch {
/* ignore */
}
};
export interface AppNotification {
@@ -371,7 +377,10 @@ export interface AppNotification {
read: boolean;
}
export const getCabineNotifications = async (): Promise<{ notifications: AppNotification[]; unread_count: number }> => {
export const getCabineNotifications = async (): Promise<{
notifications: AppNotification[];
unread_count: number;
}> => {
const { data } = await apiClient.get(`${API}/notifications`);
return data;
};
@@ -382,7 +391,9 @@ export const markCabineNotificationsRead = async (): Promise<void> => {
export const getPublicSettings = async (): Promise<PublicSettings> => {
try {
const { data } = await apiClient.get(`http://5.181.0.112/api/v1/app-settings`);
const { data } = await apiClient.get(
`http://5.181.0.112/api/v1/app-settings`,
);
return {
penalties_enabled: data.penalties_enabled ?? true,
show_amende_score: data.show_amende_score ?? true,
@@ -390,6 +401,49 @@ export const getPublicSettings = async (): Promise<PublicSettings> => {
points_separated: data.points_separated ?? true,
};
} catch {
return { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true };
return {
penalties_enabled: true,
show_amende_score: true,
points_enabled: true,
points_separated: true,
};
}
};
// ============================================
// ADDRESSES
// ============================================
export const addAddress = async (
invalidAddress: string,
correctAddress: string,
): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.post(`${API}/add/address`, {
invalid_address: invalidAddress,
correct_address: correctAddress,
});
return { success: true, message: data.message };
};
export const deleteAddress = async (
invalidAddress: string,
correctAddress: string,
): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.delete(`${API}/delete/address`, {
data: {
invalid_address: invalidAddress,
correct_address: correctAddress,
},
});
return { success: true, message: data.message };
};
export const getAllAddresses = async (): Promise<
{ invalid_address: string; correct_address: string }[]
> => {
const { data } = await apiClient.get(`${API}/addresses`);
return (data.addresses ?? []).map((a: any) => ({
invalid_address: a.invalid_address ?? a.InvalidAddress ?? "",
correct_address: a.correct_address ?? a.CorrectAddress ?? "",
}));
};
@@ -17,7 +17,7 @@ import type { AppSettings, Category, PointsTier } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
type PoolAssignment = "weed" | "zipette" | "none";
type PoolAssignment = "weed" | "zipette" | "total" | "none";
// ──────────────────────────────────────────────────────────────
// Composant éditeur de paliers
@@ -146,9 +146,11 @@ export default function SettingsScreen() {
points_enabled: true,
points_categories_weed: [],
points_categories_zipette: [],
points_categories_total: [],
points_separated: true,
points_weed_tiers: [],
points_zipette_tiers: [],
points_total_tiers: [],
referral_enabled: true,
});
const [categories, setCategories] = useState<Category[]>([]);
@@ -164,6 +166,8 @@ export default function SettingsScreen() {
...settingsRes.settings,
points_categories_weed: settingsRes.settings.points_categories_weed ?? [],
points_categories_zipette: settingsRes.settings.points_categories_zipette ?? [],
points_categories_total: settingsRes.settings.points_categories_total ?? [],
points_total_tiers: settingsRes.settings.points_total_tiers ?? [],
});
}
if (categoriesRes) {
@@ -179,6 +183,7 @@ export default function SettingsScreen() {
const getPoolFor = (name: string): PoolAssignment => {
if ((settings.points_categories_weed ?? []).includes(name)) return "weed";
if ((settings.points_categories_zipette ?? []).includes(name)) return "zipette";
if ((settings.points_categories_total ?? []).includes(name)) return "total";
return "none";
};
@@ -186,9 +191,11 @@ export default function SettingsScreen() {
setSettings((prev) => {
const weed = (prev.points_categories_weed ?? []).filter((c) => c !== name);
const zipette = (prev.points_categories_zipette ?? []).filter((c) => c !== name);
const total = (prev.points_categories_total ?? []).filter((c) => c !== name);
if (pool === "weed") weed.push(name);
else if (pool === "zipette") zipette.push(name);
return { ...prev, points_categories_weed: weed, points_categories_zipette: zipette };
else if (pool === "total") total.push(name);
return { ...prev, points_categories_weed: weed, points_categories_zipette: zipette, points_categories_total: total };
});
};
@@ -310,6 +317,17 @@ export default function SettingsScreen() {
const WEED_COLOR = "#10b981";
const ZIP_COLOR = "#9333ea";
const TOTAL_COLOR = "#f97316";
// Le mode séparé nécessite que W ET Z aient au moins une catégorie
const hasBothPools =
(settings.points_categories_weed ?? []).length > 0 &&
(settings.points_categories_zipette ?? []).length > 0;
// Chips disponibles selon le mode
const availableChips: PoolAssignment[] = settings.points_separated
? ["weed", "zipette", "none"] // mode séparé : T désactivé
: ["total", "none"]; // mode non-séparé : W/Z désactivés
return (
<View style={s.container}>
@@ -393,16 +411,20 @@ export default function SettingsScreen() {
thumbColor="#fff"
/>
</View>
<View style={s.row}>
<View style={[s.row, { opacity: hasBothPools ? 1 : 0.4 }]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Points séparés par pool</Text>
<Text style={s.rowDesc}>
Activé : Weed point / Zipette point_zipette{"\n"}
Désactivé : tout dans un seul compteur (point)
Désactivé : Barème Total sur la somme W+Z{"\n"}
{!hasBothPools && (
"⚠️ Nécessite des catégories W ET Z configurées"
)}
</Text>
</View>
<Switch
value={settings.points_separated}
disabled={!hasBothPools}
onValueChange={(v) =>
setSettings((prev) => ({ ...prev, points_separated: v }))
}
@@ -416,19 +438,30 @@ export default function SettingsScreen() {
<View style={s.section}>
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
<Text style={s.hint}>
Pour chaque catégorie, choisis si elle génère des points Weed, Zipette, ou aucun.
{settings.points_separated
? "Mode séparé : W = pool Weed, Z = pool Zipette."
: "Mode non-séparé : T = pool Total (barème sur W+Z). W et Z désactivés."}
</Text>
{/* Légende */}
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingBottom: spacing.m }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: WEED_COLOR }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Weed</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: ZIP_COLOR }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Zipette</Text>
</View>
<View style={{ flexDirection: "row", gap: spacing.m, paddingHorizontal: spacing.l, paddingBottom: spacing.m, flexWrap: "wrap" }}>
{settings.points_separated ? (
<>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: WEED_COLOR }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Weed</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: ZIP_COLOR }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Zipette</Text>
</View>
</>
) : (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: TOTAL_COLOR }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Total</Text>
</View>
)}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<View style={[s.colorDot, { backgroundColor: colors.border }]} />
<Text style={{ fontSize: fontSize.sm, color: colors.textMuted }}>Aucun</Text>
@@ -450,21 +483,24 @@ export default function SettingsScreen() {
/>
<Text style={s.catName}>{cat.name}</Text>
<View style={s.chips}>
{(["weed", "zipette", "none"] as PoolAssignment[]).map(
(p) => {
{availableChips.map((p) => {
const active = pool === p;
const chipColor =
p === "weed"
? WEED_COLOR
: p === "zipette"
? ZIP_COLOR
: colors.textMuted;
: p === "total"
? TOTAL_COLOR
: colors.textMuted;
const label =
p === "weed"
? "W"
: p === "zipette"
? "Z"
: "—";
: p === "total"
? "T"
: "—";
return (
<TouchableOpacity
key={p}
@@ -493,8 +529,7 @@ export default function SettingsScreen() {
</Text>
</TouchableOpacity>
);
},
)}
})}
</View>
</View>
);
@@ -507,23 +542,36 @@ export default function SettingsScreen() {
)}
</View>
{/* Barème de points */}
<TiersSection
title="Barème Weed / Hash"
tiers={settings.points_weed_tiers ?? []}
onChange={(tiers) => setSettings((p) => ({ ...p, points_weed_tiers: tiers }))}
colors={colors}
s={s}
accentColor={WEED_COLOR}
/>
<TiersSection
title="Barème Zipette"
tiers={settings.points_zipette_tiers ?? []}
onChange={(tiers) => setSettings((p) => ({ ...p, points_zipette_tiers: tiers }))}
colors={colors}
s={s}
accentColor={ZIP_COLOR}
/>
{/* Barème de points — conditionnel selon le mode */}
{settings.points_separated ? (
<>
<TiersSection
title="Barème Weed / Hash"
tiers={settings.points_weed_tiers ?? []}
onChange={(tiers) => setSettings((p) => ({ ...p, points_weed_tiers: tiers }))}
colors={colors}
s={s}
accentColor={WEED_COLOR}
/>
<TiersSection
title="Barème Zipette"
tiers={settings.points_zipette_tiers ?? []}
onChange={(tiers) => setSettings((p) => ({ ...p, points_zipette_tiers: tiers }))}
colors={colors}
s={s}
accentColor={ZIP_COLOR}
/>
</>
) : (
<TiersSection
title="Barème Total (W + Z)"
tiers={settings.points_total_tiers ?? []}
onChange={(tiers) => setSettings((p) => ({ ...p, points_total_tiers: tiers }))}
colors={colors}
s={s}
accentColor={TOTAL_COLOR}
/>
)}
<TouchableOpacity
style={s.saveButton}
@@ -0,0 +1,281 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
addAddress,
deleteAddress,
getAllAddresses,
} from "../../api/api_cabine";
import { useAlert } from "../../hooks/useAlert";
import Card from "../../components/ui/Card";
import Modal from "../../components/ui/Modal";
import TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
type AddressEntry = {
invalid_address: string;
correct_address: string;
};
export default function AddressScreen() {
const { colors } = useTheme();
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const [addresses, setAddresses] = useState<AddressEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [addModal, setAddModal] = useState(false);
const [invalidInput, setInvalidInput] = useState("");
const [correctInput, setCorrectInput] = useState("");
const [saving, setSaving] = useState(false);
const loadAddresses = useCallback(async () => {
try {
const result = await getAllAddresses();
setAddresses(result);
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setLoading(false);
}
}, [showError]);
useEffect(() => {
loadAddresses();
}, [loadAddresses]);
const onRefresh = async () => {
setRefreshing(true);
await loadAddresses();
setRefreshing(false);
};
const openAddModal = () => {
setInvalidInput("");
setCorrectInput("");
setAddModal(true);
};
const handleAdd = useCallback(async () => {
const inv = invalidInput.trim();
const cor = correctInput.trim();
if (!inv || !cor) {
showError("Erreur", "Les deux champs sont obligatoires.");
return;
}
setSaving(true);
try {
await addAddress(inv, cor);
setAddresses((prev) => [
...prev,
{ invalid_address: inv, correct_address: cor },
]);
setAddModal(false);
showSuccess("Succès", "Adresse ajoutée avec succès.");
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setSaving(false);
}
}, [invalidInput, correctInput, showError, showSuccess]);
const handleDelete = useCallback(
(item: AddressEntry) => {
showConfirm(
"Supprimer",
`Supprimer la correction :\n"${item.invalid_address}" → "${item.correct_address}" ?`,
async () => {
try {
await deleteAddress(
item.invalid_address,
item.correct_address,
);
setAddresses((prev) =>
prev.filter(
(a) =>
a.invalid_address !==
item.invalid_address ||
a.correct_address !== item.correct_address,
),
);
showSuccess("Supprimé", "Adresse supprimée.");
} catch (e: any) {
showError(
"Erreur",
e.response?.data?.error ?? e.message,
);
}
},
"Supprimer",
);
},
[showConfirm, showError, showSuccess],
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
paddingBottom: spacing.s,
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
addBtn: {
backgroundColor: colors.accent,
borderRadius: 20,
padding: spacing.s,
},
invalid: {
color: colors.danger,
fontSize: fontSize.md,
fontWeight: "600",
},
correct: {
color: colors.success,
fontSize: fontSize.md,
fontWeight: "600",
marginTop: 6,
},
label: {
fontSize: fontSize.xs,
fontWeight: "700",
letterSpacing: 0.5,
marginBottom: 2,
},
labelInvalid: {
color: colors.danger,
},
labelCorrect: {
color: colors.success,
},
arrow: {
color: colors.textMuted,
fontSize: fontSize.lg,
marginVertical: 4,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
}),
[colors],
);
const renderItem = ({ item }: { item: AddressEntry }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<View style={{ flex: 1, marginRight: spacing.m }}>
<Text style={styles.invalid}>{item.invalid_address}</Text>
<Text style={styles.arrow}></Text>
<Text style={styles.correct}>{item.correct_address}</Text>
</View>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Ionicons
name="trash-outline"
size={22}
color={colors.danger}
/>
</TouchableOpacity>
</View>
</Card>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Corrections d'adresses</Text>
<TouchableOpacity style={styles.addBtn} onPress={openAddModal}>
<Ionicons name="add" size={22} color={colors.textWhite} />
</TouchableOpacity>
</View>
<FlatList
data={addresses}
keyExtractor={(item, i) => `${item.invalid_address}-${i}`}
renderItem={renderItem}
contentContainerStyle={{
padding: spacing.l,
paddingTop: spacing.s,
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
ListEmptyComponent={
<Text style={styles.empty}>
{loading
? "Chargement..."
: "Aucune correction.\nAppuyez sur + pour en ajouter une."}
</Text>
}
/>
<Modal
visible={addModal}
onClose={() => setAddModal(false)}
title="Ajouter une correction"
icon="map-outline"
>
<TextInput
label="Adresse invalide"
value={invalidInput}
onChangeText={setInvalidInput}
placeholder="Ex: 10 rue de la paix"
autoCapitalize="none"
/>
<TextInput
label="Adresse correcte"
value={correctInput}
onChangeText={setCorrectInput}
placeholder="Ex: 10 Rue de la Paix, 75001 Paris"
autoCapitalize="none"
/>
<Button
title="Ajouter"
onPress={handleAdd}
loading={saving}
style={{ marginTop: spacing.m }}
/>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -275,19 +275,21 @@ export default function OrderHistoryScreen() {
Pts Zipette
</Text>
</View>
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="trophy-outline"
size={24}
color={colors.warning}
/>
<Text style={styles.statValue}>
{totalPoints}
</Text>
<Text style={styles.statLabel}>
Total Points
</Text>
</View>
{(stats?.points || 0) > 0 && (stats?.points_zipette || 0) > 0 && (
<View style={[styles.statCard, shadows.sm]}>
<Ionicons
name="trophy-outline"
size={24}
color={colors.warning}
/>
<Text style={styles.statValue}>
{totalPoints}
</Text>
<Text style={styles.statLabel}>
Total Points
</Text>
</View>
)}
</>
) : (
<View style={[styles.statCard, shadows.sm]}>