From a7371323c4d5c08a65a2d71642edc297bbe7c812 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Wed, 5 Nov 2025 11:18:43 +0100 Subject: [PATCH] add orders collecrtion for coins and subscriptions page --- functions/index.js | 1 + functions/package-lock.json | 428 +++++++++++++++++++++++++- functions/src/helpers/orders.js | 90 ++++++ functions/src/orders.js | 190 ++++++++++++ functions/src/subscription.js | 77 +++-- functions/src/users.js | 17 +- src/constants/strings.js | 8 +- src/layouts/Page.js | 29 +- src/navigation/MainStack.js | 6 + src/navigation/Routes.js | 1 + src/screens/Profile/OrderHistory.js | 351 +++++++++++++++++++++ src/screens/Profile/Profile.js | 25 +- src/screens/Profile/Settings.js | 6 + src/screens/Studio/ComposeSong.js | 70 ++--- src/screens/Studio/ComposeSong.web.js | 72 ++--- src/screens/Writing/Goals.js | 247 ++++++++++++--- src/screens/Writing/SongStyle.js | 223 ++++++++++++-- 17 files changed, 1613 insertions(+), 228 deletions(-) create mode 100644 functions/src/helpers/orders.js create mode 100644 functions/src/orders.js create mode 100644 src/screens/Profile/OrderHistory.js 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 de757e2..2af33e2 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -48,29 +48,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, + }, });