diff --git a/functions/index.js b/functions/index.js
index 328d89a..1a927b3 100644
--- a/functions/index.js
+++ b/functions/index.js
@@ -50,3 +50,4 @@ exports.rankings = require("./src/rankings");
exports.payouts = require("./src/payouts");
exports.subscription = require("./src/subscription");
exports.youtube = require("./src/youtube");
+exports.orders = require("./src/orders");
diff --git a/functions/package-lock.json b/functions/package-lock.json
index 340b4e4..96d9f5d 100644
--- a/functions/package-lock.json
+++ b/functions/package-lock.json
@@ -68,6 +68,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz",
+ "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -137,6 +147,34 @@
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
+ "node_modules/@ffmpeg-installer/darwin-arm64": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
+ "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
+ "cpu": [
+ "arm64"
+ ],
+ "hasInstallScript": true,
+ "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/darwin-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
+ "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
+ "cpu": [
+ "x64"
+ ],
+ "hasInstallScript": true,
+ "license": "LGPL-2.1",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
"node_modules/@ffmpeg-installer/ffmpeg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
@@ -153,6 +191,48 @@
"@ffmpeg-installer/win32-x64": "4.1.0"
}
},
+ "node_modules/@ffmpeg-installer/linux-arm": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
+ "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
+ "cpu": [
+ "arm"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-arm64": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
+ "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
+ "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
"node_modules/@ffmpeg-installer/linux-x64": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
@@ -167,6 +247,32 @@
"linux"
]
},
+ "node_modules/@ffmpeg-installer/win32-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
+ "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/win32-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
+ "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@firebase/app-check-interop-types": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz",
@@ -433,6 +539,8 @@
"version": "7.11.6",
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
+ "license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/api": "^1.3.0",
"fast-deep-equal": "^3.1.1",
@@ -622,9 +730,9 @@
}
},
"node_modules/@google-cloud/storage": {
- "version": "7.17.2",
- "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.2.tgz",
- "integrity": "sha512-6xN0KNO8L/LIA5zu3CJwHkJiB6n65eykBLOb0E+RooiHYgX8CSao6lvQiKT9TBk2gL5g33LL3fmhDodZnt56rw==",
+ "version": "7.17.3",
+ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.3.tgz",
+ "integrity": "sha512-gOnCAbFgAYKRozywLsxagdevTF7Gm+2Ncz5u5CQAuOv/2VCa0rdGJWvJFDOftPx1tc+q8TXiC2pEJfFKu+yeMQ==",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/paginator": "^5.0.0",
@@ -743,6 +851,130 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
@@ -759,6 +991,22 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
@@ -775,6 +1023,72 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
@@ -797,6 +1111,28 @@
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
@@ -819,6 +1155,63 @@
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
@@ -872,6 +1265,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -967,6 +1361,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz",
"integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "1.25.1"
},
@@ -2154,6 +2549,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz",
"integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "1.25.1",
"@opentelemetry/semantic-conventions": "1.25.1"
@@ -2196,6 +2592,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz",
"integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "1.25.1",
"@opentelemetry/resources": "1.25.1",
@@ -2249,6 +2646,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz",
"integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "1.25.1",
"@opentelemetry/resources": "1.25.1",
@@ -2759,6 +3157,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2937,9 +3336,9 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz",
- "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -3586,6 +3985,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4080,6 +4480,7 @@
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz",
"integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@firebase/database-compat": "1.0.8",
@@ -4367,6 +4768,7 @@
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.22.0.tgz",
"integrity": "sha512-GoVVO3EnNHrjkMkUPRvgx1MjBHKvOlZAu/ffMIJgLFxrH7rrUbvfHXE6Nk7uh5BNvET7+DApyhbhqz9G8sy+mQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@genkit-ai/ai": "1.22.0",
"@genkit-ai/core": "1.22.0",
@@ -6047,9 +6449,9 @@
"license": "MIT"
},
"node_modules/resend": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/resend/-/resend-6.4.0.tgz",
- "integrity": "sha512-CTr4ix4RI5M/ucL58Wqr+LE8eI4JHtJEFaBAx6yUVNOI3eaPVtJjpNL0G/BdRSWMbwv6CtpprVOY8Xvpp6UJlA==",
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.4.1.tgz",
+ "integrity": "sha512-+P1gF6bYT0lKd0vsYyKKTHd6PfMH37yDgNRA6wAARQgQbYlc2BZYuo9sB/uZKkVg1oGSh0cgpnxNKGsZJutXMg==",
"license": "MIT",
"dependencies": {
"svix": "1.76.1"
@@ -6546,9 +6948,9 @@
}
},
"node_modules/stripe": {
- "version": "19.2.0",
- "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.2.0.tgz",
- "integrity": "sha512-strzN8luMGMC1LEleGKg7pJGXFx0kSS4y/uSjK8yPQV9SUBMtJVAp/v8XMQLRnMbXaSaWLrIaHcMlKcsizdRDQ==",
+ "version": "19.2.1",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-19.2.1.tgz",
+ "integrity": "sha512-eRc2T413316D7fAjSSEgPbJJ+4a8KY9rOTOb27aXd7bkw9ADO/3OxmIk7YWDhWvHgvxnEZ/29YjcmBBOu4mhrw==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
@@ -6962,6 +7364,7 @@
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -7126,6 +7529,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/functions/src/helpers/orders.js b/functions/src/helpers/orders.js
new file mode 100644
index 0000000..0f8b3b5
--- /dev/null
+++ b/functions/src/helpers/orders.js
@@ -0,0 +1,90 @@
+const admin = require("firebase-admin");
+
+const ORDER_TYPES = {
+ GIFT: "GIFT",
+ SONG: "SONG",
+ COINS: "COINS",
+};
+
+const ORDER_STATUS = {
+ PENDING: "PENDING",
+ APPLIED: "APPLIED",
+ REJECTED: "REJECTED",
+};
+
+const ORDERS_COLLECTION = "orders";
+
+const isFiniteNumber = (value) => {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return true;
+ }
+ if (typeof value === "string") {
+ const parsed = Number(value);
+ return Number.isFinite(parsed);
+ }
+ return false;
+};
+
+const normalizeAmount = (amount) => {
+ if (!isFiniteNumber(amount)) {
+ return null;
+ }
+ return Number(amount);
+};
+
+const createOrderDocument = async ({
+ userId,
+ type,
+ amount,
+ songId = null,
+ createdBy = "system",
+ metadata = {},
+ orderId = null,
+}) => {
+ if (!userId || typeof userId !== "string") {
+ throw new Error("[orders] Missing userId when creating order");
+ }
+
+ if (!Object.values(ORDER_TYPES).includes(type)) {
+ throw new Error(`[orders] Invalid order type "${type}"`);
+ }
+
+ const normalizedAmount = normalizeAmount(amount);
+
+ if (normalizedAmount === null || normalizedAmount === 0) {
+ throw new Error("[orders] Invalid order amount");
+ }
+
+ const payload = {
+ userId,
+ type,
+ amount: normalizedAmount,
+ songId: type === ORDER_TYPES.SONG ? songId || null : null,
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
+ createdBy: createdBy || "system",
+ status: ORDER_STATUS.PENDING,
+ metadata: metadata || {},
+ };
+
+ const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
+ const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
+
+ if (orderId) {
+ const existingSnapshot = await orderRef.get();
+ if (existingSnapshot.exists) {
+ return { orderRef, orderId: orderRef.id };
+ }
+ }
+
+ await orderRef.set(payload);
+
+ return { orderRef, orderId: orderRef.id };
+};
+
+module.exports = {
+ ORDER_TYPES,
+ ORDER_STATUS,
+ ORDERS_COLLECTION,
+ createOrderDocument,
+ normalizeAmount,
+};
diff --git a/functions/src/orders.js b/functions/src/orders.js
new file mode 100644
index 0000000..068f4dd
--- /dev/null
+++ b/functions/src/orders.js
@@ -0,0 +1,190 @@
+const admin = require("firebase-admin");
+const { onDocumentCreated } = require("firebase-functions/firestore");
+const { HttpsError, onCall } = require("firebase-functions/https");
+
+const { REGION } = require("../index");
+const {
+ ORDER_TYPES,
+ ORDER_STATUS,
+ ORDERS_COLLECTION,
+ createOrderDocument,
+ normalizeAmount,
+} = require("./helpers/orders");
+
+const USERS_COLLECTION = "users";
+
+const onOrderCreated = onDocumentCreated(
+ `${ORDERS_COLLECTION}/{orderId}`,
+ async (event) => {
+ const orderRef = event?.data?.ref;
+ const orderData = event?.data?.data();
+
+ if (!orderRef || !orderData) {
+ return;
+ }
+
+ if (orderData?.processedAt) {
+ return;
+ }
+
+ const userId =
+ typeof orderData.userId === "string" ? orderData.userId.trim() : "";
+ const amount = normalizeAmount(orderData.amount);
+
+ if (!userId) {
+ await orderRef.set(
+ {
+ status: ORDER_STATUS.REJECTED,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ failureReason: "USER_NOT_FOUND",
+ },
+ { merge: true },
+ );
+ return;
+ }
+
+ if (amount === null || amount === 0) {
+ await orderRef.set(
+ {
+ status: ORDER_STATUS.REJECTED,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ failureReason: "INVALID_AMOUNT",
+ },
+ { merge: true },
+ );
+ return;
+ }
+
+ const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
+
+ try {
+ await admin.firestore().runTransaction(async (transaction) => {
+ const userSnapshot = await transaction.get(userRef);
+ const userData = userSnapshot?.data() || {};
+ const currentBalanceValue = normalizeAmount(userData?.coins);
+ const currentBalance =
+ currentBalanceValue !== null ? currentBalanceValue : 0;
+
+ const nextBalance = currentBalance + amount;
+
+ if (
+ amount < 0 &&
+ nextBalance < 0 &&
+ orderData?.type === ORDER_TYPES.SONG
+ ) {
+ transaction.set(
+ orderRef,
+ {
+ status: ORDER_STATUS.REJECTED,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ failureReason: "INSUFFICIENT_FUNDS",
+ balanceBefore: currentBalance,
+ balanceAfter: currentBalance,
+ },
+ { merge: true },
+ );
+ return;
+ }
+
+ if (userSnapshot?.exists) {
+ transaction.update(userRef, {
+ coins: nextBalance,
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ });
+ } else {
+ transaction.set(
+ userRef,
+ {
+ coins: nextBalance,
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
+ updatedAt: admin.firestore.FieldValue.serverTimestamp(),
+ },
+ { merge: true },
+ );
+ }
+
+ transaction.set(
+ orderRef,
+ {
+ status: ORDER_STATUS.APPLIED,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ balanceBefore: currentBalance,
+ balanceAfter: nextBalance,
+ },
+ { merge: true },
+ );
+ });
+ } catch (error) {
+ console.error(
+ "[orders-onOrderCreated] Failed to process order",
+ orderRef.id,
+ error,
+ );
+
+ await orderRef.set(
+ {
+ status: ORDER_STATUS.REJECTED,
+ processedAt: admin.firestore.FieldValue.serverTimestamp(),
+ failureReason: "PROCESSING_ERROR",
+ errorMessage: error?.message || String(error),
+ },
+ { merge: true },
+ );
+ }
+ },
+);
+
+const createSongOrder = onCall({ region: REGION }, async (request) => {
+ const { auth, data } = request || {};
+
+ if (!auth?.uid) {
+ throw new HttpsError("unauthenticated", "Authentification requise.");
+ }
+
+ const amount = normalizeAmount(data?.amount);
+
+ if (amount === null || amount >= 0) {
+ throw new HttpsError(
+ "invalid-argument",
+ "Le montant doit être négatif pour un achat de musique.",
+ );
+ }
+
+ const songId =
+ typeof data?.songId === "string" && data.songId.trim()
+ ? data.songId.trim()
+ : null;
+
+ const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
+ const userSnapshot = await userRef.get();
+ const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
+ const currentCoins =
+ currentCoinsValue !== null ? currentCoinsValue : 0;
+
+ if (currentCoins + amount < 0) {
+ throw new HttpsError(
+ "failed-precondition",
+ "Crédits insuffisants pour finaliser l'opération.",
+ );
+ }
+
+ const { orderId } = await createOrderDocument({
+ userId: auth.uid,
+ type: ORDER_TYPES.SONG,
+ amount,
+ songId,
+ createdBy: auth.uid,
+ metadata: {
+ source: data?.source || "music_generation",
+ requestId:
+ typeof data?.requestId === "string" ? data.requestId : undefined,
+ },
+ });
+
+ return { orderId };
+});
+
+module.exports = {
+ onOrderCreated,
+ createSongOrder,
+};
diff --git a/functions/src/subscription.js b/functions/src/subscription.js
index c7fba98..fe7c4b1 100644
--- a/functions/src/subscription.js
+++ b/functions/src/subscription.js
@@ -4,6 +4,7 @@ const { onRequest } = require("firebase-functions/v2/https");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
const refsList = refList;
+const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
const {
getStripeClient,
buildCheckoutLineItems,
@@ -76,9 +77,7 @@ const COIN_PACK_PRODUCTS = [
},
];
-const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map(
- (pack) => pack.productId,
-);
+const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
(acc, pack) => ({
@@ -324,7 +323,7 @@ const handleCheckoutSessionCompleted = async (session, event) => {
lastEventAt: getServerTimestamp(),
});
- const { userRef } = await resolveUserContext({
+ const { uid, userRef } = await resolveUserContext({
metadata: session.metadata,
customerId: session.customer,
});
@@ -378,17 +377,23 @@ const handleCheckoutSessionCompleted = async (session, event) => {
}
const alreadyGranted = Boolean(
- paymentSnapshot?.exists &&
- paymentSnapshot.data()?.coinPackGrantedAt,
+ paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
);
if (!alreadyGranted) {
- await userRef.set(
- {
- coins: FieldValue.increment(coinAmount),
+ const targetUserId = uid || firebaseUid || userRef.id;
+
+ await createOrderDocument({
+ userId: targetUserId,
+ type: ORDER_TYPES.COINS,
+ amount: coinAmount,
+ metadata: {
+ source: "STRIPE_CHECKOUT",
+ paymentId: session.id || null,
+ coinPackKey: session.metadata?.coinPackKey || null,
},
- { merge: true },
- );
+ orderId: `stripe_${session.id}`,
+ });
await paymentDocRef.set(
{
@@ -409,9 +414,26 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
}
const subscriptionPayload = buildSubscriptionPayload(subscription);
+ if (!subscriptionPayload) {
+ return;
+ }
+
+ const { uid, userRef } = await resolveUserContext({
+ metadata: subscription.metadata,
+ customerId: subscription.customer,
+ });
+
+ let resolvedCustomerId = subscriptionPayload?.customerId || null;
+ if (!resolvedCustomerId && subscription.customer) {
+ resolvedCustomerId = subscription.customer;
+ }
+
+ const fallbackUid = extractFirebaseUid(subscription.metadata);
+ const resolvedUid = uid || fallbackUid || null;
await upsertPaymentDocument(subscription.id, {
- customerId: subscription.customer || null,
+ userId: resolvedUid,
+ customerId: resolvedCustomerId,
subscriptionId: subscription.id || null,
status: subscription.status || null,
mode: "subscription",
@@ -420,17 +442,15 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
currentPeriodStart: subscriptionPayload?.currentPeriodStart || null,
currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null,
- metadata: subscription.metadata || {},
+ metadata: {
+ ...subscription.metadata,
+ stripeEventType: event?.type || null,
+ },
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
- const { uid, userRef } = await resolveUserContext({
- metadata: subscription.metadata,
- customerId: subscription.customer,
- });
-
if (!userRef) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
@@ -441,13 +461,11 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
}
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
- if (uid) {
- lastEvent.uid = uid;
+ if (resolvedUid) {
+ lastEvent.uid = resolvedUid;
}
- const priceMeta = getSubscriptionMetaFromPrice(
- subscriptionPayload?.priceId,
- );
+ const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const metadataPeriod =
subscription.metadata?.subscriptionBillingPeriod || null;
@@ -468,6 +486,8 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
if (subscriptionPayload?.customerId) {
userUpdate.stripeCustomerId = subscriptionPayload.customerId;
+ } else if (resolvedCustomerId) {
+ userUpdate.stripeCustomerId = resolvedCustomerId;
}
if (resolvedLevel && isPremium) {
@@ -733,9 +753,7 @@ const formatCoinPack = ({ product, price, fallback = {} }) => {
const priceId =
(resolvedPrice && resolvedPrice.id) ||
- (typeof product.default_price === "string"
- ? product.default_price
- : null);
+ (typeof product.default_price === "string" ? product.default_price : null);
const rawCoinAmount =
product?.metadata?.coins ??
@@ -753,8 +771,7 @@ const formatCoinPack = ({ product, price, fallback = {} }) => {
priceId,
name: product.name || "",
description: product.description || "",
- coinAmount:
- Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0,
+ coinAmount: Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0,
currency:
resolvedPrice?.currency ||
(typeof resolvedPrice?.currency === "string"
@@ -824,9 +841,7 @@ const listCoinPacks = onCall({ region: REGION }, async () => {
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
- resolvedPrice = await stripe.prices.retrieve(
- product.default_price,
- );
+ resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
diff --git a/functions/src/users.js b/functions/src/users.js
index 3482eed..8b09ff8 100644
--- a/functions/src/users.js
+++ b/functions/src/users.js
@@ -4,6 +4,7 @@ const {
onDocumentCreated,
} = require("firebase-functions/firestore");
const { refList } = require("../index");
+const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
const { deleteFolder } = require("../helpers/firebase");
const { Resend } = require("resend");
const { welcomeTemplate } = require("../helpers/email");
@@ -56,14 +57,16 @@ exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
lastName = "",
} = event?.data?.data() || {};
+ const userId = event?.params?.userID;
+
try {
- await event.data.ref.set(
- {
- coins: admin.firestore.FieldValue.increment(10),
- coinWelcomeGrantedAt: admin.firestore.FieldValue.serverTimestamp(),
- },
- { merge: true },
- );
+ await createOrderDocument({
+ userId,
+ type: ORDER_TYPES.GIFT,
+ amount: 10,
+ metadata: { reason: "WELCOME_BONUS" },
+ orderId: `welcome_${userId}`,
+ });
} catch (coinError) {
console.warn(
"[users-onUserCreated] Unable to grant welcome coins",
diff --git a/src/constants/strings.js b/src/constants/strings.js
index 02c3c57..94e59ff 100644
--- a/src/constants/strings.js
+++ b/src/constants/strings.js
@@ -1,12 +1,8 @@
export const strings = {
writing: {
steps: {
- contextTitle: "Étape 2: Quel est le contexte ?",
- contextSubtitle:
- "Ajoute quelques détails pour nous guider (ex.: anniversaire, équipe de sport ou d’entreprise…)",
- contextExamples:
- "Exemples : « Anniversaire de Clara – ton joyeux et pop », « Hymne d’équipe – style rap énergique », « Esprit d’entreprise – ambiance motivante »",
- emotionTitle: "Étape 3: Quelle émotion veux-tu transmettre ?",
+ contextTitle: "Quel est le contexte ?",
+ emotionTitle: "Quelle émotion veux-tu transmettre ?",
emotionSubtitle: "Sélectionne une seule intention émotionnelle.",
},
labels: {
diff --git a/src/layouts/Page.js b/src/layouts/Page.js
index 8eaad34..2ca9416 100644
--- a/src/layouts/Page.js
+++ b/src/layouts/Page.js
@@ -49,29 +49,18 @@ export default ({
const { currentUID = null, currentUserData = null } = useUserData?.() || {};
const coinBalance = React.useMemo(() => {
- const data = currentUserData || {};
- const candidates = [
- data?.coins,
- data?.coinBalance,
- data?.coin,
- data?.wallet?.coins,
- data?.wallet?.coinBalance,
- ];
-
- for (const value of candidates) {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string") {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
+ const value = currentUserData?.coins;
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
}
}
-
return 0;
- }, [currentUserData]);
+ }, [currentUserData?.coins]);
const formattedCoins = React.useMemo(() => {
try {
diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js
index 553ee72..faae07c 100644
--- a/src/navigation/MainStack.js
+++ b/src/navigation/MainStack.js
@@ -37,6 +37,7 @@ import EditProfile from "../screens/Profile/EditProfile";
import Follows from "../screens/Profile/Follows";
import Language from "../screens/Profile/Language";
import Notifications from "../screens/Profile/Notifications";
+import OrderHistory from "../screens/Profile/OrderHistory";
import Profile from "../screens/Profile/Profile";
import Reels from "../screens/Profile/Reels";
import Settings from "../screens/Profile/Settings";
@@ -271,6 +272,11 @@ const baseScreens = [
name: Routes.Settings,
component: Settings,
},
+ {
+ name: Routes.OrderHistory,
+ component: OrderHistory,
+ title: "Historique de commandes",
+ },
{
name: Routes.Follows,
component: Follows,
diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js
index a7bfe2e..ac13756 100644
--- a/src/navigation/Routes.js
+++ b/src/navigation/Routes.js
@@ -76,6 +76,7 @@ export const Routes = {
SingerProfile: "SingerProfile",
EditProfile: "EditProfile",
Settings: "Settings",
+ OrderHistory: "OrderHistory",
ChangeEmailAddress: "ChangeEmailAddress",
ChangePassword: "ChangePassword",
Notifications: "Notifications",
diff --git a/src/screens/Profile/OrderHistory.js b/src/screens/Profile/OrderHistory.js
new file mode 100644
index 0000000..2e957f2
--- /dev/null
+++ b/src/screens/Profile/OrderHistory.js
@@ -0,0 +1,351 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+
+import firebase from "../../config/firebase";
+import { background } from "../../assets";
+import Page from "../../layouts/Page";
+import { useUser } from "../../providers/UserDataProvider";
+import { gutters, Palette } from "../../styles";
+import { FONT_FAMILY } from "../../styles/Fonts";
+
+const ORDER_TYPE_LABELS = {
+ GIFT: "Crédit offert",
+ COINS: "Achat de coins",
+ SONG: "Génération de musique",
+};
+
+const STATUS_LABELS = {
+ PENDING: "En cours",
+ APPLIED: "Confirmée",
+ REJECTED: "Refusée",
+};
+
+const formatCoins = (amount) => {
+ if (typeof amount !== "number" || !Number.isFinite(amount)) {
+ return "0";
+ }
+ try {
+ return new Intl.NumberFormat("fr-FR", {
+ maximumFractionDigits: 0,
+ }).format(amount);
+ } catch (_error) {
+ return `${amount}`;
+ }
+};
+
+const formatDate = (date) => {
+ if (!date) {
+ return "En attente de confirmation";
+ }
+ try {
+ return date.toLocaleString("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch (_error) {
+ return date.toString();
+ }
+};
+
+const mapOrderType = (type) => ORDER_TYPE_LABELS[type] || "Opération";
+
+const mapStatus = (status) => STATUS_LABELS[status] || "Inconnue";
+
+const OrderHistory = () => {
+ const { currentUID } = useUser() || {};
+ const [orders, setOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!currentUID) {
+ setOrders([]);
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ const unsubscribe = firebase
+ .firestore()
+ .collection("orders")
+ .where("userId", "==", currentUID)
+ .orderBy("createdAt", "desc")
+ .onSnapshot(
+ (snapshot) => {
+ const nextOrders = snapshot.docs.map((doc) => {
+ const rawData = doc.data() || {};
+ const createdAt =
+ typeof rawData.createdAt?.toDate === "function"
+ ? rawData.createdAt.toDate()
+ : null;
+ return {
+ id: doc.id,
+ ...rawData,
+ createdAt,
+ };
+ });
+
+ setOrders(nextOrders);
+ setLoading(false);
+ },
+ (firestoreError) => {
+ console.warn(
+ "[OrderHistory] Unable to load orders",
+ firestoreError?.message || firestoreError,
+ );
+ setError(
+ firestoreError?.message ||
+ "Impossible de récupérer l'historique des commandes.",
+ );
+ setLoading(false);
+ },
+ );
+
+ return () => {
+ if (typeof unsubscribe === "function") {
+ unsubscribe();
+ }
+ };
+ }, [currentUID]);
+
+ const renderOrder = useCallback(({ item }) => {
+ const amountValue =
+ typeof item.amount === "number" && Number.isFinite(item.amount)
+ ? item.amount
+ : 0;
+ const isPositive = amountValue > 0;
+ const amountLabel = `${isPositive ? "+" : ""}${formatCoins(amountValue)} ${
+ amountValue === 1 ? "coin" : "coins"
+ }`;
+
+ const status = item.status || "PENDING";
+
+ const details = (() => {
+ if (item.type === "GIFT" && item.metadata?.reason) {
+ switch (item.metadata.reason) {
+ case "WELCOME_BONUS":
+ return "Crédit de bienvenue";
+ default:
+ return item.metadata.reason;
+ }
+ }
+ if (item.type === "SONG" && item.songId) {
+ return `Musique ${item.songId}`;
+ }
+ if (item.type === "COINS" && item.metadata?.paymentId) {
+ return `Paiement ${item.metadata.paymentId}`;
+ }
+ return null;
+ })();
+
+ return (
+
+
+ {mapOrderType(item.type)}
+
+ {amountLabel}
+
+
+
+ {formatDate(item.createdAt)}
+
+ {mapStatus(status)}
+
+
+ {details ? {details} : null}
+ {typeof item.balanceAfter === "number" &&
+ Number.isFinite(item.balanceAfter) ? (
+
+ Solde après opération: {formatCoins(item.balanceAfter)} coins
+
+ ) : null}
+
+ );
+ }, []);
+
+ const keyExtractor = useCallback((item) => item.id, []);
+
+ const listEmptyComponent = useMemo(() => {
+ if (loading) {
+ return null;
+ }
+ if (!currentUID) {
+ return (
+
+ Connecte-toi pour consulter l'historique de tes commandes.
+
+ );
+ }
+ if (error) {
+ return {error};
+ }
+ return (
+
+ Aucune opération de coins pour le moment.
+
+ );
+ }, [loading, currentUID, error]);
+
+ return (
+
+
+ {loading ? (
+
+
+
+ ) : (
+ }
+ ListEmptyComponent={listEmptyComponent}
+ />
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingHorizontal: gutters,
+ paddingTop: gutters,
+ },
+ loaderContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ listContent: {
+ paddingBottom: gutters * 2,
+ },
+ emptyListContent: {
+ flexGrow: 1,
+ justifyContent: "center",
+ },
+ orderCard: {
+ padding: 16,
+ borderRadius: 16,
+ backgroundColor: Palette.ultraLightWhite,
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ },
+ orderHeader: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ marginBottom: 8,
+ gap: 12,
+ },
+ orderTitle: {
+ flex: 1,
+ fontSize: 16,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ color: Palette.white,
+ },
+ orderAmount: {
+ fontSize: 16,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ },
+ amountPositive: {
+ color: Palette.green,
+ },
+ amountNegative: {
+ color: Palette.red,
+ },
+ orderMetaRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginBottom: 6,
+ gap: 12,
+ },
+ orderDate: {
+ fontSize: 13,
+ color: Palette.grayMid,
+ fontFamily: FONT_FAMILY.InterRegular,
+ },
+ statusBadge: {
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 999,
+ },
+ statusApplied: {
+ backgroundColor: Palette.transparentGreen,
+ },
+ statusPending: {
+ backgroundColor: Palette.transparentOrange,
+ },
+ statusRejected: {
+ backgroundColor: Palette.transparentRed,
+ },
+ statusText: {
+ fontSize: 12,
+ fontFamily: FONT_FAMILY.InterMedium,
+ color: Palette.white,
+ },
+ orderDetails: {
+ marginBottom: 6,
+ fontSize: 14,
+ fontFamily: FONT_FAMILY.InterRegular,
+ color: Palette.white,
+ },
+ orderBalance: {
+ fontSize: 13,
+ fontFamily: FONT_FAMILY.InterRegular,
+ color: Palette.grayMid,
+ },
+ emptyText: {
+ textAlign: "center",
+ color: Palette.grayMid,
+ fontSize: 15,
+ fontFamily: FONT_FAMILY.InterRegular,
+ },
+ errorText: {
+ textAlign: "center",
+ color: Palette.red,
+ fontSize: 15,
+ fontFamily: FONT_FAMILY.InterRegular,
+ },
+});
+
+export default OrderHistory;
diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js
index 14ede65..349e551 100644
--- a/src/screens/Profile/Profile.js
+++ b/src/screens/Profile/Profile.js
@@ -13,6 +13,7 @@ import {
View,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
+import { Feather } from "@expo/vector-icons";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { useGlobal } from "reactn";
import { background, icons } from "../../assets";
@@ -33,7 +34,7 @@ import useLayoutType from "../../hooks/useLayoutType";
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
-import { push } from "../../navigation/NavigationService";
+import { navigate, push } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
@@ -83,6 +84,9 @@ const Profile = () => {
const onPressMenu = (item) => {
setSelected(item);
};
+ const handleOpenSettings = () => {
+ navigate(Routes.Settings);
+ };
// Chargement des données utilisateur si on consulte un autre profil
useEffect(() => {
@@ -340,8 +344,8 @@ const Profile = () => {
}}
rightComponent={() =>
!isWeb && isSelf ? (
- SheetManager.show("ProfileSettings")}>
-
+
+
) : null
}
@@ -363,18 +367,13 @@ const Profile = () => {
SheetManager.show("ProfileSettings")}
+ onPress={handleOpenSettings}
>
-
+
)}
diff --git a/src/screens/Profile/Settings.js b/src/screens/Profile/Settings.js
index ce7e1f9..f7c8130 100644
--- a/src/screens/Profile/Settings.js
+++ b/src/screens/Profile/Settings.js
@@ -37,6 +37,12 @@ const SETTINGS = [
navigate(Routes.Language);
},
},
+ {
+ title: "Historique de commandes",
+ action: () => {
+ navigate(Routes.OrderHistory);
+ },
+ },
{
title: "Découvrir les packs",
action: () => {
diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js
index 9ad4044..8920305 100644
--- a/src/screens/Studio/ComposeSong.js
+++ b/src/screens/Studio/ComposeSong.js
@@ -6,7 +6,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert";
-import firebase, { usersRef } from "../../config/firebase";
+import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
@@ -50,29 +50,18 @@ const ComposeSong = () => {
useState(false);
const coinBalance = useMemo(() => {
- const data = currentUserData || {};
- const candidates = [
- data?.coins,
- data?.coinBalance,
- data?.coin,
- data?.wallet?.coins,
- data?.wallet?.coinBalance,
- ];
-
- for (const value of candidates) {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string") {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
+ const value = currentUserData?.coins;
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
}
}
-
return 0;
- }, [currentUserData]);
+ }, [currentUserData?.coins]);
const formattedCoinBalance = useMemo(() => {
try {
@@ -154,24 +143,29 @@ const ComposeSong = () => {
throw new Error("Utilisateur introuvable. Merci de réessayer.");
}
- const decrement = firebase.firestore.FieldValue.increment(
- -MUSIC_GENERATION_COIN_COST,
- );
- const timestamp = firebase.firestore.FieldValue.serverTimestamp();
+ try {
+ const functionsClient = getFunctionsClient();
+ const createSongOrder =
+ functionsClient.httpsCallable("orders-createSongOrder");
- await usersRef.doc(currentUID).set(
- {
- coins: decrement,
- coinBalance: decrement,
- wallet: {
- coins: decrement,
- coinBalance: decrement,
- },
- lastCoinSpendAt: timestamp,
- lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
- },
- { merge: true },
- );
+ await createSongOrder({
+ amount: -MUSIC_GENERATION_COIN_COST,
+ songId: selectedProjectId || null,
+ source: "music_generation",
+ });
+ } catch (error) {
+ const message =
+ typeof error?.message === "string"
+ ? error.message.replace(
+ /^functions\.https\.HttpsError:\s*/iu,
+ "",
+ )
+ : null;
+ throw new Error(
+ message ||
+ "Une erreur est survenue lors de la création de la commande de crédits.",
+ );
+ }
};
const handleConfirmGeneration = async () => {
diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js
index 23a039c..9c15d2d 100644
--- a/src/screens/Studio/ComposeSong.web.js
+++ b/src/screens/Studio/ComposeSong.web.js
@@ -11,7 +11,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert";
-import firebase, { usersRef } from "../../config/firebase";
+import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
@@ -53,29 +53,18 @@ const ComposeSong = () => {
useState(false);
const coinBalance = useMemo(() => {
- const data = currentUserData || {};
- const candidates = [
- data?.coins,
- data?.coinBalance,
- data?.coin,
- data?.wallet?.coins,
- data?.wallet?.coinBalance,
- ];
-
- for (const value of candidates) {
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string") {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
+ const value = currentUserData?.coins;
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
}
}
-
return 0;
- }, [currentUserData]);
+ }, [currentUserData?.coins]);
const formattedCoinBalance = useMemo(() => {
try {
@@ -209,25 +198,30 @@ const ComposeSong = () => {
throw new Error("Utilisateur introuvable. Merci de réessayer.");
}
- const decrement = firebase.firestore.FieldValue.increment(
- -MUSIC_GENERATION_COIN_COST,
- );
- const timestamp = firebase.firestore.FieldValue.serverTimestamp();
+ try {
+ const functionsClient = getFunctionsClient();
+ const createSongOrder =
+ functionsClient.httpsCallable("orders-createSongOrder");
- await usersRef.doc(currentUID).set(
- {
- coins: decrement,
- coinBalance: decrement,
- wallet: {
- coins: decrement,
- coinBalance: decrement,
- },
- lastCoinSpendAt: timestamp,
- lastCoinSpendAmount: MUSIC_GENERATION_COIN_COST,
- },
- { merge: true },
- );
- }, [currentUID]);
+ await createSongOrder({
+ amount: -MUSIC_GENERATION_COIN_COST,
+ songId: selectedProjectId || null,
+ source: "music_generation",
+ });
+ } catch (error) {
+ const message =
+ typeof error?.message === "string"
+ ? error.message.replace(
+ /^functions\.https\.HttpsError:\s*/iu,
+ "",
+ )
+ : null;
+ throw new Error(
+ message ||
+ "Une erreur est survenue lors de la création de la commande de crédits.",
+ );
+ }
+ }, [currentUID, selectedProjectId]);
const handleConfirmGeneration = useCallback(async () => {
if (isProcessingConfirmation) return;
diff --git a/src/screens/Writing/Goals.js b/src/screens/Writing/Goals.js
index f2633b7..895cfdf 100644
--- a/src/screens/Writing/Goals.js
+++ b/src/screens/Writing/Goals.js
@@ -1,7 +1,16 @@
import React, { useMemo, useState } from "react";
-import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
+import {
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { BaseButton } from "../../components/Button";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import ListSelection from "../../components/ListSelection/ListSelection";
+import Overlay from "../../components/Overlay";
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
import { strings } from "../../constants/strings";
import { Palette } from "../../styles";
@@ -15,10 +24,22 @@ const Goals = ({
otherObjective,
setOtherObjective,
}) => {
+ const normalizedOtherObjective =
+ typeof otherObjective === "string" ? otherObjective : "";
+
const [internalSelected, setInternalSelected] = useState(null);
+ const [otherObjectiveModalVisible, setOtherObjectiveModalVisible] =
+ useState(false);
+ const [previousSelection, setPreviousSelection] = useState(null);
+ const [previousOtherObjective, setPreviousOtherObjective] = useState(
+ normalizedOtherObjective
+ );
const selected = selectedProp ?? internalSelected;
- const setSelected = setSelectedProp ?? setInternalSelected;
+ const setSelectedBase = setSelectedProp ?? setInternalSelected;
+
+ const trimmedOtherObjective = normalizedOtherObjective.trim();
+ const hasOtherObjectiveValue = trimmedOtherObjective.length > 0;
const goalsOptions = useMemo(() => GOALS ?? [], []);
@@ -27,47 +48,145 @@ const Goals = ({
const trimmed = value?.trim() ?? "";
if (trimmed.length) {
if (selected !== OTHER_OBJECTIVE_OPTION) {
- setSelected(OTHER_OBJECTIVE_OPTION);
+ setSelectedBase(OTHER_OBJECTIVE_OPTION);
}
} else if (selected === OTHER_OBJECTIVE_OPTION) {
- setSelected(null);
+ setSelectedBase(null);
+ setPreviousSelection(null);
}
};
- // Selection handled by ListSelection
+ const handleGoalSelect = (value) => {
+ if (value === OTHER_OBJECTIVE_OPTION) {
+ setPreviousSelection(selected ?? null);
+ setPreviousOtherObjective(normalizedOtherObjective);
+ setSelectedBase(value);
+ setOtherObjectiveModalVisible(true);
+ return;
+ }
+ setSelectedBase(value);
+ setOtherObjectiveModalVisible(false);
+ setPreviousSelection(null);
+ };
+
+ const handleOpenOtherObjectiveModal = () => {
+ setPreviousSelection(selected ?? null);
+ setPreviousOtherObjective(normalizedOtherObjective);
+ setOtherObjectiveModalVisible(true);
+ };
+
+ const handleCancelOtherObjective = () => {
+ setOtherObjectiveModalVisible(false);
+ setOtherObjective?.(previousOtherObjective);
+ if (selected === OTHER_OBJECTIVE_OPTION) {
+ setSelectedBase(previousSelection ?? null);
+ }
+ setPreviousSelection(null);
+ };
+
+ const handleConfirmOtherObjective = () => {
+ if (!hasOtherObjectiveValue) {
+ const fallback =
+ previousSelection && previousSelection !== OTHER_OBJECTIVE_OPTION
+ ? previousSelection
+ : null;
+ setOtherObjective?.(previousOtherObjective);
+ setSelectedBase(fallback);
+ setOtherObjectiveModalVisible(false);
+ setPreviousSelection(null);
+ return;
+ }
+ if (selected !== OTHER_OBJECTIVE_OPTION) {
+ setSelectedBase(OTHER_OBJECTIVE_OPTION);
+ }
+ setOtherObjectiveModalVisible(false);
+ setPreviousSelection(OTHER_OBJECTIVE_OPTION);
+ };
return (
-
-
-
-
-
- {strings.writing.steps.contextExamples}
-
-
-
-
+
+
+
-
-
-
-
+
+
+ {strings.writing.steps.contextExamples}
+
+
+
+
+
+
+ {selected === OTHER_OBJECTIVE_OPTION ? (
+
+
+
+ {strings.writing.labels.otherObjective}
+
+
+ {hasOtherObjectiveValue
+ ? trimmedOtherObjective
+ : strings.writing.labels.otherObjectivePlaceholder}
+
+
+
+
+ {hasOtherObjectiveValue ? "Modifier" : "Ajouter"}
+
+
+
+ ) : null}
+
+
+
+
+ {strings.writing.labels.otherObjective}
+
+
+
+
+
+
+
+
+ >
);
};
@@ -112,4 +231,62 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
},
+ otherObjectiveSummary: {
+ backgroundColor: Palette.ultraLightWhite,
+ borderRadius: 12,
+ padding: 12,
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 12,
+ },
+ summaryLabel: {
+ fontSize: 14,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterMedium,
+ },
+ summaryValue: {
+ fontSize: 14,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterRegular,
+ lineHeight: 20,
+ },
+ summaryPlaceholder: {
+ color: Palette.grayMid,
+ fontStyle: "italic",
+ },
+ summaryButton: {
+ alignSelf: "flex-start",
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 999,
+ borderWidth: 1,
+ borderColor: Palette.white,
+ backgroundColor: "#FFFFFF10",
+ },
+ summaryButtonText: {
+ fontSize: 12,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterMedium,
+ },
+ modalContent: {
+ width: "90%",
+ maxWidth: 420,
+ padding: 20,
+ borderRadius: 16,
+ backgroundColor: Palette.darkPurple,
+ gap: 16,
+ },
+ modalTitle: {
+ fontSize: 18,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ textAlign: "center",
+ },
+ modalActions: {
+ flexDirection: "row",
+ gap: 12,
+ },
+ modalActionButton: {
+ flex: 1,
+ },
});
diff --git a/src/screens/Writing/SongStyle.js b/src/screens/Writing/SongStyle.js
index e559fc2..f24787b 100644
--- a/src/screens/Writing/SongStyle.js
+++ b/src/screens/Writing/SongStyle.js
@@ -1,4 +1,4 @@
-import { View, StyleSheet } from "react-native";
+import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import React, { useMemo, useState } from "react";
import CreateLyricsHeader from "./components/CreateLyricsHeader";
import { OTHER_STYLE_OPTION, SONG_STYLE } from "../../data/data";
@@ -7,6 +7,8 @@ import CustomInput from "./components/CustomInput";
import { FONT_FAMILY } from "../../styles/Fonts";
import ItemContainer from "../../components/ItemContainer/ItemContainer";
import ListSelection from "../../components/ListSelection/ListSelection";
+import { BaseButton } from "../../components/Button";
+import Overlay from "../../components/Overlay";
const SongStyle = ({
selected: selectedProp,
@@ -14,9 +16,21 @@ const SongStyle = ({
otherStyle,
setOtherStyle,
}) => {
+ const normalizedOtherStyle =
+ typeof otherStyle === "string" ? otherStyle : "";
+
const [internalSelected, setInternalSelected] = useState(null);
+ const [otherStyleModalVisible, setOtherStyleModalVisible] = useState(false);
+ const [previousSelection, setPreviousSelection] = useState(null);
+ const [previousOtherStyle, setPreviousOtherStyle] = useState(
+ normalizedOtherStyle
+ );
+
const selected = selectedProp ?? internalSelected;
- const setSelected = setSelectedProp ?? setInternalSelected;
+ const setSelectedBase = setSelectedProp ?? setInternalSelected;
+
+ const trimmedOtherStyle = normalizedOtherStyle.trim();
+ const hasOtherStyleValue = trimmedOtherStyle.length > 0;
const songStyleOptions = useMemo(() => SONG_STYLE ?? [], []);
@@ -25,40 +39,137 @@ const SongStyle = ({
const trimmed = value?.trim() ?? "";
if (trimmed.length) {
if (selected !== OTHER_STYLE_OPTION) {
- setSelected(OTHER_STYLE_OPTION);
+ setSelectedBase(OTHER_STYLE_OPTION);
}
} else if (selected === OTHER_STYLE_OPTION) {
- setSelected(null);
+ setSelectedBase(null);
+ setPreviousSelection(null);
}
};
- // Selection handled by ListSelection
+ const handleStyleSelect = (value) => {
+ if (value === OTHER_STYLE_OPTION) {
+ setPreviousSelection(selected ?? null);
+ setPreviousOtherStyle(normalizedOtherStyle);
+ setSelectedBase(value);
+ setOtherStyleModalVisible(true);
+ return;
+ }
+ setSelectedBase(value);
+ setOtherStyleModalVisible(false);
+ setPreviousSelection(null);
+ };
+
+ const handleOpenOtherStyleModal = () => {
+ setPreviousSelection(selected ?? null);
+ setPreviousOtherStyle(normalizedOtherStyle);
+ setOtherStyleModalVisible(true);
+ };
+
+ const handleCancelOtherStyle = () => {
+ setOtherStyleModalVisible(false);
+ setOtherStyle?.(previousOtherStyle);
+ if (selected === OTHER_STYLE_OPTION) {
+ setSelectedBase(previousSelection ?? null);
+ }
+ setPreviousSelection(null);
+ };
+
+ const handleConfirmOtherStyle = () => {
+ if (!hasOtherStyleValue) {
+ const fallback =
+ previousSelection && previousSelection !== OTHER_STYLE_OPTION
+ ? previousSelection
+ : null;
+ setOtherStyle?.(previousOtherStyle);
+ setSelectedBase(fallback);
+ setOtherStyleModalVisible(false);
+ setPreviousSelection(null);
+ return;
+ }
+ if (selected !== OTHER_STYLE_OPTION) {
+ setSelectedBase(OTHER_STYLE_OPTION);
+ }
+ setOtherStyleModalVisible(false);
+ setPreviousSelection(OTHER_STYLE_OPTION);
+ };
return (
-
-
-
-
-
+
+
+
-
+
+
+
+
+ {selected === OTHER_STYLE_OPTION ? (
+
+
+
+ Tu as un autre style de chanson ?
+
+
+ {hasOtherStyleValue ? trimmedOtherStyle : "Décrire le style"}
+
+
+
+
+ {hasOtherStyleValue ? "Modifier" : "Ajouter"}
+
+
+
+ ) : null}
-
-
+
+
+
+ Tu as un autre style de chanson ?
+
+
+
+
+
+
+
+
+ >
);
};
@@ -90,4 +201,62 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
},
+ otherStyleSummary: {
+ backgroundColor: Palette.ultraLightWhite,
+ borderRadius: 12,
+ padding: 12,
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 12,
+ },
+ summaryLabel: {
+ fontSize: 14,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterMedium,
+ },
+ summaryValue: {
+ fontSize: 14,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterRegular,
+ lineHeight: 20,
+ },
+ summaryPlaceholder: {
+ color: Palette.grayMid,
+ fontStyle: "italic",
+ },
+ summaryButton: {
+ alignSelf: "flex-start",
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 999,
+ borderWidth: 1,
+ borderColor: Palette.white,
+ backgroundColor: "#FFFFFF10",
+ },
+ summaryButtonText: {
+ fontSize: 12,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterMedium,
+ },
+ modalContent: {
+ width: "90%",
+ maxWidth: 420,
+ padding: 20,
+ borderRadius: 16,
+ backgroundColor: Palette.darkPurple,
+ gap: 16,
+ },
+ modalTitle: {
+ fontSize: 18,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ textAlign: "center",
+ },
+ modalActions: {
+ flexDirection: "row",
+ gap: 12,
+ },
+ modalActionButton: {
+ flex: 1,
+ },
});