start adding payment
This commit is contained in:
@@ -8,6 +8,19 @@
|
|||||||
],
|
],
|
||||||
"predeploy": []
|
"predeploy": []
|
||||||
},
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 5001
|
||||||
|
},
|
||||||
|
"pubsub": {
|
||||||
|
"port": 8085
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"singleProjectMode": true
|
||||||
|
},
|
||||||
"react-native": {
|
"react-native": {
|
||||||
"crashlytics_debug_enabled": true
|
"crashlytics_debug_enabled": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
|||||||
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
||||||
|
|
||||||
exports.RESEND_API_KEY = "re_";
|
exports.RESEND_API_KEY = "re_";
|
||||||
|
|
||||||
|
exports.STRIPE_SECRET_KEY =
|
||||||
|
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
|
||||||
|
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
|
||||||
|
exports.STRIPE_RETURN_URL = "";
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const { HttpsError } = require("firebase-functions/https");
|
||||||
|
const Stripe = require("stripe");
|
||||||
|
const { URL } = require("url");
|
||||||
|
|
||||||
|
const {
|
||||||
|
STRIPE_SECRET_KEY = "",
|
||||||
|
STRIPE_RETURN_URL = "",
|
||||||
|
STRIPE_PORTAL_CONFIGURATION = "",
|
||||||
|
STRIPE_MODE: CONFIG_STRIPE_MODE,
|
||||||
|
} = require("../config/keys");
|
||||||
|
|
||||||
|
const STRIPE_MODE =
|
||||||
|
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
|
||||||
|
? CONFIG_STRIPE_MODE.trim()
|
||||||
|
: "test";
|
||||||
|
|
||||||
|
const requireEnv = (key) => {
|
||||||
|
const value = process.env?.[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
throw new Error(`${key} not configured`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRIPE_API_VERSION = "2023-10-16";
|
||||||
|
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
|
||||||
|
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
|
||||||
|
|
||||||
|
let cachedStripeClient = null;
|
||||||
|
let cachedPortalConfigurationId = null;
|
||||||
|
|
||||||
|
const resolveStripeSecretKey = () => {
|
||||||
|
const inlineKey =
|
||||||
|
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
|
||||||
|
|
||||||
|
if (inlineKey) {
|
||||||
|
return inlineKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = requireEnv("STRIPE_SECRET_KEY");
|
||||||
|
if (typeof required === "string" && required.trim()) {
|
||||||
|
return required.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("STRIPE_SECRET_KEY not configured");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStripeClient = () => {
|
||||||
|
if (cachedStripeClient) {
|
||||||
|
return cachedStripeClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
let secretKey;
|
||||||
|
try {
|
||||||
|
secretKey = resolveStripeSecretKey();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
"Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedStripeClient = new Stripe(secretKey, {
|
||||||
|
apiVersion: STRIPE_API_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedStripeClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReturnBaseUrl = () => {
|
||||||
|
const resolveBase = () => {
|
||||||
|
if (STRIPE_RETURN_URL) {
|
||||||
|
return STRIPE_RETURN_URL;
|
||||||
|
}
|
||||||
|
if (STRIPE_MODE !== "prod") {
|
||||||
|
return DEFAULT_TEST_RETURN_URL;
|
||||||
|
}
|
||||||
|
return requireEnv("STRIPE_RETURN_URL");
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawBase = resolveBase();
|
||||||
|
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
|
||||||
|
if (!sanitizedBase) {
|
||||||
|
if (STRIPE_MODE !== "prod") {
|
||||||
|
return DEFAULT_TEST_RETURN_URL;
|
||||||
|
}
|
||||||
|
throw new Error("STRIPE_RETURN_URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizedBase.endsWith("/")
|
||||||
|
? sanitizedBase.slice(0, -1)
|
||||||
|
: sanitizedBase;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeReturnUrl = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateScheme = (scheme) => {
|
||||||
|
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(trimmed);
|
||||||
|
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
|
||||||
|
validateScheme(scheme);
|
||||||
|
return trimmed;
|
||||||
|
} catch (_error) {
|
||||||
|
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
|
||||||
|
if (schemeMatch && schemeMatch[1]) {
|
||||||
|
const scheme = schemeMatch[1].toLowerCase();
|
||||||
|
validateScheme(scheme);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`URL de retour Stripe invalide: ${trimmed}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReturnUrls = (overrides) => {
|
||||||
|
if (overrides && typeof overrides === "object") {
|
||||||
|
const successOverride = sanitizeReturnUrl(overrides.successUrl);
|
||||||
|
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
|
||||||
|
|
||||||
|
if (!successOverride) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
"successUrl est requis pour configurer les retours Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successUrl: successOverride,
|
||||||
|
cancelUrl: cancelOverride || successOverride,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getReturnBaseUrl();
|
||||||
|
|
||||||
|
const joinPath = (url, path) => {
|
||||||
|
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
|
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||||
|
return `${trimmedUrl}/${trimmedPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendQuery = (url, query) =>
|
||||||
|
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
|
||||||
|
|
||||||
|
const successBase = joinPath(baseUrl, "payment-success");
|
||||||
|
const cancelBase = joinPath(baseUrl, "payment-error");
|
||||||
|
|
||||||
|
return {
|
||||||
|
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBoolean = (value) => value === true;
|
||||||
|
|
||||||
|
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
|
||||||
|
if (!Array.isArray(productList) || productList.length === 0) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
"Au moins un produit est requis pour créer une session de paiement.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItems = [];
|
||||||
|
const summary = [];
|
||||||
|
let hasSubscription = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < productList.length; index += 1) {
|
||||||
|
const rawItem = productList[index];
|
||||||
|
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
|
||||||
|
|
||||||
|
const isRenewable = normalizeBoolean(item.isRenewable);
|
||||||
|
const rawQuantity =
|
||||||
|
typeof item.quantity === "number" && Number.isFinite(item.quantity)
|
||||||
|
? item.quantity
|
||||||
|
: parseInt(item.quantity, 10);
|
||||||
|
const quantity =
|
||||||
|
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
|
||||||
|
|
||||||
|
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
|
||||||
|
|
||||||
|
if (isRenewable && !priceId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceId) {
|
||||||
|
let stripePrice = null;
|
||||||
|
if (stripe) {
|
||||||
|
try {
|
||||||
|
stripePrice = await stripe.prices.retrieve(priceId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[buildCheckoutLineItems] Unable to retrieve price",
|
||||||
|
priceId,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceIsRecurring =
|
||||||
|
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
|
||||||
|
if (isRenewable && !priceIsRecurring) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
|
||||||
|
|
||||||
|
if (resolvedIsRenewable) {
|
||||||
|
hasSubscription = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItems.push({
|
||||||
|
price: priceId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
summary.push({
|
||||||
|
type: "price",
|
||||||
|
priceID: priceId,
|
||||||
|
quantity,
|
||||||
|
isRenewable: resolvedIsRenewable,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUnitAmount = Number(item.unitAmount);
|
||||||
|
const unitAmount = Math.round(rawUnitAmount);
|
||||||
|
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency =
|
||||||
|
typeof item.currency === "string"
|
||||||
|
? item.currency.trim().toLowerCase()
|
||||||
|
: "eur";
|
||||||
|
|
||||||
|
if (!/^[a-z]{3}$/.test(currency)) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
typeof item.label === "string" && item.label.trim()
|
||||||
|
? item.label.trim()
|
||||||
|
: "Paiement ponctuel";
|
||||||
|
|
||||||
|
lineItems.push({
|
||||||
|
price_data: {
|
||||||
|
currency,
|
||||||
|
product_data: {
|
||||||
|
name: label,
|
||||||
|
},
|
||||||
|
unit_amount: unitAmount,
|
||||||
|
},
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.push({
|
||||||
|
type: "custom",
|
||||||
|
currency,
|
||||||
|
unitAmount,
|
||||||
|
quantity,
|
||||||
|
isRenewable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSubscription) {
|
||||||
|
const hasNonSubscription = summary.some((item) => !item.isRenewable);
|
||||||
|
if (hasNonSubscription) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lineItems, summary, hasSubscription };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureStripeCustomer = async ({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
refsList,
|
||||||
|
createIfMissing = true,
|
||||||
|
}) => {
|
||||||
|
if (!uid) {
|
||||||
|
return { customerId: null, userData: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRef = refsList?.users?.doc(uid);
|
||||||
|
const snapshot = userRef ? await userRef.get() : null;
|
||||||
|
const userData = snapshot?.exists ? snapshot.data() : null;
|
||||||
|
|
||||||
|
let customerId = userData?.stripeCustomerId;
|
||||||
|
if (customerId) {
|
||||||
|
return { customerId, userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createIfMissing) {
|
||||||
|
return { customerId: null, userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
let authRecord = null;
|
||||||
|
try {
|
||||||
|
authRecord = await admin.auth().getUser(uid);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[ensureStripeCustomer] Impossible de récupérer auth user",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = userData?.email || authRecord?.email || undefined;
|
||||||
|
const nameFromProfile = [userData?.firstName, userData?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.trim();
|
||||||
|
const name = nameFromProfile || authRecord?.displayName || undefined;
|
||||||
|
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
metadata: {
|
||||||
|
firebaseUID: uid,
|
||||||
|
appMode: STRIPE_MODE || "test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
customerId = customer.id;
|
||||||
|
|
||||||
|
if (userRef) {
|
||||||
|
await userRef.set(
|
||||||
|
{
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
userData: { ...userData, stripeCustomerId: customerId },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCheckoutSessionResponse = (session) => ({
|
||||||
|
id: session.id,
|
||||||
|
object: session.object,
|
||||||
|
customer: session.customer,
|
||||||
|
customer_details: session.customer_details,
|
||||||
|
url: session.url,
|
||||||
|
mode: session.mode,
|
||||||
|
status: session.status,
|
||||||
|
payment_status: session.payment_status,
|
||||||
|
currency: session.currency,
|
||||||
|
amount_subtotal: session.amount_subtotal,
|
||||||
|
amount_total: session.amount_total,
|
||||||
|
created: session.created,
|
||||||
|
expires_at: session.expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPortalConfigurationId = async (stripe) => {
|
||||||
|
if (STRIPE_PORTAL_CONFIGURATION) {
|
||||||
|
return STRIPE_PORTAL_CONFIGURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedPortalConfigurationId) {
|
||||||
|
return cachedPortalConfigurationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!stripe ||
|
||||||
|
typeof stripe.billingPortal?.configurations?.list !== "function"
|
||||||
|
) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"internal",
|
||||||
|
"Client Stripe indisponible pour la configuration du portail.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configurations = await stripe.billingPortal.configurations.list({
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultConfiguration =
|
||||||
|
configurations.data.find((config) => config.is_default) ||
|
||||||
|
configurations.data.find((config) => config.active);
|
||||||
|
|
||||||
|
if (defaultConfiguration?.id) {
|
||||||
|
cachedPortalConfigurationId = defaultConfiguration.id;
|
||||||
|
return cachedPortalConfigurationId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const defaultReturnUrl = getReturnBaseUrl();
|
||||||
|
const createdConfiguration =
|
||||||
|
await stripe.billingPortal.configurations.create({
|
||||||
|
default_return_url: defaultReturnUrl,
|
||||||
|
business_profile: {
|
||||||
|
headline: "Minuit Starter",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createdConfiguration?.id) {
|
||||||
|
cachedPortalConfigurationId = createdConfiguration.id;
|
||||||
|
return cachedPortalConfigurationId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStripeErrorToHttps = (error, fallbackMessage) => {
|
||||||
|
const message =
|
||||||
|
error?.raw?.message ||
|
||||||
|
error?.message ||
|
||||||
|
fallbackMessage ||
|
||||||
|
"Erreur Stripe.";
|
||||||
|
const statusCode = error?.statusCode || error?.raw?.statusCode;
|
||||||
|
const isClientError =
|
||||||
|
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
|
||||||
|
|
||||||
|
const code = isClientError ? "failed-precondition" : "internal";
|
||||||
|
return new HttpsError(code, message);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStripeClient,
|
||||||
|
getReturnUrls,
|
||||||
|
getReturnBaseUrl,
|
||||||
|
buildCheckoutLineItems,
|
||||||
|
ensureStripeCustomer,
|
||||||
|
formatCheckoutSessionResponse,
|
||||||
|
getPortalConfigurationId,
|
||||||
|
mapStripeErrorToHttps,
|
||||||
|
};
|
||||||
Generated
-7511
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,8 @@
|
|||||||
"googleapis": "^131.0.0",
|
"googleapis": "^131.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"resend": "^6.3.0",
|
"resend": "^6.3.0",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4",
|
||||||
|
"stripe": "^19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^8.15.0",
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
const admin = require("firebase-admin");
|
||||||
|
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||||
|
|
||||||
|
let FieldValue = null;
|
||||||
|
try {
|
||||||
|
({ FieldValue } = require("firebase-admin/firestore"));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[stripe] FieldValue import failed", error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { REGION, refsList } = require("../index");
|
||||||
|
const STRIPE_MODE = "test";
|
||||||
|
const {
|
||||||
|
getStripeClient,
|
||||||
|
getReturnUrls,
|
||||||
|
getReturnBaseUrl,
|
||||||
|
buildCheckoutLineItems,
|
||||||
|
ensureStripeCustomer,
|
||||||
|
formatCheckoutSessionResponse,
|
||||||
|
getPortalConfigurationId,
|
||||||
|
mapStripeErrorToHttps,
|
||||||
|
} = require("../helpers/stripe");
|
||||||
|
|
||||||
|
const paymentsCollection = admin.firestore().collection("payments");
|
||||||
|
|
||||||
|
const getServerTimestamp = () => {
|
||||||
|
if (FieldValue?.serverTimestamp) {
|
||||||
|
return FieldValue.serverTimestamp();
|
||||||
|
}
|
||||||
|
if (admin.firestore?.FieldValue?.serverTimestamp) {
|
||||||
|
return admin.firestore.FieldValue.serverTimestamp();
|
||||||
|
}
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
"Firestore FieldValue.serverTimestamp indisponible.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePaymentIntent = (paymentIntent) => ({
|
||||||
|
id: paymentIntent.id,
|
||||||
|
object: paymentIntent.object,
|
||||||
|
status: paymentIntent.status,
|
||||||
|
currency: paymentIntent.currency,
|
||||||
|
amount: paymentIntent.amount,
|
||||||
|
amount_received: paymentIntent.amount_received,
|
||||||
|
customer: paymentIntent.customer,
|
||||||
|
created: paymentIntent.created,
|
||||||
|
latest_charge: paymentIntent.latest_charge,
|
||||||
|
metadata: paymentIntent.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||||
|
try {
|
||||||
|
const uid = request?.auth?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"unauthenticated",
|
||||||
|
"Connecte-toi pour créer une session Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedUserId =
|
||||||
|
typeof request?.data?.userID === "string"
|
||||||
|
? request.data.userID.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (requestedUserId && requestedUserId !== uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"permission-denied",
|
||||||
|
"Tu ne peux créer une session que pour ton propre compte.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productList = Array.isArray(request?.data?.productList)
|
||||||
|
? request.data.productList
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const { lineItems, summary, hasSubscription } =
|
||||||
|
await buildCheckoutLineItems(productList, { stripe });
|
||||||
|
|
||||||
|
const mode = hasSubscription ? "subscription" : "payment";
|
||||||
|
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
|
||||||
|
|
||||||
|
const { customerId } = await ensureStripeCustomer({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
refsList,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"internal",
|
||||||
|
"Impossible de retrouver le client Stripe associé.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode,
|
||||||
|
customer: customerId,
|
||||||
|
line_items: lineItems,
|
||||||
|
success_url: successUrl,
|
||||||
|
cancel_url: cancelUrl,
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
metadata: {
|
||||||
|
firebaseUID: uid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await paymentsCollection.doc(session.id).set({
|
||||||
|
userId: uid,
|
||||||
|
customerId,
|
||||||
|
status: session.status || "created",
|
||||||
|
mode,
|
||||||
|
createdAt: getServerTimestamp(),
|
||||||
|
updatedAt: getServerTimestamp(),
|
||||||
|
sessionId: session.id,
|
||||||
|
sessionUrl: session.url,
|
||||||
|
lineItems: summary,
|
||||||
|
amountSubtotal: session.amount_subtotal,
|
||||||
|
amountTotal: session.amount_total,
|
||||||
|
currency: session.currency,
|
||||||
|
paymentStatus: session.payment_status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatCheckoutSessionResponse(session);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[createCheckoutSession] error", error);
|
||||||
|
if (error instanceof HttpsError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw mapStripeErrorToHttps(
|
||||||
|
error,
|
||||||
|
"Création de la session Stripe impossible.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||||
|
try {
|
||||||
|
const uid = request?.auth?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"unauthenticated",
|
||||||
|
"Connecte-toi pour consulter ton statut Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const { customerId } = await ensureStripeCustomer({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
refsList,
|
||||||
|
createIfMissing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
return {
|
||||||
|
customerId: null,
|
||||||
|
subscriptions: [],
|
||||||
|
invoices: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subscriptions, invoices] = await Promise.all([
|
||||||
|
stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: "all",
|
||||||
|
expand: ["data.items.data.price"],
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
stripe.invoices.list({
|
||||||
|
customer: customerId,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
|
||||||
|
id: subscription.id,
|
||||||
|
status: subscription.status,
|
||||||
|
created: subscription.created,
|
||||||
|
current_period_start: subscription.current_period_start,
|
||||||
|
current_period_end: subscription.current_period_end,
|
||||||
|
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||||
|
items: subscription.items.data.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
price: item.price?.id,
|
||||||
|
product: item.price?.product,
|
||||||
|
unit_amount: item.price?.unit_amount,
|
||||||
|
currency: item.price?.currency,
|
||||||
|
recurring: item.price?.recurring,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedInvoices = invoices.data.map((invoice) => ({
|
||||||
|
id: invoice.id,
|
||||||
|
status: invoice.status,
|
||||||
|
amount_due: invoice.amount_due,
|
||||||
|
amount_paid: invoice.amount_paid,
|
||||||
|
amount_remaining: invoice.amount_remaining,
|
||||||
|
currency: invoice.currency,
|
||||||
|
hosted_invoice_url: invoice.hosted_invoice_url,
|
||||||
|
invoice_pdf: invoice.invoice_pdf,
|
||||||
|
created: invoice.created,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId,
|
||||||
|
subscriptions: formattedSubscriptions,
|
||||||
|
invoices: formattedInvoices,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[getPremiumStatus] error", error);
|
||||||
|
if (error instanceof HttpsError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw mapStripeErrorToHttps(
|
||||||
|
error,
|
||||||
|
"Impossible de récupérer le statut Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStripeCustomerPortalSession = onCall(
|
||||||
|
{ region: REGION },
|
||||||
|
async (request) => {
|
||||||
|
try {
|
||||||
|
const uid = request?.auth?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"unauthenticated",
|
||||||
|
"Connecte-toi pour ouvrir le portail client Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const { customerId } = await ensureStripeCustomer({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
refsList,
|
||||||
|
createIfMissing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
"Aucun client Stripe associé à cet utilisateur.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalConfigurationId = await getPortalConfigurationId(stripe);
|
||||||
|
if (!portalConfigurationId) {
|
||||||
|
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
|
||||||
|
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { successUrl } = getReturnUrls();
|
||||||
|
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: successUrl,
|
||||||
|
configuration: portalConfigurationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: portalSession.id,
|
||||||
|
url: portalSession.url,
|
||||||
|
created: portalSession.created,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[createStripeCustomerPortalSession] error", error);
|
||||||
|
if (error instanceof HttpsError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw mapStripeErrorToHttps(
|
||||||
|
error,
|
||||||
|
"Ouverture du portail Stripe impossible.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveStripeConnectAccountId = (userData) => {
|
||||||
|
if (!userData || typeof userData !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidatePaths = [
|
||||||
|
["stripeConnectAccountId"],
|
||||||
|
["stripeConnectId"],
|
||||||
|
["stripeAccountId"],
|
||||||
|
["stripeConnectAccount"],
|
||||||
|
["stripeAccount"],
|
||||||
|
["stripe", "connectAccountId"],
|
||||||
|
["stripe", "accountId"],
|
||||||
|
["providers", "stripeConnect", "accountId"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of candidatePaths) {
|
||||||
|
let current = userData;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== "object") {
|
||||||
|
current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current === "string" && current.trim()) {
|
||||||
|
return current.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureStripeConnectAccount = async ({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
userRef,
|
||||||
|
userData,
|
||||||
|
}) => {
|
||||||
|
if (!uid || !stripe) {
|
||||||
|
return { connectAccountId: null, userData, createdAccount: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectAccountId = resolveStripeConnectAccountId(userData);
|
||||||
|
if (connectAccountId) {
|
||||||
|
return { connectAccountId, userData, createdAccount: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let authRecord = null;
|
||||||
|
try {
|
||||||
|
authRecord = await admin.auth().getUser(uid);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = userData?.email || authRecord?.email || undefined;
|
||||||
|
|
||||||
|
const accountParams = {
|
||||||
|
type: "express",
|
||||||
|
capabilities: {
|
||||||
|
card_payments: { requested: true },
|
||||||
|
transfers: { requested: true },
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
firebaseUID: uid,
|
||||||
|
appMode: STRIPE_MODE || "test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
accountParams.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await stripe.accounts.create(accountParams);
|
||||||
|
connectAccountId = account?.id;
|
||||||
|
|
||||||
|
if (!connectAccountId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"internal",
|
||||||
|
"Stripe n’a pas renvoyé d’identifiant de compte Connect.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providersConnect = {
|
||||||
|
...(userData?.providers?.stripeConnect || {}),
|
||||||
|
accountId: connectAccountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userRef) {
|
||||||
|
await userRef.set(
|
||||||
|
{
|
||||||
|
stripeConnectAccountId: connectAccountId,
|
||||||
|
providers: {
|
||||||
|
...(userData?.providers || {}),
|
||||||
|
stripeConnect: {
|
||||||
|
...providersConnect,
|
||||||
|
updatedAt: getServerTimestamp(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectAccountId,
|
||||||
|
userData: {
|
||||||
|
...(userData || {}),
|
||||||
|
stripeConnectAccountId: connectAccountId,
|
||||||
|
providers: {
|
||||||
|
...(userData?.providers || {}),
|
||||||
|
stripeConnect: providersConnect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAccount: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStripeConnectLoginLink = onCall(
|
||||||
|
{ region: REGION },
|
||||||
|
async (request) => {
|
||||||
|
try {
|
||||||
|
const uid = request?.auth?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"unauthenticated",
|
||||||
|
"Connecte-toi pour ouvrir Stripe Connect.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRef = refsList?.users?.doc(uid);
|
||||||
|
const snapshot = userRef ? await userRef.get() : null;
|
||||||
|
let userData = snapshot?.exists ? snapshot.data() : null;
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const ensureResult = await ensureStripeConnectAccount({
|
||||||
|
uid,
|
||||||
|
stripe,
|
||||||
|
userRef,
|
||||||
|
userData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectAccountId = ensureResult.connectAccountId;
|
||||||
|
userData = ensureResult.userData;
|
||||||
|
|
||||||
|
if (!connectAccountId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
"Impossible de créer un compte Stripe Connect pour cet utilisateur.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = getReturnBaseUrl();
|
||||||
|
|
||||||
|
const loginLink = await stripe.accounts.createLoginLink(
|
||||||
|
connectAccountId,
|
||||||
|
redirectUrl
|
||||||
|
? {
|
||||||
|
redirect_url: redirectUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!loginLink?.url) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"internal",
|
||||||
|
"Stripe Connect n’a pas renvoyé de lien de connexion.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: loginLink.id,
|
||||||
|
url: loginLink.url,
|
||||||
|
created: loginLink.created,
|
||||||
|
connectAccountId,
|
||||||
|
createdAccount: ensureResult.createdAccount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[createStripeConnectLoginLink] error", error);
|
||||||
|
if (error instanceof HttpsError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw mapStripeErrorToHttps(
|
||||||
|
error,
|
||||||
|
"Ouverture de Stripe Connect impossible.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
||||||
|
try {
|
||||||
|
const uid = request?.auth?.uid;
|
||||||
|
if (!uid) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"unauthenticated",
|
||||||
|
"Connecte-toi pour vérifier un paiement Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPaymentId =
|
||||||
|
typeof request?.data?.paymentId === "string"
|
||||||
|
? request.data.paymentId.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!rawPaymentId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
"Fournis un identifiant de paiement Stripe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
|
||||||
|
const fetchPaymentIntent = async (paymentIntentId) =>
|
||||||
|
stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||||
|
expand: ["latest_charge"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCheckoutSession = async (sessionId) =>
|
||||||
|
stripe.checkout.sessions.retrieve(sessionId, {
|
||||||
|
expand: ["payment_intent"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let paymentIntent = null;
|
||||||
|
let checkoutSession = null;
|
||||||
|
let paymentType = null;
|
||||||
|
|
||||||
|
if (rawPaymentId.startsWith("pi_")) {
|
||||||
|
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||||
|
paymentType = "payment_intent";
|
||||||
|
} else if (rawPaymentId.startsWith("cs_")) {
|
||||||
|
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||||
|
paymentIntent =
|
||||||
|
checkoutSession?.payment_intent &&
|
||||||
|
typeof checkoutSession.payment_intent === "object"
|
||||||
|
? checkoutSession.payment_intent
|
||||||
|
: checkoutSession?.payment_intent
|
||||||
|
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||||
|
: null;
|
||||||
|
paymentType = "checkout_session";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||||
|
paymentType = "payment_intent";
|
||||||
|
} catch (intentError) {
|
||||||
|
try {
|
||||||
|
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||||
|
paymentType = "checkout_session";
|
||||||
|
paymentIntent =
|
||||||
|
checkoutSession?.payment_intent &&
|
||||||
|
typeof checkoutSession.payment_intent === "object"
|
||||||
|
? checkoutSession.payment_intent
|
||||||
|
: checkoutSession?.payment_intent
|
||||||
|
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||||
|
: null;
|
||||||
|
} catch (sessionError) {
|
||||||
|
console.error("[verifyStripePayment] lookup failure", {
|
||||||
|
intentError,
|
||||||
|
sessionError,
|
||||||
|
});
|
||||||
|
throw new HttpsError(
|
||||||
|
"not-found",
|
||||||
|
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentIntent && !checkoutSession) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"not-found",
|
||||||
|
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedIntent = paymentIntent
|
||||||
|
? normalizePaymentIntent(paymentIntent)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: paymentType,
|
||||||
|
payment_intent: normalizedIntent,
|
||||||
|
checkout_session: checkoutSession
|
||||||
|
? {
|
||||||
|
id: checkoutSession.id,
|
||||||
|
status: checkoutSession.status,
|
||||||
|
payment_status: checkoutSession.payment_status,
|
||||||
|
amount_total: checkoutSession.amount_total,
|
||||||
|
amount_subtotal: checkoutSession.amount_subtotal,
|
||||||
|
currency: checkoutSession.currency,
|
||||||
|
customer: checkoutSession.customer,
|
||||||
|
created: checkoutSession.created,
|
||||||
|
expires_at: checkoutSession.expires_at,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
amount_received: normalizedIntent?.amount_received ?? null,
|
||||||
|
status:
|
||||||
|
normalizedIntent?.status ??
|
||||||
|
checkoutSession?.payment_status ??
|
||||||
|
checkoutSession?.status ??
|
||||||
|
null,
|
||||||
|
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentDocId =
|
||||||
|
paymentType === "checkout_session"
|
||||||
|
? checkoutSession?.id
|
||||||
|
: normalizedIntent?.id;
|
||||||
|
|
||||||
|
if (paymentDocId) {
|
||||||
|
const paymentDocRef = paymentsCollection.doc(paymentDocId);
|
||||||
|
const existing = await paymentDocRef.get();
|
||||||
|
if (existing.exists) {
|
||||||
|
await paymentDocRef.set(
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
paymentStatus: checkoutSession?.payment_status,
|
||||||
|
amountTotal:
|
||||||
|
checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
||||||
|
amountReceived: normalizedIntent?.amount_received,
|
||||||
|
currency: response.currency,
|
||||||
|
updatedAt: getServerTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[verifyStripePayment] error", error);
|
||||||
|
if (error instanceof HttpsError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createCheckoutSession,
|
||||||
|
getPremiumStatus,
|
||||||
|
createStripeCustomerPortalSession,
|
||||||
|
createStripeConnectLoginLink,
|
||||||
|
verifyStripePayment,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
+24
-2
@@ -18,7 +18,9 @@ const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
|||||||
exports.testWelcomMail = onRequest(async (req, res) => {
|
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
res.set("Allow", "GET");
|
res.set("Allow", "GET");
|
||||||
return res.status(405).json({ success: false, error: "Method not allowed" });
|
return res
|
||||||
|
.status(405)
|
||||||
|
.json({ success: false, error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
||||||
@@ -48,7 +50,27 @@ exports.testWelcomMail = onRequest(async (req, res) => {
|
|||||||
|
|
||||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||||
try {
|
try {
|
||||||
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
|
const {
|
||||||
|
email = "",
|
||||||
|
firstName = "",
|
||||||
|
lastName = "",
|
||||||
|
} = event?.data?.data() || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await event.data.ref.set(
|
||||||
|
{
|
||||||
|
coins: admin.firestore.FieldValue.increment(10),
|
||||||
|
coinWelcomeGrantedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
} catch (coinError) {
|
||||||
|
console.warn(
|
||||||
|
"[users-onUserCreated] Unable to grant welcome coins",
|
||||||
|
event?.params?.userID,
|
||||||
|
coinError?.message || coinError,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (email) {
|
if (email) {
|
||||||
if (!resendClient) {
|
if (!resendClient) {
|
||||||
console.warn("Resend API key not configured; skipping welcome email.");
|
console.warn("Resend API key not configured; skipping welcome email.");
|
||||||
|
|||||||
+11
-11
@@ -1,13 +1,15 @@
|
|||||||
import '@expo/metro-runtime';
|
import "@expo/metro-runtime";
|
||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from "expo";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
// Inject minimal global CSS and a small setNativeProps polyfill for web
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
document.documentElement?.setAttribute('translate', 'no');
|
document.documentElement?.setAttribute("translate", "no");
|
||||||
document.body?.setAttribute('translate', 'no');
|
document.body?.setAttribute("translate", "no");
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement("style");
|
||||||
style.setAttribute('data-inline-global', 'true');
|
style.setAttribute("data-inline-global", "true");
|
||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
body { margin: 0; }
|
body { margin: 0; }
|
||||||
@@ -18,14 +20,14 @@ if (typeof document !== 'undefined') {
|
|||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const proto = window.HTMLElement && window.HTMLElement.prototype;
|
const proto = window.HTMLElement && window.HTMLElement.prototype;
|
||||||
if (proto && typeof proto.setNativeProps !== 'function') {
|
if (proto && typeof proto.setNativeProps !== "function") {
|
||||||
proto.setNativeProps = function (nativeProps = {}) {
|
proto.setNativeProps = function (nativeProps = {}) {
|
||||||
try {
|
try {
|
||||||
const { style: s, pointerEvents, ...rest } = nativeProps || {};
|
const { style: s, pointerEvents, ...rest } = nativeProps || {};
|
||||||
if (pointerEvents != null) {
|
if (pointerEvents != null) {
|
||||||
this.style.pointerEvents = pointerEvents;
|
this.style.pointerEvents = pointerEvents;
|
||||||
}
|
}
|
||||||
if (s && typeof s === 'object') {
|
if (s && typeof s === "object") {
|
||||||
for (const k in s) {
|
for (const k in s) {
|
||||||
if (Object.prototype.hasOwnProperty.call(s, k)) {
|
if (Object.prototype.hasOwnProperty.call(s, k)) {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +48,4 @@ if (typeof document !== 'undefined') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
registerRootComponent(App);
|
registerRootComponent(App);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
+1
-32
@@ -54,10 +54,6 @@ import cloud from "./icons/cloud.png";
|
|||||||
import dashboard from "./icons/dashboard.png";
|
import dashboard from "./icons/dashboard.png";
|
||||||
import file from "./icons/file.png";
|
import file from "./icons/file.png";
|
||||||
|
|
||||||
import circle from "./art/circle.png";
|
|
||||||
import gradientTriangle from "./art/gradientTriangle.png";
|
|
||||||
import square from "./art/square.png";
|
|
||||||
import triangle from "./art/triangle.png";
|
|
||||||
import algolia from "./icons/algolia.png";
|
import algolia from "./icons/algolia.png";
|
||||||
import calendar from "./icons/calendar.png";
|
import calendar from "./icons/calendar.png";
|
||||||
import chatBubble from "./icons/chatBubble.png";
|
import chatBubble from "./icons/chatBubble.png";
|
||||||
@@ -82,16 +78,6 @@ import play from "./icons/play.png";
|
|||||||
import share from "./icons/share.png";
|
import share from "./icons/share.png";
|
||||||
import stars from "./icons/stars.png";
|
import stars from "./icons/stars.png";
|
||||||
|
|
||||||
import boost from "./art/boost.png";
|
|
||||||
import boostBig from "./art/boostBig.png";
|
|
||||||
import threeCoins from "./art/threeCoins.png";
|
|
||||||
|
|
||||||
import tutorialChat from "./tutorial/chat.png";
|
|
||||||
import coins from "./tutorial/coins.png";
|
|
||||||
import followProgress from "./tutorial/followProgress.png";
|
|
||||||
import tutorialTasks from "./tutorial/tasks.png";
|
|
||||||
import welcome from "./tutorial/welcome.png";
|
|
||||||
|
|
||||||
import hitParadeBG from "./UI/hitParadeBG.png";
|
import hitParadeBG from "./UI/hitParadeBG.png";
|
||||||
import homeBG from "./UI/homeBG.png";
|
import homeBG from "./UI/homeBG.png";
|
||||||
import libraryBG from "./UI/libraryBG.png";
|
import libraryBG from "./UI/libraryBG.png";
|
||||||
@@ -202,24 +188,6 @@ export const icons = {
|
|||||||
coin,
|
coin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const art = {
|
|
||||||
triangle,
|
|
||||||
circle,
|
|
||||||
square,
|
|
||||||
gradientTriangle,
|
|
||||||
threeCoins,
|
|
||||||
boost,
|
|
||||||
boostBig,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tutorial = {
|
|
||||||
welcome,
|
|
||||||
chat: tutorialChat,
|
|
||||||
coins,
|
|
||||||
followProgress,
|
|
||||||
tasks: tutorialTasks,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const background = {
|
export const background = {
|
||||||
writingBG,
|
writingBG,
|
||||||
writingBgWeb: require("./UI/writingBgWeb.png"),
|
writingBgWeb: require("./UI/writingBgWeb.png"),
|
||||||
@@ -240,6 +208,7 @@ export const background = {
|
|||||||
homeBGWeb: require("./UI/homeBGWeb.png"),
|
homeBGWeb: require("./UI/homeBGWeb.png"),
|
||||||
loginBgWeb: require("./UI/loginBgWeb.png"),
|
loginBgWeb: require("./UI/loginBgWeb.png"),
|
||||||
profileWebBG: require("./UI/profileWebBG.jpg"),
|
profileWebBG: require("./UI/profileWebBG.jpg"),
|
||||||
|
bgTrans: require("./UI/bgTrans.png"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ai = {
|
export const ai = {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const FeatureCarousel = ({
|
|||||||
activeIndex,
|
activeIndex,
|
||||||
onActiveIndexChange,
|
onActiveIndexChange,
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
|
isFocused,
|
||||||
}) => {
|
}) => {
|
||||||
const stageStates = useMemo(() => {
|
const stageStates = useMemo(() => {
|
||||||
if (stageStatesProp) {
|
if (stageStatesProp) {
|
||||||
@@ -223,6 +224,23 @@ const FeatureCarousel = ({
|
|||||||
scrollToIndex(activeIndexRef.current, false);
|
scrollToIndex(activeIndexRef.current, false);
|
||||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || !isFocused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!itemHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPendingAlignment();
|
||||||
|
scrollToIndex(activeIndexRef.current, false);
|
||||||
|
}, [
|
||||||
|
clearPendingAlignment,
|
||||||
|
isFocused,
|
||||||
|
isWeb,
|
||||||
|
itemHeight,
|
||||||
|
scrollToIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
const alignToOffset = useCallback(
|
const alignToOffset = useCallback(
|
||||||
(offset, layoutHeight, options = {}) => {
|
(offset, layoutHeight, options = {}) => {
|
||||||
const { forceSnap = false } = options || {};
|
const { forceSnap = false } = options || {};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
getReactNativePersistence,
|
getReactNativePersistence,
|
||||||
initializeAuth,
|
initializeAuth,
|
||||||
@@ -9,6 +10,26 @@ import "firebase/compat/firestore";
|
|||||||
import "firebase/compat/functions";
|
import "firebase/compat/functions";
|
||||||
import "firebase/compat/storage";
|
import "firebase/compat/storage";
|
||||||
|
|
||||||
|
const functionsInstances = {};
|
||||||
|
const emulatorConfigured = {};
|
||||||
|
const emulatorHost = Platform.OS === "web" ? "localhost" : "192.168.1.146";
|
||||||
|
|
||||||
|
const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
||||||
|
if (!__DEV__ || !instance?.useEmulator || emulatorConfigured[regionKey]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
instance.useEmulator(emulatorHost, 5001);
|
||||||
|
emulatorConfigured[regionKey] = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[firebase] Unable to set functions emulator for region ${regionKey}`,
|
||||||
|
error?.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const firebaseConfig = {
|
export const firebaseConfig = {
|
||||||
apiKey: "AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo",
|
apiKey: "AIzaSyCuHJHdwVN_F-VmUG4Hd7bGiMRqj6rPlLo",
|
||||||
authDomain: "musicland-d33f9.firebaseapp.com",
|
authDomain: "musicland-d33f9.firebaseapp.com",
|
||||||
@@ -29,6 +50,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
|
|||||||
// if (__DEV__) {
|
// if (__DEV__) {
|
||||||
// firebase.functions().useEmulator("localhost", 5001);
|
// firebase.functions().useEmulator("localhost", 5001);
|
||||||
// }
|
// }
|
||||||
|
const defaultFunctions = firebase.functions();
|
||||||
|
functionsInstances["us-central1"] = defaultFunctions;
|
||||||
|
configureFunctionsEmulator(defaultFunctions, "us-central1");
|
||||||
console.log("Firebase init");
|
console.log("Firebase init");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,4 +81,18 @@ export const reportsRef = firestore.collection("reports");
|
|||||||
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
||||||
firebase.firestore.FieldValue;
|
firebase.firestore.FieldValue;
|
||||||
|
|
||||||
|
export const getFunctionsClient = (region = "us-central1") => {
|
||||||
|
const regionKey = region || "us-central1";
|
||||||
|
|
||||||
|
if (!functionsInstances[regionKey]) {
|
||||||
|
functionsInstances[regionKey] = firebase.app().functions(regionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = functionsInstances[regionKey];
|
||||||
|
|
||||||
|
configureFunctionsEmulator(instance, regionKey);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
export default firebase;
|
export default firebase;
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
export const STRIPE_PUBLISHABLE_KEY_TEST = "..";
|
export const STRIPE_PUBLISHABLE_KEY_TEST =
|
||||||
export const STRIPE_PUBLISHABLE_KEY_LIVE = "...";
|
"pk_test_51SPfcjCzf2o5bDRdxkCvPUnZnc5XS80HEIo1mlOmqsC2i8pkFBwD78dVLM5AOlFDMnddKH6EFA2kiPzIuqrTi7YQ001YJz4T7r";
|
||||||
|
export const STRIPE_PUBLISHABLE_KEY_LIVE = "";
|
||||||
export const GOOGLE_API_KEY = "...";
|
export const GOOGLE_API_KEY = "...";
|
||||||
|
|
||||||
// OAuth Client IDs for Google Sign-In
|
// OAuth Client IDs for Google Sign-In
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import RecordedPlayback from "../screens/Playback/RecordedPlayback";
|
|||||||
import VideoFinalize from "../screens/Playback/VideoFinalize";
|
import VideoFinalize from "../screens/Playback/VideoFinalize";
|
||||||
import PublishYoutube from "../screens/Publishing/PublishYoutube";
|
import PublishYoutube from "../screens/Publishing/PublishYoutube";
|
||||||
import PrivacyPolicy from "../screens/PrivacyPolicy";
|
import PrivacyPolicy from "../screens/PrivacyPolicy";
|
||||||
|
import Payments from "../screens/Payments";
|
||||||
import DownloadPrices from "../screens/Production/DownloadPrices";
|
import DownloadPrices from "../screens/Production/DownloadPrices";
|
||||||
import DownloadSongs from "../screens/Production/DownloadSongs";
|
import DownloadSongs from "../screens/Production/DownloadSongs";
|
||||||
import PlaybackExample from "../screens/Production/PlaybackExample";
|
import PlaybackExample from "../screens/Production/PlaybackExample";
|
||||||
@@ -109,6 +110,11 @@ const baseScreens = [
|
|||||||
component: PrivacyPolicy,
|
component: PrivacyPolicy,
|
||||||
title: "Politique de confidentialité",
|
title: "Politique de confidentialité",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: Routes.Payments,
|
||||||
|
component: Payments,
|
||||||
|
title: "Abonnements",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: Routes.Writing,
|
name: Routes.Writing,
|
||||||
component: Writing,
|
component: Writing,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const Routes = {
|
|||||||
|
|
||||||
TermsOfUse: "TermsOfUse",
|
TermsOfUse: "TermsOfUse",
|
||||||
PrivacyPolicy: "PrivacyPolicy",
|
PrivacyPolicy: "PrivacyPolicy",
|
||||||
|
Payments: "Payments",
|
||||||
|
|
||||||
Writing: "Writing",
|
Writing: "Writing",
|
||||||
WritingLyrics: "WritingLyrics",
|
WritingLyrics: "WritingLyrics",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform } from "react-native";
|
import { AppState, Platform } from "react-native";
|
||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import firebase, {
|
import firebase, {
|
||||||
arrayUnion,
|
arrayUnion,
|
||||||
@@ -66,6 +66,18 @@ export default function NotificationProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [notifications]);
|
}, [notifications]);
|
||||||
|
|
||||||
|
const clearBadgeCount = useCallback(async () => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Notifications.setBadgeCountAsync(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("clearBadgeCount error:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markNotificationAsRead = useCallback(async (notificationId) => {
|
const markNotificationAsRead = useCallback(async (notificationId) => {
|
||||||
try {
|
try {
|
||||||
if (!notificationId) return;
|
if (!notificationId) return;
|
||||||
@@ -117,6 +129,33 @@ export default function NotificationProvider({ children }) {
|
|||||||
registerForPushNotificationsAsync();
|
registerForPushNotificationsAsync();
|
||||||
}, [user, uid, notifInit]);
|
}, [user, uid, notifInit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBadgeCount();
|
||||||
|
|
||||||
|
const handleAppStateChange = (state) => {
|
||||||
|
if (state === "active") {
|
||||||
|
clearBadgeCount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
handleAppStateChange
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (subscription?.remove) {
|
||||||
|
subscription.remove();
|
||||||
|
} else {
|
||||||
|
AppState.removeEventListener("change", handleAppStateChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [clearBadgeCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allowNotifications) {
|
if (!allowNotifications) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const DEFAULT_CONTEXT = {
|
|||||||
isLooping: false,
|
isLooping: false,
|
||||||
setLooping: noop,
|
setLooping: noop,
|
||||||
toggleLooping: noop,
|
toggleLooping: noop,
|
||||||
|
getTrackInfo: () => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PlayerContext = createContext(DEFAULT_CONTEXT);
|
export const PlayerContext = createContext(DEFAULT_CONTEXT);
|
||||||
@@ -230,6 +231,7 @@ const PlayerProvider = ({ children }) => {
|
|||||||
const autoPlayRef = useRef(false);
|
const autoPlayRef = useRef(false);
|
||||||
const pendingSeekValueRef = useRef(null);
|
const pendingSeekValueRef = useRef(null);
|
||||||
const didJustFinishRef = useRef(false);
|
const didJustFinishRef = useRef(false);
|
||||||
|
const trackInfoRef = useRef({});
|
||||||
|
|
||||||
const setQueueIndexValue = useCallback((index = -1) => {
|
const setQueueIndexValue = useCallback((index = -1) => {
|
||||||
const list = queueRef.current;
|
const list = queueRef.current;
|
||||||
@@ -401,6 +403,18 @@ const PlayerProvider = ({ children }) => {
|
|||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (currentTrack?.id) {
|
||||||
|
trackInfoRef.current[currentTrack.id] = {
|
||||||
|
durationMs:
|
||||||
|
durationMs > 0
|
||||||
|
? durationMs
|
||||||
|
: trackInfoRef.current[currentTrack.id]?.durationMs || 0,
|
||||||
|
positionMs,
|
||||||
|
isLoaded,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}, [status, currentTrack]);
|
}, [status, currentTrack]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -614,6 +628,10 @@ const PlayerProvider = ({ children }) => {
|
|||||||
const toggleLooping = useCallback(() => {
|
const toggleLooping = useCallback(() => {
|
||||||
setIsLooping((prev) => !prev);
|
setIsLooping((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
const getTrackInfo = useCallback((trackId) => {
|
||||||
|
if (!trackId) return null;
|
||||||
|
return trackInfoRef.current[trackId] || null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTrack?.id) {
|
if (!currentTrack?.id) {
|
||||||
@@ -692,6 +710,7 @@ const PlayerProvider = ({ children }) => {
|
|||||||
isLooping,
|
isLooping,
|
||||||
setLooping,
|
setLooping,
|
||||||
toggleLooping,
|
toggleLooping,
|
||||||
|
getTrackInfo,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -715,6 +734,7 @@ const PlayerProvider = ({ children }) => {
|
|||||||
setLooping,
|
setLooping,
|
||||||
toggleLooping,
|
toggleLooping,
|
||||||
setQueue,
|
setQueue,
|
||||||
|
getTrackInfo,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { View, Text, Image } from "react-native";
|
|
||||||
import React from "react";
|
|
||||||
import { Palette, Style } from "../../../styles";
|
|
||||||
import { icons } from "../../../assets";
|
|
||||||
import { size } from "../../../styles/Style";
|
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
|
||||||
|
|
||||||
const CoinBadge = () => {
|
|
||||||
return (
|
|
||||||
<View style={{ ...Style.containerRow }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingLeft: 5,
|
|
||||||
paddingRight: 10,
|
|
||||||
paddingVertical: 3,
|
|
||||||
borderTopLeftRadius: 8,
|
|
||||||
borderBottomLeftRadius: 8,
|
|
||||||
backgroundColor: Palette.glass,
|
|
||||||
right: -10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: Palette.white,
|
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
100
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Image source={icons.coin} style={size({ size: 26 })} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CoinBadge;
|
|
||||||
@@ -4,7 +4,6 @@ import { BlurView } from "expo-blur";
|
|||||||
import { img } from "../../../assets";
|
import { img } from "../../../assets";
|
||||||
import { Palette, Style } from "../../../styles";
|
import { Palette, Style } from "../../../styles";
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||||
import CoinBadge from "./CoinBadge";
|
|
||||||
|
|
||||||
const PlaybacksCard = ({
|
const PlaybacksCard = ({
|
||||||
rank = 1,
|
rank = 1,
|
||||||
@@ -72,7 +71,6 @@ const PlaybacksCard = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<CoinBadge />
|
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { BlurView } from "expo-blur";
|
|||||||
import Style, { size } from "../../../styles/Style";
|
import Style, { size } from "../../../styles/Style";
|
||||||
import { Palette } from "../../../styles";
|
import { Palette } from "../../../styles";
|
||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||||
import CoinBadge from "./CoinBadge";
|
|
||||||
|
|
||||||
const SongCard = ({
|
const SongCard = ({
|
||||||
rank = 1,
|
rank = 1,
|
||||||
@@ -91,7 +90,6 @@ const SongCard = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<CoinBadge />
|
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import { responsiveHeight } from "react-native-responsive-dimensions";
|
|||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import BorderGradient from "../../components/BorderGradient/BorderGradient";
|
import BorderGradient from "../../components/BorderGradient/BorderGradient";
|
||||||
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MoreMenu from "../../components/MoreMenu";
|
import MoreMenu from "../../components/MoreMenu";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import ProfilePicture from "../../components/ProfilePicture";
|
import ProfilePicture from "../../components/ProfilePicture";
|
||||||
@@ -473,6 +474,19 @@ const Profile = () => {
|
|||||||
<Text style={styles.label}>abonnements</Text>
|
<Text style={styles.label}>abonnements</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
{isSelf ? (
|
||||||
|
<GradientButton
|
||||||
|
title="Découvrir les abonnements"
|
||||||
|
size="large"
|
||||||
|
onPress={() => push(Routes.Payments)}
|
||||||
|
containerStyle={{
|
||||||
|
marginTop: 18,
|
||||||
|
width: "80%",
|
||||||
|
alignSelf: "center",
|
||||||
|
}}
|
||||||
|
gradientStyle={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{!isSelf && (
|
{!isSelf && (
|
||||||
<Pressable onPress={handleFollowUser}>
|
<Pressable onPress={handleFollowUser}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ const GeneratingSong = () => {
|
|||||||
"Le service Suno est indisponible. Veuillez réessayer plus tard.";
|
"Le service Suno est indisponible. Veuillez réessayer plus tard.";
|
||||||
AppAlert(
|
AppAlert(
|
||||||
"Impossible de générer la musique",
|
"Impossible de générer la musique",
|
||||||
sanitized || fallbackMessage
|
sanitized || fallbackMessage,
|
||||||
);
|
);
|
||||||
if (selectedProjectId) {
|
if (selectedProjectId) {
|
||||||
try {
|
try {
|
||||||
@@ -196,12 +196,12 @@ const GeneratingSong = () => {
|
|||||||
message: sanitized || fallbackMessage,
|
message: sanitized || fallbackMessage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
} catch (firestoreError) {
|
} catch (firestoreError) {
|
||||||
console.log(
|
console.log(
|
||||||
"GeneratingSong Firestore update error",
|
"GeneratingSong Firestore update error",
|
||||||
firestoreError?.message
|
firestoreError?.message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user