From ffa465001dfe095eaf18e95bec38a5141b4affde Mon Sep 17 00:00:00 2001 From: blankkirigaya Date: Tue, 2 Jun 2026 16:11:00 +0530 Subject: [PATCH 1/2] fixed problems with mobile side and fixed up issues with dependencies --- apps/mobile-new/RNTest | 1 + apps/mobile/adb | 0 apps/mobile/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 31 +++++ .../java/com/devcard/app/MainApplication.kt | 32 ++++-- apps/mobile/android/gradle.properties | 2 +- apps/mobile/metro.config.js | 52 ++------- apps/mobile/package.json | 4 +- pnpm-lock.yaml | 108 ++++++------------ 9 files changed, 101 insertions(+), 131 deletions(-) create mode 160000 apps/mobile-new/RNTest create mode 100644 apps/mobile/adb diff --git a/apps/mobile-new/RNTest b/apps/mobile-new/RNTest new file mode 160000 index 00000000..660a81c2 --- /dev/null +++ b/apps/mobile-new/RNTest @@ -0,0 +1 @@ +Subproject commit 660a81c2af3e27afe3030b5e28bf81a8f351f69d diff --git a/apps/mobile/adb b/apps/mobile/adb new file mode 100644 index 00000000..e69de29b diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 40b74821..41cbd245 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -22,7 +22,7 @@ react { // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] - + debuggableVariants = ["debug"] /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. // nodeExecutableAndArgs = ["node"] diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index e69de29b..b8e89114 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt index a7b8f9f2..a8bffaa8 100644 --- a/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt +++ b/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt @@ -4,24 +4,34 @@ import android.app.Application import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost -import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative class MainApplication : Application(), ReactApplication { - override val reactHost: ReactHost by lazy { - getDefaultReactHost( - context = applicationContext, - packageList = - PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) - }, - ) - } + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + + override fun getPackages(): List = + PackageList(this).packages + + override fun getJSMainModuleName(): String = "index" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + + override val reactHost: ReactHost + get() = getDefaultReactHost(applicationContext, reactNativeHost) override fun onCreate() { super.onCreate() loadReactNative(this) } } + diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties index 183b46a8..1dca3a44 100644 --- a/apps/mobile/android/gradle.properties +++ b/apps/mobile/android/gradle.properties @@ -16,6 +16,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true +newArchEnabled=false # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK @@ -32,7 +33,6 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=false # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 0d21ee3a..23051259 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,52 +1,20 @@ const { getDefaultConfig } = require('@react-native/metro-config'); const path = require('path'); -// Monorepo root const projectRoot = __dirname; const monorepoRoot = path.resolve(projectRoot, '../..'); -/** - * Metro configuration for React Native monorepo - */ -module.exports = (async () => { - const config = await getDefaultConfig(projectRoot); +const config = getDefaultConfig(projectRoot); - config.watchFolders = [monorepoRoot]; - config.resolver = config.resolver || {}; - config.resolver.nodeModulesPaths = [ - path.resolve(projectRoot, 'node_modules'), - path.resolve(monorepoRoot, 'node_modules'), - ]; - config.resolver.disableHierarchicalLookup = false; +config.watchFolders = [ + path.resolve(monorepoRoot, 'packages'), +]; - const pinnedModules = { - react: path.resolve(projectRoot, 'node_modules/react'), - 'react-native': path.resolve(projectRoot, 'node_modules/react-native'), - 'react-native-reanimated': path.resolve( - projectRoot, - 'node_modules/react-native-reanimated' - ), - 'react-native-worklets': path.resolve( - projectRoot, - 'node_modules/react-native-worklets' - ), - 'react-native-gesture-handler': path.resolve( - projectRoot, - 'node_modules/react-native-gesture-handler' - ), - }; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), +]; - config.resolver.extraNodeModules = pinnedModules; - config.resolver.resolveRequest = (context, moduleName, platform) => { - for (const [name, modulePath] of Object.entries(pinnedModules)) { - if (moduleName === name || moduleName.startsWith(`${name}/`)) { - const target = path.join(modulePath, moduleName.slice(name.length)); - return context.resolveRequest(context, target, platform); - } - } +config.resolver.disableHierarchicalLookup = true; - return context.resolveRequest(context, moduleName, platform); - }; - - return config; -})(); +module.exports = config; \ No newline at end of file diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4cae19e2..ffc43f4f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -24,7 +24,7 @@ "react-native-camera-kit": "^14.0.0", "react-native-gesture-handler": "^2.28.0", "react-native-qrcode-svg": "^6.3.0", - "react-native-reanimated": "^3.16.7", + "react-native-reanimated": "^4.4.0", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.0.0", "react-native-svg": "^15.0.0", @@ -32,7 +32,7 @@ "react-native-view-shot": "^5.1.0", "react-native-web": "^0.21.2", "react-native-webview": "^13.0.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.9.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08a8f46..2f678b2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: version: link:../../packages/shared '@gorhom/bottom-sheet': specifier: ^5.0.5 - version: 5.2.14(@types/react-native@0.70.19)(@types/react@19.2.15)(react-native-gesture-handler@2.31.2(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + version: 5.2.14(zod5vefarwkien3rkwwy24c3fq) '@react-native-async-storage/async-storage': specifier: ^2.1.0 version: 2.2.0(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3)) @@ -148,8 +148,8 @@ importers: specifier: ^6.3.0 version: 6.3.21(react-native-svg@15.15.5(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) react-native-reanimated: - specifier: ^3.16.7 - version: 3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + specifier: ^4.4.0 + version: 4.4.0(react-native-worklets@0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) react-native-safe-area-context: specifier: ^5.5.2 version: 5.8.0(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) @@ -172,8 +172,8 @@ importers: specifier: ^13.0.0 version: 13.16.1(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) react-native-worklets: - specifier: 0.5.1 - version: 0.5.1(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + specifier: 0.9.1 + version: 0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -1950,79 +1950,66 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -2295,61 +2282,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -4906,8 +4883,8 @@ packages: react: '*' react-native: '*' - react-native-is-edge-to-edge@1.1.7: - resolution: {integrity: sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==} + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} peerDependencies: react: '*' react-native: '*' @@ -4919,12 +4896,12 @@ packages: react-native: '>=0.63.4' react-native-svg: '>=14.0.0' - react-native-reanimated@3.19.5: - resolution: {integrity: sha512-bd4AwIkBAaY4BjrgpSoKjEaRG/tXD756F5nGuiH5IMBSKN8tRdUEA8hWZCyIo/R6/kha/tVSoCqodVUACh7ZWw==} + react-native-reanimated@4.4.0: + resolution: {integrity: sha512-0XbC1SpF3JZOz5QfmTEx3vt8VkmkTlS05CBIOKEg5q5ZSNlGtlacntlhj5CrfZlN1ciHAeoliJouTC2cLGKbDA==} peerDependencies: - '@babel/core': ^7.0.0-0 react: '*' - react-native: '*' + react-native: 0.83 - 0.86 + react-native-worklets: 0.9.x react-native-safe-area-context@5.8.0: resolution: {integrity: sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==} @@ -4968,12 +4945,13 @@ packages: react: '*' react-native: '*' - react-native-worklets@0.5.1: - resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==} + react-native-worklets@0.9.1: + resolution: {integrity: sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '*' + '@react-native/metro-config': '*' react: '*' - react-native: '*' + react-native: 0.83 - 0.86 react-native@0.84.1: resolution: {integrity: sha512-0PjxOyXRu3tZ8EobabxSukvhKje2HJbsZikR0U+pvS0pYZza2hXKjcSBiBdFN4h9D0S3v6a8kkrDK6WTRKMwzg==} @@ -5169,11 +5147,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.1: resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} engines: {node: '>=10'} @@ -5488,8 +5461,8 @@ packages: resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} engines: {node: '>=18'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@1.1.1: @@ -7151,14 +7124,14 @@ snapshots: fastq: 1.20.1 glob: 11.1.0 - '@gorhom/bottom-sheet@5.2.14(@types/react-native@0.70.19)(@types/react@19.2.15)(react-native-gesture-handler@2.31.2(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native-reanimated@3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3)': + '@gorhom/bottom-sheet@5.2.14(zod5vefarwkien3rkwwy24c3fq)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) invariant: 2.2.4 react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) react-native-gesture-handler: 2.31.2(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) - react-native-reanimated: 3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-reanimated: 4.4.0(react-native-worklets@0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) optionalDependencies: '@types/react': 19.2.15 '@types/react-native': 0.70.19 @@ -7662,7 +7635,7 @@ snapshots: hermes-parser: 0.32.0 invariant: 2.2.4 nullthrows: 1.1.1 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 yargs: 17.7.2 '@react-native/community-cli-plugin@0.84.1(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))': @@ -8185,7 +8158,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.1 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -9348,7 +9321,7 @@ snapshots: get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash-x: 0.2.0 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.4.1(jiti@2.7.0)) @@ -11410,7 +11383,7 @@ snapshots: react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) - react-native-is-edge-to-edge@1.1.7(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) @@ -11424,25 +11397,13 @@ snapshots: react-native-svg: 15.15.5(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) text-encoding: 0.7.0 - react-native-reanimated@3.19.5(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + react-native-reanimated@4.4.0(react-native-worklets@0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - convert-source-map: 2.0.0 - invariant: 2.2.4 react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) - react-native-is-edge-to-edge: 1.1.7(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) - transitivePeerDependencies: - - supports-color + react-native-is-edge-to-edge: 1.3.1(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + react-native-worklets: 0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3) + semver: 7.8.1 react-native-safe-area-context@5.8.0(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): dependencies: @@ -11496,7 +11457,7 @@ snapshots: react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) - react-native-worklets@0.5.1(@babel/core@7.29.7)(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): + react-native-worklets@0.9.1(@babel/core@7.29.7)(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -11508,10 +11469,11 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@react-native/metro-config': 0.84.1(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 react-native: 0.84.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.7))(@types/react@19.2.15)(react@19.2.3) - semver: 7.7.2 + semver: 7.8.1 transitivePeerDependencies: - supports-color @@ -11549,7 +11511,7 @@ snapshots: scheduler: 0.27.0 semver: 7.8.1 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 ws: 7.5.11 yargs: 17.7.2 @@ -11767,8 +11729,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.2: {} - semver@7.8.1: {} send@0.19.2: @@ -12122,7 +12082,7 @@ snapshots: tinyexec@1.2.3: {} - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -12346,7 +12306,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.15 rollup: 4.60.4 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.19.19 fsevents: 2.3.3 From c602ee0d9361c7e163f2390edd90a900d9c5b498 Mon Sep 17 00:00:00 2001 From: blankkirigaya Date: Mon, 15 Jun 2026 20:17:56 +0530 Subject: [PATCH 2/2] feat: integrate card APIs in mobile --- .../20260615051500_card_sharing/migration.sql | 25 + apps/backend/prisma/schema.prisma | 17 +- apps/backend/src/routes/auth.ts | 10 +- apps/backend/src/routes/cards.ts | 195 ++-- apps/backend/src/services/cardService.ts | 186 +++- apps/backend/src/utils/validators.ts | 14 +- apps/mobile/src/config.ts | 12 + apps/mobile/src/screens/CardsScreen.tsx | 858 +++++++++++++----- apps/mobile/src/services/api.ts | 284 +++++- apps/mobile/src/services/backendAuth.ts | 36 + packages/shared/src/__tests__/cards.test.ts | 2 +- .../src/__tests__/platforms-url.test.ts | 6 - packages/shared/src/cards.ts | 2 +- packages/shared/src/index.ts | 2 +- packages/shared/src/platforms.test.ts | 21 +- packages/shared/src/platforms.ts | 31 +- packages/shared/src/types.ts | 22 +- 17 files changed, 1315 insertions(+), 408 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260615051500_card_sharing/migration.sql create mode 100644 apps/mobile/src/services/backendAuth.ts diff --git a/apps/backend/prisma/migrations/20260615051500_card_sharing/migration.sql b/apps/backend/prisma/migrations/20260615051500_card_sharing/migration.sql new file mode 100644 index 00000000..52236368 --- /dev/null +++ b/apps/backend/prisma/migrations/20260615051500_card_sharing/migration.sql @@ -0,0 +1,25 @@ +CREATE TYPE "CardVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE'); + +ALTER TABLE "cards" + ADD COLUMN "description" TEXT, + ADD COLUMN "slug" TEXT, + ADD COLUMN "visibility" "CardVisibility" NOT NULL DEFAULT 'PUBLIC', + ADD COLUMN "qr_enabled" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "view_count" INTEGER NOT NULL DEFAULT 0; + +UPDATE "cards" +SET "slug" = concat( + trim(both '-' from regexp_replace(lower("title"), '[^a-z0-9]+', '-', 'g')), + '-', + substring("id" from 1 for 8) +) +WHERE "slug" IS NULL; + +ALTER TABLE "cards" + ALTER COLUMN "slug" SET NOT NULL; + +CREATE UNIQUE INDEX "cards_slug_key" ON "cards"("slug"); +CREATE INDEX "cards_user_id_idx" ON "cards"("user_id"); +CREATE INDEX "cards_view_count_idx" ON "cards"("view_count"); +CREATE INDEX "card_views_card_id_idx" ON "card_views"("card_id"); +CREATE INDEX "card_views_owner_id_idx" ON "card_views"("owner_id"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7017ca81..d6b157b8 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -54,10 +54,21 @@ model PlatformLink { @@map("platform_links") } +enum CardVisibility { + PUBLIC + UNLISTED + PRIVATE +} + model Card { id String @id @default(uuid()) userId String @map("user_id") title String + description String? + slug String @unique + visibility CardVisibility @default(PUBLIC) + qrEnabled Boolean @default(true) @map("qr_enabled") + viewCount Int @default(0) @map("view_count") isDefault Boolean @default(false) @map("is_default") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -67,6 +78,8 @@ model Card { views CardView[] @@map("cards") + @@index([userId]) + @@index([viewCount]) } model CardLink { @@ -114,6 +127,8 @@ model CardView { viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) @@map("card_views") + @@index([cardId]) + @@index([ownerId]) } model FollowLog { @@ -194,4 +209,4 @@ model TeamMember{ @@unique([userId, teamId]) @@index([userId]) @@map("team_members") -} \ No newline at end of file +} diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..60b04867 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -57,8 +57,9 @@ export async function authRoutes(app: FastifyInstance) { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; + const isMobileOAuth = state?.startsWith('mobile_'); const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); @@ -130,7 +131,7 @@ export async function authRoutes(app: FastifyInstance) { const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); - if (request.query.state?.startsWith('mobile_')) { + if (isMobileOAuth) { const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; return reply.redirect(`${mobileRedirect}#token=${token}`); } @@ -183,8 +184,9 @@ export async function authRoutes(app: FastifyInstance) { app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { const { code, state } = request.query; + const isMobileOAuth = state?.startsWith('mobile_'); const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) { return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); @@ -232,7 +234,7 @@ export async function authRoutes(app: FastifyInstance) { const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); - if (request.query.state?.startsWith('mobile_')) { + if (isMobileOAuth) { const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; return reply.redirect(`${mobileRedirect}#token=${token}`); } diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 32fe835c..fd8a6c79 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,138 +1,161 @@ -import { handleDbError } from '../utils/error.util.js'; -import { createCardSchema, updateCardSchema } from '../utils/validators.js'; -import * as cardService from '../services/cardService' +import { createHash } from 'node:crypto' +import { handleDbError } from '../utils/error.util.js' +import { addPlatformLinkSchema, createCardSchema, updateCardSchema } from '../utils/validators.js' +import * as cardService from '../services/cardService.js' -import type { Card } from '@devcard/shared'; -import type { Prisma } from '@prisma/client'; -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; - - -interface CreateCardBody { - title: string; - linkIds: string[]; -} - -interface UpdateCardBody { - title?: string; - linkIds?: string[]; -} +import type { CardResponse, CreateCardBody, UpdateCardBody } from '../services/cardService.js' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' interface CardParams { - id: string; + id: string } -interface PlatformLink { - id: string; - userId: string; - platform: string; - username: string; - url: string; - displayOrder: number; - createdAt: Date; +function getUserId(request: FastifyRequest): string { + return (request.user as { id: string }).id } -interface CardLinkWithPlatform { - id: string; - cardId: string; - platformLinkId: string; - displayOrder: number; - platformLink: PlatformLink; -} - -interface CardWithLinks { - id: string; - userId: string; - title: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; - cardLinks: CardLinkWithPlatform[]; +function hashIp(ip: string): string { + return createHash('sha256').update(ip).digest('hex') } export async function cardRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { - const server = request.server as any; + const server = request.server as any if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }); - - // ─── List Cards ─── + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } + }) - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { - const userId = (request.user as { id: string }).id; + app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { try { - return await cardService.listCards(app, userId) + return await cardService.listCards(app, getUserId(request)) } catch (error) { return handleDbError(error, request, reply) } - }); - - // ─── Create Card ─── - - app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { - const userId = (request.user as { id: string }).id; - const parsed = createCardSchema.safeParse(request.body); + }) - if (!parsed.success) { - return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); - } + app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { + const parsed = createCardSchema.safeParse(request.body) + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) try { - const card = await cardService.createCard(app, userId, parsed.data) + const card = await cardService.createCard(app, getUserId(request), parsed.data) return reply.status(201).send(card) } catch (error: any) { if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) return handleDbError(error, request, reply) } - }); - - // ─── Update Card ─── + }) - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { - const userId = (request.user as { id: string }).id; - const { id } = request.params; + async function updateCard(request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise { + const parsed = updateCardSchema.safeParse(request.body) + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) try { - const parsed = updateCardSchema.safeParse(request.body) - if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) - const updated = await cardService.updateCard(app, userId, id, parsed.data) + const updated = await cardService.updateCard(app, getUserId(request), request.params.id, parsed.data) if (!updated) return reply.status(404).send({ error: 'Card not found' }) - return updated + return reply.status(200).send(updated) } catch (error: any) { if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) return handleDbError(error, request, reply) } - }); - - // ─── Delete Card ─── + } - app.delete('/:id', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { - const userId = (request.user as { id: string }).id; - const { id } = request.params; + app.put('/:id', updateCard) + app.put('/:id/update', updateCard) + async function deleteCard(request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise { try { - const res = await cardService.deleteCard(app, userId, id) + const res = await cardService.deleteCard(app, getUserId(request), request.params.id) if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' }) if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' }) return reply.status(204).send() } catch (error) { return handleDbError(error, request, reply) } - }); + } - // ─── Set Default Card ─── + app.delete('/:id', deleteCard) + app.delete('/:id/delete', deleteCard) app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { - const userId = (request.user as { id: string }).id; - const { id } = request.params; - try { - const resp = await cardService.setDefaultCard(app, userId, id) + const resp = await cardService.setDefaultCard(app, getUserId(request), request.params.id) if (!resp) return reply.status(404).send({ error: 'Card not found' }) - return resp + return reply.status(200).send(resp) } catch (error) { return handleDbError(error, request, reply) } - }); + }) + + app.put('/:id/platform-link', async (request: FastifyRequest<{ Params: CardParams; Body: { platformLinkId: string } }>, reply: FastifyReply): Promise => { + const parsed = addPlatformLinkSchema.safeParse(request.body) + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) + + try { + await cardService.addPlatformLink(app, getUserId(request), request.params.id, parsed.data.platformLinkId) + return reply.status(200).send({ message: 'Platform link added successfully' }) + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) + if (error?.code === 'PLATFORM_LINK_NOT_FOUND') return reply.status(403).send({ error: error.message }) + if (error?.code === 'LINK_ALREADY_EXISTS') return reply.status(409).send({ error: error.message }) + return handleDbError(error, request, reply) + } + }) + + app.post('/:id/share', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { + try { + return reply.status(200).send(await cardService.shareCard(app, getUserId(request), request.params.id)) + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) + if (error?.code === 'CARD_PRIVATE') return reply.status(403).send({ error: error.message }) + return handleDbError(error, request, reply) + } + }) + + app.get('/share/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + try { + const card = await cardService.getSharedCard(app, request.params.slug) + const viewerId = getUserId(request) + + await app.prisma.$transaction([ + app.prisma.card.update({ where: { id: card.id }, data: { viewCount: { increment: 1 } } }), + app.prisma.cardView.create({ + data: { + cardId: card.id, + ownerId: card.userId, + viewerId, + source: 'link', + viewerIp: hashIp(request.ip), + viewerAgent: request.headers['user-agent'] ?? 'unknown', + }, + }), + ]) + + return reply.status(200).send(card) + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) + return handleDbError(error, request, reply) + } + }) + + app.get('/:id/qr', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { + try { + const qrImage = await cardService.generateCardQr(app, getUserId(request), request.params.id) + return reply.type('image/png').send(qrImage) + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) + if (error?.code === 'CARD_PRIVATE' || error?.code === 'QR_DISABLED') return reply.status(403).send({ error: error.message }) + return handleDbError(error, request, reply) + } + }) + + app.get('/:id/analytics', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { + try { + return reply.status(200).send(await cardService.cardAnalytics(app, getUserId(request), request.params.id)) + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) + return handleDbError(error, request, reply) + } + }) } diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..911a8ff8 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,7 +1,67 @@ +import { CardVisibility, Prisma } from '@prisma/client' +import { generateUniqueSlug } from '../utils/slug.js' +import { generateQRBuffer } from '../utils/qr.js' + import type { FastifyInstance } from 'fastify' -import type { Prisma } from '@prisma/client' -export async function listCards(app: FastifyInstance, userId: string) { +type CardLinkWithPlatform = { platformLink: unknown } +type RawCard = { + id: string + userId: string + title: string + description: string | null + slug: string + visibility: CardVisibility + qrEnabled: boolean + viewCount: number + isDefault: boolean + cardLinks: CardLinkWithPlatform[] +} + +export type CardResponse = { + id: string + userId: string + title: string + description: string | null + slug: string + visibility: CardVisibility + qrEnabled: boolean + viewCount: number + isDefault: boolean + links: unknown[] +} + +export type CreateCardBody = { + title: string + linkIds: string[] + description?: string + visibility?: CardVisibility +} + +export type UpdateCardBody = { + title?: string + linkIds?: string[] + description?: string + visibility?: CardVisibility + qrEnabled?: boolean +} + +function mapCard(card: RawCard): CardResponse { + return { + id: card.id, + userId: card.userId, + title: card.title, + description: card.description, + slug: card.slug, + visibility: card.visibility, + qrEnabled: card.qrEnabled, + viewCount: card.viewCount, + isDefault: card.isDefault, + links: card.cardLinks.map(cl => cl.platformLink), + } +} + +export async function listCards(app: FastifyInstance, userId: string): Promise { const cards = await app.prisma.card.findMany({ where: { userId }, take: 50, @@ -9,55 +69,65 @@ export async function listCards(app: FastifyInstance, userId: string) { orderBy: { createdAt: 'asc' }, }) - return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) + return cards.map(card => mapCard(card as RawCard)) } -export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) - } +export async function createCard(app: FastifyInstance, userId: string, body: CreateCardBody): Promise { + const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) + if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) - const cardCount = await app.prisma.card.count({ where: { userId } }) + const slug = await generateUniqueSlug(body.title, async value => { + const existing = await app.prisma.card.findUnique({ where: { slug: value }, select: { id: true } }) + return Boolean(existing) + }) + const cardCount = await app.prisma.card.count({ where: { userId } }) const card = await app.prisma.card.create({ data: { userId, title: body.title, + description: body.description, + slug, + visibility: body.visibility ?? CardVisibility.PUBLIC, isDefault: cardCount === 0, cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, }) - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } + return mapCard(card as RawCard) } -export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) +export async function updateCard(app: FastifyInstance, userId: string, id: string, body: UpdateCardBody): Promise { + const existing = await app.prisma.card.findFirst({ where: { id, userId }, select: { id: true } }) if (!existing) return null - if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + if (body.linkIds) { + const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) + if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) } - if (body.linkIds) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) - } + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await tx.card.update({ + where: { id }, + data: { + title: body.title, + description: body.description, + visibility: body.visibility, + qrEnabled: body.qrEnabled, + }, + }) - const linkIds = body.linkIds - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (body.linkIds) { await tx.cardLink.deleteMany({ where: { cardId: id } }) - if (linkIds.length > 0) { - await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) + if (body.linkIds.length > 0) { + await tx.cardLink.createMany({ data: body.linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) } - }) - } + } + }) const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } + return updated ? mapCard(updated as RawCard) : null } export async function deleteCard(app: FastifyInstance, userId: string, id: string) { @@ -70,9 +140,7 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin if (existing.isDefault) { const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) - if (oldestRemainingCard) { - await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) - } + if (oldestRemainingCard) await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) } await tx.card.delete({ where: { id } }) @@ -91,3 +159,63 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s return { message: 'Default card updated' } } + +export async function addPlatformLink(app: FastifyInstance, userId: string, id: string, platformLinkId: string) { + const [card, platformLink, existingLink, lastLink] = await Promise.all([ + app.prisma.card.findFirst({ where: { id, userId }, select: { id: true } }), + app.prisma.platformLink.findFirst({ where: { id: platformLinkId, userId }, select: { id: true } }), + app.prisma.cardLink.findUnique({ where: { cardId_platformLinkId: { cardId: id, platformLinkId } } }), + app.prisma.cardLink.findFirst({ where: { cardId: id }, orderBy: { displayOrder: 'desc' }, select: { displayOrder: true } }), + ]) + + if (!card) throw Object.assign(new Error('Card not found'), { code: 'CARD_NOT_FOUND' }) + if (!platformLink) throw Object.assign(new Error('Platform link not found'), { code: 'PLATFORM_LINK_NOT_FOUND' }) + if (existingLink) throw Object.assign(new Error('This platform link has already been added to the card'), { code: 'LINK_ALREADY_EXISTS' }) + + await app.prisma.cardLink.create({ data: { cardId: id, platformLinkId, displayOrder: (lastLink?.displayOrder ?? -1) + 1 } }) +} + +export async function shareCard(app: FastifyInstance, userId: string, id: string) { + const card = await app.prisma.card.findFirst({ where: { id, userId } }) + if (!card) throw Object.assign(new Error('Card not found'), { code: 'CARD_NOT_FOUND' }) + if (card.visibility === CardVisibility.PRIVATE) throw Object.assign(new Error('Private cards cannot be shared'), { code: 'CARD_PRIVATE' }) + + return { shareUrl: `/cards/share/${card.slug}`, slug: card.slug } +} + +export async function getSharedCard(app: FastifyInstance, slug: string) { + const card = await app.prisma.card.findUnique({ + where: { slug }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }) + + if (!card || card.visibility === CardVisibility.PRIVATE) throw Object.assign(new Error('Card not found'), { code: 'CARD_NOT_FOUND' }) + return card +} + +export async function generateCardQr(app: FastifyInstance, userId: string, id: string) { + const card = await app.prisma.card.findFirst({ where: { id, userId } }) + if (!card) throw Object.assign(new Error('Card not found'), { code: 'CARD_NOT_FOUND' }) + if (card.visibility === CardVisibility.PRIVATE) throw Object.assign(new Error('Private cards cannot be shared'), { code: 'CARD_PRIVATE' }) + if (!card.qrEnabled) throw Object.assign(new Error('QR generation disabled for this card'), { code: 'QR_DISABLED' }) + + const baseUrl = process.env.MOBILE_REDIRECT_URI || process.env.WEB_BASE_URL || '' + return generateQRBuffer(`${baseUrl}/cards/share/${card.slug}`) +} + +export async function cardAnalytics(app: FastifyInstance, userId: string, id: string) { + const analytics = await app.prisma.card.findFirst({ + where: { id, userId }, + include: { + views: { + orderBy: { createdAt: 'desc' }, + include: { + viewer: { select: { id: true, username: true, avatarUrl: true, displayName: true, role: true, accentColor: true } }, + }, + }, + }, + }) + + if (!analytics) throw Object.assign(new Error('Card not found'), { code: 'CARD_NOT_FOUND' }) + return analytics +} diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..45f7a265 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { getPlatform } from '@devcard/shared'; +import { CardVisibility } from '@prisma/client'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), @@ -47,10 +48,19 @@ export const reorderLinksSchema = z.object({ export const createCardSchema = z.object({ title: z.string().min(1).max(100), - linkIds: z.array(z.string().uuid()), + linkIds: z.array(z.string().uuid()).nonempty('At least one link is required').refine(ids => new Set(ids).size === ids.length, 'Duplicate links are not allowed'), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), }); export const updateCardSchema = z.object({ title: z.string().min(1).max(100).optional(), - linkIds: z.array(z.string().uuid()).optional(), + linkIds: z.array(z.string().uuid()).refine(ids => new Set(ids).size === ids.length, 'Duplicate links are not allowed').optional(), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), + qrEnabled: z.boolean().optional(), +}).refine(data => Object.values(data).some(value => value !== undefined), 'At least one field must be provided'); + +export const addPlatformLinkSchema = z.object({ + platformLinkId: z.string().uuid(), }); diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts index 3ef038e2..9b7e374f 100644 --- a/apps/mobile/src/config.ts +++ b/apps/mobile/src/config.ts @@ -12,6 +12,12 @@ export const API_BASE_URL: string = __DEV__ ? `http://${DEV_HOST}:3000` : 'https://api.devcard.dev'; +// OAuth must use the same host for start and callback so browser cookies/state match. +// Android reaches the host machine via `adb reverse tcp:3000 tcp:3000`. +export const AUTH_BASE_URL: string = __DEV__ + ? 'http://localhost:3000' + : 'https://api.devcard.dev'; + export const APP_URL: string = __DEV__ ? 'http://localhost:5173' : 'https://devcard.dev'; @@ -20,3 +26,9 @@ export const APP_URL: string = __DEV__ export const DEEP_LINK_SCHEME = 'devcard'; export const OAUTH_REDIRECT_URI = `${DEEP_LINK_SCHEME}://oauth/callback`; + +// Backend OAuth placeholders. Replace these once backend routes are finalized. +export const BACKEND_AUTH_ROUTES = { + github: '/auth/github', + google: '/auth/google', +} as const; diff --git a/apps/mobile/src/screens/CardsScreen.tsx b/apps/mobile/src/screens/CardsScreen.tsx index 6953ffd2..7980fdf0 100644 --- a/apps/mobile/src/screens/CardsScreen.tsx +++ b/apps/mobile/src/screens/CardsScreen.tsx @@ -6,17 +6,20 @@ import { FlatList, TouchableOpacity, TextInput, - Alert, StatusBar, Modal, RefreshControl, + Linking, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; -import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tokens'; +import QRCode from 'react-native-qrcode-svg'; +import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; import { useAuth } from '../context/AuthContext'; +import { useTheme } from '../context/ThemeContext'; import { PLATFORMS } from '@devcard/shared'; import { get, post, del, put } from '../services/api'; +import { API_BASE_URL } from '../config'; import { EmptyState } from '../components/EmptyState'; import { Skeleton } from '../components/Skeleton'; @@ -24,34 +27,77 @@ interface PlatformLink { id: string; platform: string; username: string; + url?: string; } +type CardVisibility = 'PUBLIC' | 'UNLISTED' | 'PRIVATE'; + interface Card { id: string; title: string; + description: string | null; + slug?: string; + visibility: CardVisibility; + qrEnabled: boolean; + viewCount: number; isDefault: boolean; links: PlatformLink[]; } +type ApiCard = Card & { + cardLinks?: Array<{ link?: PlatformLink; platformLink?: PlatformLink }>; +}; + +type CardAlert = { + title: string; + message: string; + tone?: 'success' | 'error' | 'info' | 'danger'; + confirmText?: string; + cancelText?: string; + onConfirm?: () => void | Promise; +}; + +const getApiCardLinks = (card: ApiCard): PlatformLink[] => { + if (card.links) return card.links; + return (card.cardLinks ?? []).map(cl => cl.platformLink ?? cl.link).filter(Boolean) as PlatformLink[]; +}; + export default function CardsScreen() { - const { token, user } = useAuth(); + const { token } = useAuth(); + const { colors, isDark } = useTheme(); + const themed = React.useMemo(() => createCardsThemedStyles(colors), [colors]); const [cards, setCards] = useState([]); - const [allLinks, setAllLinks] = useState([]); const [showCreate, setShowCreate] = useState(false); + const [editingCard, setEditingCard] = useState(null); const [newTitle, setNewTitle] = useState(''); - const [selectedLinkIds, setSelectedLinkIds] = useState([]); + const [newDescription, setNewDescription] = useState(''); + const [newLinkUrl, setNewLinkUrl] = useState(''); + const [visibility, setVisibility] = useState('PUBLIC'); + const [qrEnabled, setQrEnabled] = useState(true); const [refreshing, setRefreshing] = useState(false); const [loading, setLoading] = useState(true); + const [cardAlert, setCardAlert] = useState(null); + + const showAlert = useCallback((nextAlert: CardAlert) => { + setCardAlert(nextAlert); + }, []); const fetchData = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); try { - const [cardsData, profileData] = await Promise.all([ - get('/api/cards', token).catch(() => []), - get('/api/profiles/me', token).catch(() => null), - ]); - setCards(cardsData || []); - setAllLinks(profileData?.platformLinks || []); + const cardsData = await get('/api/cards', token).catch(() => []); + const normalizedCards: Card[] = (cardsData || []).map(card => ({ + id: card.id, + title: card.title, + description: card.description ?? null, + slug: card.slug, + visibility: card.visibility ?? 'PUBLIC', + qrEnabled: card.qrEnabled ?? true, + viewCount: card.viewCount ?? 0, + isDefault: card.isDefault, + links: getApiCardLinks(card), + })); + setCards(normalizedCards); } catch (error) { console.error('Failed to fetch:', error); } finally { @@ -71,61 +117,152 @@ export default function CardsScreen() { fetchData(false); }; - const createCard = async () => { - if (!newTitle.trim() || selectedLinkIds.length === 0) { - Alert.alert('Error', 'Please enter a title and select at least one link'); + const resetModal = () => { + setShowCreate(false); + setEditingCard(null); + setNewTitle(''); + setNewDescription(''); + setNewLinkUrl(''); + setVisibility('PUBLIC'); + setQrEnabled(true); + }; + + const openCreateModal = () => { + resetModal(); + setShowCreate(true); + }; + + const openEditModal = (card: Card) => { + setEditingCard(card); + setNewTitle(card.title); + setNewDescription(card.description ?? ''); + setNewLinkUrl(card.links?.[0]?.url || card.links?.[0]?.username || ''); + setVisibility(card.visibility); + setQrEnabled(card.qrEnabled); + setShowCreate(true); + }; + + const formatLinkUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ''; + return trimmed.includes('://') ? trimmed : `https://${trimmed}`; + }; + + const saveCard = async () => { + const formattedUrl = formatLinkUrl(newLinkUrl); + if (!newTitle.trim()) { + showAlert({ title: 'Missing title', message: 'Please enter a card title.', tone: 'error' }); + return; + } + + if (!formattedUrl) { + showAlert({ title: 'Missing link', message: 'Please add a link URL.', tone: 'error' }); return; } + try { - await post('/api/cards', { title: newTitle.trim(), linkIds: selectedLinkIds }, token); - setShowCreate(false); - setNewTitle(''); - setSelectedLinkIds([]); + const linkIds = editingCard ? (editingCard.links ?? []).map(link => link.id) : []; + + if (editingCard && linkIds[0]) { + await put(`/api/profiles/me/links/${linkIds[0]}`, { + platform: 'custom', + username: formattedUrl, + url: formattedUrl, + }, token); + } else { + const createdLink = await post('/api/profiles/me/links', { + platform: 'custom', + username: formattedUrl, + url: formattedUrl, + }, token); + linkIds.push(createdLink.id); + } + + if (linkIds.length === 0) { + showAlert({ title: 'Missing link', message: 'Please add a link URL.', tone: 'error' }); + return; + } + + const payload = { + title: newTitle.trim(), + description: newDescription.trim() || undefined, + linkIds, + visibility, + qrEnabled, + }; + + if (editingCard) { + await put(`/api/cards/${editingCard.id}/update`, payload, token); + } else { + await post('/api/cards', payload, token); + } + + resetModal(); fetchData(); - } catch { - Alert.alert('Error', 'Failed to create card'); + showAlert({ title: 'Saved', message: editingCard ? 'Card updated.' : 'Card created.', tone: 'success' }); + } catch (error) { + showAlert({ title: 'Could not save', message: error instanceof Error ? error.message : `Failed to ${editingCard ? 'update' : 'create'} card`, tone: 'error' }); } }; const deleteCard = (id: string) => { - Alert.alert('Delete Card', 'Are you sure?', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - try { - await del(`/api/cards/${id}`, undefined, token); - } catch { - // ignore - } - fetchData(); - }, + showAlert({ + title: 'Delete card?', + message: 'This card will be removed from your account.', + tone: 'danger', + confirmText: 'Delete', + cancelText: 'Keep it', + onConfirm: async () => { + try { + await del(`/api/cards/${id}/delete`, undefined, token); + showAlert({ title: 'Deleted', message: 'Card deleted.', tone: 'success' }); + } catch (error) { + showAlert({ title: 'Delete failed', message: error instanceof Error ? error.message : 'Failed to delete card', tone: 'error' }); + } + fetchData(); }, - ]); + }); }; const setDefault = async (id: string) => { try { await put(`/api/cards/${id}/default`, undefined, token); - } catch { - // ignore + showAlert({ title: 'Updated', message: 'Default card updated.', tone: 'success' }); + } catch (error) { + showAlert({ title: 'Update failed', message: error instanceof Error ? error.message : 'Failed to set default card', tone: 'error' }); } fetchData(); }; - const toggleLink = (linkId: string) => { - setSelectedLinkIds(prev => - prev.includes(linkId) - ? prev.filter(id => id !== linkId) - : [...prev, linkId] - ); + const onCardPress = async (card: Card) => { + const firstLink = card.links?.[0]; + const url = formatLinkUrl(firstLink?.url || firstLink?.username || ''); + + if (!url) { + showAlert({ title: 'No link', message: 'This card does not have a link to open.', tone: 'info' }); + return; + } + + const canOpen = await Linking.canOpenURL(url).catch(() => false); + if (!canOpen) { + showAlert({ title: 'Unable to open', message: 'This link cannot be opened on this device.', tone: 'error' }); + return; + } + + await Linking.openURL(url); + }; + + const getPlatformSummary = (card: Card) => { + const names = (card.links ?? []) + .slice(0, 4) + .map(link => PLATFORMS[link.platform]?.name || link.platform); + return names.join(' · '); }; if (loading) { return ( - - + + @@ -146,15 +283,15 @@ export default function CardsScreen() { } return ( - - + + - Context Cards + My Cards setShowCreate(true)}> - + New Card + onPress={openCreateModal}> + + New card @@ -171,59 +308,74 @@ export default function CardsScreen() { } renderItem={({ item }) => ( - - {/* Card Header: Logo & Chip */} - - - - DevCard - - 📶 - - - {/* Card Center: Title */} - - {item.title} - CONTMEMORY ACCESS + onCardPress(item)} + style={[styles.cardTile, item.isDefault ? themed.cardDefault : themed.cardNormal]}> + + {item.isDefault ? ( + + ACTIVE + + ) : } + - {/* Card Footer: User & Platforms */} - - - {user?.displayName || 'Card Holder'} - {Math.random().toString(36).substring(2, 6).toUpperCase()} {Math.random().toString(36).substring(2, 6).toUpperCase()} - - - {item.links.slice(0, 3).map(link => ( - - ))} - {item.links.length > 3 && ( - +{item.links.length - 3} + + + + {item.title} + + + {getPlatformSummary(item)} + + {!!item.description && ( + {item.description} )} + {(item.links ?? []).length} platforms + {item.visibility.toLowerCase()} · {item.viewCount} views + {item.slug && item.qrEnabled ? ( + + + + ) : ( + + QR off + + )} + - {/* Glass Overlay for Default */} - {item.isDefault && } - - - {/* Card Actions Below the Card */} - {!item.isDefault ? ( - setDefault(item.id)} style={styles.actionBtn}> - Set as Primary - - ) : ( - - ACTIVE CARD - - )} + openEditModal(item)} style={styles.defaultBtn}> + Edit + + setDefault(item.id)} disabled={item.isDefault} style={styles.defaultBtn}> + {item.isDefault ? 'Default' : 'Set default'} + deleteCard(item.id)} style={styles.deleteBtn}> Delete )} + ListFooterComponent={ + + + + Create a new context card + e.g. "Open Source" or "Job Search" + + + Tip: select active card before opening Share screen + + + } ListEmptyComponent={ - {/* Create Card Modal */} + {/* Create/Edit Card Modal */} - - Create Card + + {editingCard ? 'Edit Card' : 'Create Card'} - Select platforms to include: - {allLinks.length === 0 ? ( - - You haven't added any links yet. - Go to the "Links" tab to add your GitHub, LinkedIn, etc. before creating a card. - - ) : ( - allLinks.map(link => ( + + + Visibility: + + {(['PUBLIC', 'UNLISTED', 'PRIVATE'] as CardVisibility[]).map(option => ( toggleLink(link.id)}> - - - {PLATFORMS[link.platform]?.name || link.platform} — {link.username} - - {selectedLinkIds.includes(link.id) && } + key={option} + style={[themed.visibilityChip, visibility === option && styles.visibilityChipSelected]} + onPress={() => setVisibility(option)}> + {option.toLowerCase()} - )) - )} - - Create Card + ))} + + setQrEnabled(value => !value)}> + {qrEnabled ? '✓ QR sharing enabled' : 'QR sharing disabled'} + + {editingCard && editingCard.links.length > 0 ? ( + + Update the URL above to change this card's link. + + ) : null} + + {editingCard ? 'Save Changes' : 'Create Card'} { setShowCreate(false); setNewTitle(''); setSelectedLinkIds([]); }}> + onPress={resetModal}> Cancel + + + + + + + {cardAlert?.tone === 'success' ? '✓' : cardAlert?.tone === 'danger' ? '!' : cardAlert?.tone === 'error' ? '!' : 'i'} + + + {cardAlert?.title} + {cardAlert?.message} + + {cardAlert?.onConfirm ? ( + setCardAlert(null)}> + {cardAlert.cancelText || 'Cancel'} + + ) : null} + { + const onConfirm = cardAlert?.onConfirm; + setCardAlert(null); + await onConfirm?.(); + }}> + {cardAlert?.confirmText || 'OK'} + + + + + ); } @@ -288,7 +486,7 @@ const styles = StyleSheet.create({ }, title: { fontSize: FONT_SIZE.xl, fontWeight: '800', color: COLORS.textPrimary }, addButton: { - backgroundColor: COLORS.primary, borderRadius: BORDER_RADIUS.sm, + backgroundColor: '#1E1E1E', borderRadius: BORDER_RADIUS.md, paddingHorizontal: SPACING.md, paddingVertical: SPACING.xs, }, addButtonText: { color: COLORS.white, fontWeight: '600', fontSize: FONT_SIZE.sm }, @@ -325,6 +523,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: COLORS.border, marginBottom: SPACING.md, }, selectLabel: { fontSize: FONT_SIZE.sm, color: COLORS.textSecondary, marginBottom: SPACING.sm }, + visibilityRow: { flexDirection: 'row', gap: SPACING.xs, marginBottom: SPACING.sm }, + visibilityChipSelected: { borderColor: COLORS.primary, backgroundColor: 'rgba(99, 102, 241, 0.16)' }, + visibilityChipTextSelected: { color: COLORS.primaryLight, fontWeight: '700' }, + qrToggle: { marginBottom: SPACING.md, paddingVertical: SPACING.xs }, linkOption: { flexDirection: 'row', alignItems: 'center', backgroundColor: COLORS.bgCard, borderRadius: BORDER_RADIUS.sm, @@ -363,151 +565,369 @@ const styles = StyleSheet.create({ fontSize: FONT_SIZE.sm, textAlign: 'center', }, - // Premium Card Styles - cardContainer: { - marginBottom: SPACING.xl, + existingLinksText: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + marginBottom: SPACING.md, + textAlign: 'center', }, + // Premium Card Styles + cardContainer: { marginBottom: SPACING.md }, defaultCardContainer: {}, - premiumCard: { - width: '100%', - aspectRatio: 1.58, - borderRadius: 20, - padding: SPACING.lg, - justifyContent: 'space-between', - overflow: 'hidden', - position: 'relative', - ...SHADOWS.card, + cardTile: { + borderRadius: BORDER_RADIUS.lg, + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + borderWidth: 1, }, cardNormal: { - backgroundColor: '#1E293B', + backgroundColor: COLORS.bgCard, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', + borderColor: COLORS.border, }, cardDefault: { - backgroundColor: '#0F172A', - borderWidth: 1.5, + backgroundColor: '#1D2B3A', + borderWidth: 1, borderColor: COLORS.primary, }, - cardHeader: { + cardTopRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + marginBottom: SPACING.sm, }, - cardBrand: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - chip: { - width: 35, - height: 25, - borderRadius: 4, - backgroundColor: '#D1D5DB', - opacity: 0.8, + activePill: { + backgroundColor: COLORS.primary, + borderRadius: 999, + paddingHorizontal: SPACING.sm, + paddingVertical: 4, }, - brandText: { - color: 'rgba(255,255,255,0.6)', - fontSize: 12, - fontWeight: '700', - letterSpacing: 1, - textTransform: 'uppercase', - }, - contactless: { - fontSize: 20, - opacity: 0.5, - }, - cardCenter: { - marginTop: SPACING.md, - }, - premiumCardTitle: { - fontSize: 28, - fontWeight: '800', + activePillText: { color: COLORS.white, + fontSize: FONT_SIZE.xs, + fontWeight: '700', letterSpacing: 0.5, }, - cardType: { - fontSize: 8, - color: 'rgba(255,255,255,0.4)', - fontWeight: '700', - letterSpacing: 2, - marginTop: 4, + editText: { + color: COLORS.primaryLight, + fontSize: FONT_SIZE.md, + fontWeight: '500', }, - cardFooter: { + cardContentRow: { flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', + alignItems: 'center', + gap: SPACING.md, }, - userInfo: { + cardDetails: { flex: 1, }, - userName: { - fontSize: 14, - color: 'rgba(255,255,255,0.8)', - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 1, - }, - cardId: { - fontSize: 10, - color: 'rgba(255,255,255,0.3)', - fontFamily: 'monospace', - marginTop: 2, + qrContainer: { + backgroundColor: COLORS.white, + borderRadius: BORDER_RADIUS.sm, + padding: SPACING.xs, }, - platformIcons: { - flexDirection: 'row', + qrPlaceholder: { + width: 80, + height: 80, alignItems: 'center', - gap: 6, + justifyContent: 'center', + borderRadius: BORDER_RADIUS.sm, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: COLORS.border, }, - platformDot: { - width: 10, - height: 10, - borderRadius: 5, + qrPlaceholderText: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + textAlign: 'center', }, - morePlatforms: { - fontSize: 10, - color: 'rgba(255,255,255,0.5)', - fontWeight: '700', + cardName: { + fontSize: 34, + fontWeight: '800', + color: COLORS.textPrimary, + marginBottom: 4, + }, + cardNameActive: { + color: '#8BC4FF', + }, + platformSummary: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.md, + marginBottom: SPACING.sm, }, - glassOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(255,255,255,0.03)', + cardDescription: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + marginBottom: SPACING.xs, + }, + cardMeta: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.xs, + marginTop: SPACING.xs, + }, + platformCount: { + alignSelf: 'flex-start', + color: COLORS.textMuted, + backgroundColor: 'rgba(255,255,255,0.06)', + borderRadius: 999, + paddingHorizontal: SPACING.sm, + paddingVertical: 3, + fontSize: FONT_SIZE.xs, }, actionRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginTop: SPACING.md, + marginTop: SPACING.xs, paddingHorizontal: SPACING.xs, }, - actionBtn: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 6, - backgroundColor: 'rgba(99, 102, 241, 0.1)', + deleteBtn: { + paddingVertical: 4, + paddingHorizontal: 10, + }, + defaultBtn: { + paddingVertical: 4, + paddingHorizontal: 10, }, - actionBtnText: { - color: COLORS.primary, + defaultBtnText: { + color: COLORS.primaryLight, fontSize: 12, fontWeight: '600', }, - activeBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - activeBadgeText: { - color: COLORS.success, - fontSize: 10, - fontWeight: '800', - letterSpacing: 1, - }, - deleteBtn: { - paddingVertical: 6, - paddingHorizontal: 10, + defaultBtnTextDisabled: { + color: COLORS.textMuted, }, deleteBtnText: { color: 'rgba(239, 68, 68, 0.6)', fontSize: 12, fontWeight: '600', }, + createTile: { + marginTop: SPACING.sm, + borderRadius: BORDER_RADIUS.lg, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: COLORS.border, + backgroundColor: COLORS.bgSecondary, + paddingVertical: SPACING.xl, + alignItems: 'center', + }, + createTileTitle: { + color: COLORS.textSecondary, + fontSize: FONT_SIZE.lg, + fontWeight: '500', + }, + createTileSub: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.sm, + marginTop: SPACING.xs, + }, + tipCard: { + marginTop: SPACING.md, + backgroundColor: 'rgba(245, 158, 11, 0.12)', + borderColor: 'rgba(245, 158, 11, 0.45)', + borderWidth: 1, + borderRadius: BORDER_RADIUS.md, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.md, + }, + tipText: { + color: '#F4C27A', + fontSize: FONT_SIZE.sm, + textAlign: 'center', + }, + alertOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.62)', + alignItems: 'center', + justifyContent: 'center', + padding: SPACING.lg, + }, + alertIcon: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + marginBottom: SPACING.md, + }, + alertIcon_success: { backgroundColor: 'rgba(34, 197, 94, 0.2)' }, + alertIcon_error: { backgroundColor: 'rgba(239, 68, 68, 0.2)' }, + alertIcon_danger: { backgroundColor: 'rgba(239, 68, 68, 0.2)' }, + alertIcon_info: { backgroundColor: 'rgba(99, 102, 241, 0.2)' }, + alertIconText: { + color: COLORS.white, + fontSize: FONT_SIZE.lg, + fontWeight: '800', + }, + alertActions: { + flexDirection: 'row', + gap: SPACING.sm, + marginTop: SPACING.lg, + }, + alertPrimaryButton: { + flex: 1, + backgroundColor: COLORS.primary, + borderRadius: BORDER_RADIUS.md, + paddingVertical: SPACING.md, + alignItems: 'center', + }, + alertDangerButton: { + backgroundColor: '#DC2626', + }, + alertPrimaryText: { + color: COLORS.white, + fontSize: FONT_SIZE.sm, + fontWeight: '800', + }, }); + +function createCardsThemedStyles(colors: typeof COLORS) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgPrimary }, + title: { fontSize: FONT_SIZE.xl, fontWeight: '800', color: colors.textPrimary }, + cardNormal: { + backgroundColor: colors.bgCard, + borderWidth: 1, + borderColor: colors.border, + }, + cardDefault: { + backgroundColor: colors.bgElevated, + borderWidth: 1, + borderColor: colors.primary, + }, + cardName: { + fontSize: 34, + fontWeight: '800', + color: colors.textPrimary, + marginBottom: 4, + }, + platformSummary: { + color: colors.textSecondary, + fontSize: FONT_SIZE.md, + marginBottom: SPACING.sm, + }, + cardDescription: { + color: colors.textMuted, + fontSize: FONT_SIZE.sm, + marginBottom: SPACING.xs, + }, + cardMeta: { + color: colors.textMuted, + fontSize: FONT_SIZE.xs, + marginTop: SPACING.xs, + }, + platformCount: { + alignSelf: 'flex-start', + color: colors.textMuted, + backgroundColor: colors.bgSecondary, + borderRadius: 999, + paddingHorizontal: SPACING.sm, + paddingVertical: 3, + fontSize: FONT_SIZE.xs, + }, + createTile: { + marginTop: SPACING.sm, + borderRadius: BORDER_RADIUS.lg, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: colors.border, + backgroundColor: colors.bgSecondary, + paddingVertical: SPACING.xl, + alignItems: 'center', + }, + createTileTitle: { + color: colors.textSecondary, + fontSize: FONT_SIZE.lg, + fontWeight: '500', + }, + createTileSub: { + color: colors.textMuted, + fontSize: FONT_SIZE.sm, + marginTop: SPACING.xs, + }, + modalContent: { + backgroundColor: colors.bgSecondary, + borderTopLeftRadius: BORDER_RADIUS.xl, + borderTopRightRadius: BORDER_RADIUS.xl, + padding: SPACING.lg, + maxHeight: '80%', + }, + input: { + backgroundColor: colors.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.md, + color: colors.textPrimary, + fontSize: FONT_SIZE.md, + borderWidth: 1, + borderColor: colors.border, + marginBottom: SPACING.md, + }, + linkOption: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgCard, + borderRadius: BORDER_RADIUS.sm, + padding: SPACING.md, + marginBottom: SPACING.xs, + borderWidth: 1, + borderColor: colors.border, + }, + linkOptionText: { flex: 1, fontSize: FONT_SIZE.sm, color: colors.textPrimary, marginLeft: SPACING.sm }, + visibilityChip: { + flex: 1, + alignItems: 'center', + borderRadius: BORDER_RADIUS.sm, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.bgCard, + paddingVertical: SPACING.sm, + }, + visibilityChipText: { + color: colors.textSecondary, + fontSize: FONT_SIZE.xs, + textTransform: 'uppercase', + }, + qrToggleText: { + color: colors.textSecondary, + fontSize: FONT_SIZE.sm, + }, + alertCard: { + width: '100%', + maxWidth: 360, + backgroundColor: colors.bgElevated, + borderColor: colors.border, + borderWidth: 1, + borderRadius: BORDER_RADIUS.xl, + padding: SPACING.lg, + alignItems: 'center', + }, + alertTitle: { + color: colors.textPrimary, + fontSize: FONT_SIZE.lg, + fontWeight: '800', + textAlign: 'center', + marginBottom: SPACING.xs, + }, + alertMessage: { + color: colors.textSecondary, + fontSize: FONT_SIZE.sm, + lineHeight: 20, + textAlign: 'center', + }, + alertSecondaryButton: { + flex: 1, + backgroundColor: colors.bgCard, + borderColor: colors.border, + borderWidth: 1, + borderRadius: BORDER_RADIUS.md, + paddingVertical: SPACING.md, + alignItems: 'center', + }, + alertSecondaryText: { + color: colors.textSecondary, + fontSize: FONT_SIZE.sm, + fontWeight: '700', + }, + }); +} diff --git a/apps/mobile/src/services/api.ts b/apps/mobile/src/services/api.ts index 70daf195..c0fe6b32 100644 --- a/apps/mobile/src/services/api.ts +++ b/apps/mobile/src/services/api.ts @@ -1,5 +1,278 @@ import { API_BASE_URL } from '../config'; +const DEMO_TOKEN = 'devcard-demo-token'; + +type DemoLink = { + id: string; + platform: string; + username: string; + url: string; + displayOrder: number; +}; + +type DemoCard = { + id: string; + title: string; + description: string | null; + slug: string; + visibility: 'PUBLIC' | 'UNLISTED' | 'PRIVATE'; + qrEnabled: boolean; + viewCount: number; + profileId: string; + isDefault: boolean; + createdAt: string; + updatedAt: string; + cardLinks: Array<{ + id: string; + cardId: string; + linkId: string; + displayOrder: number; + link: DemoLink; + }>; +}; + +const nowIso = () => new Date().toISOString(); + +const demoState: { + profile: any; + links: DemoLink[]; + cards: DemoCard[]; +} = { + profile: { + id: 'demo-user-1', + email: 'demo@devcard.app', + username: 'demo_dev', + displayName: 'Demo Developer', + bio: 'Building and testing DevCard in demo mode.', + pronouns: 'she/her', + role: 'Full Stack Engineer', + company: 'DevCard Labs', + avatarUrl: null, + accentColor: '#6366F1', + defaultCardId: 'card-1', + }, + links: [ + { id: 'link-1', platform: 'github', username: 'demo-dev', url: 'https://github.com/demo-dev', displayOrder: 0 }, + { id: 'link-2', platform: 'linkedin', username: 'demo-dev', url: 'https://linkedin.com/in/demo-dev', displayOrder: 1 }, + { id: 'link-3', platform: 'x', username: 'demo_dev', url: 'https://x.com/demo_dev', displayOrder: 2 }, + ], + cards: [ + { + id: 'card-1', + title: 'Main Card', + description: 'Demo developer links', + slug: 'main-card', + visibility: 'PUBLIC', + qrEnabled: true, + viewCount: 0, + profileId: 'demo-user-1', + isDefault: true, + createdAt: nowIso(), + updatedAt: nowIso(), + cardLinks: [], + }, + ], +}; + +const hydrateCards = () => { + demoState.cards = demoState.cards.map(card => ({ + ...card, + cardLinks: demoState.links.map((link, index) => ({ + id: `${card.id}-${link.id}`, + cardId: card.id, + linkId: link.id, + displayOrder: index, + link, + })), + })); +}; + +hydrateCards(); + +function handleDemoRequest(path: string, method: RequestOptions['method'], body?: any): T { + if (path === '/api/profiles/me' && method === 'GET') { + return { ...demoState.profile, platformLinks: demoState.links } as T; + } + + if (path === '/api/analytics/overview' && method === 'GET') { + return { views: 128, scans: 41, clicks: 79, thisWeek: 24 } as T; + } + + if (path === '/api/analytics/views' && method === 'GET') { + return { + total: 128, + weekly: [12, 18, 22, 15, 28, 17, 16], + sources: [{ source: 'qr', count: 51 }, { source: 'profile', count: 77 }], + } as T; + } + + if (path === '/api/cards' && method === 'GET') return demoState.cards as T; + if (path === '/api/cards' && method === 'POST') { + const id = `card-${Date.now()}`; + const card = { + id, + title: body?.title || 'New Card', + description: body?.description || null, + slug: `${body?.title || 'new-card'}-${Date.now()}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + visibility: body?.visibility || 'PUBLIC', + qrEnabled: true, + viewCount: 0, + profileId: demoState.profile.id, + isDefault: false, + createdAt: nowIso(), + updatedAt: nowIso(), + cardLinks: demoState.links.map((link: DemoLink, index: number) => ({ + id: `${id}-${link.id}`, + cardId: id, + linkId: link.id, + displayOrder: index, + link, + })), + }; + demoState.cards.unshift(card); + return card as T; + } + + if (path.startsWith('/api/cards/') && path.endsWith('/update') && method === 'PUT') { + const id = path.split('/')[3]; + const card = demoState.cards.find(item => item.id === id); + if (!card) return null as T; + card.title = body?.title ?? card.title; + card.description = body?.description ?? card.description; + card.visibility = body?.visibility ?? card.visibility; + card.qrEnabled = body?.qrEnabled ?? card.qrEnabled; + if (body?.linkIds) { + card.cardLinks = demoState.links + .filter(link => body.linkIds.includes(link.id)) + .map((link, index) => ({ id: `${id}-${link.id}`, cardId: id, linkId: link.id, displayOrder: index, link })); + } + return card as T; + } + + if (path.startsWith('/api/cards/') && path.endsWith('/share') && method === 'POST') { + const id = path.split('/')[3]; + const card = demoState.cards.find(item => item.id === id); + return { shareUrl: `/cards/share/${card?.slug || id}`, slug: card?.slug || id } as T; + } + + if (path.startsWith('/api/cards/share/') && method === 'GET') { + const slug = path.split('/')[4]; + const card = demoState.cards.find(item => item.slug === slug); + if (card) card.viewCount += 1; + return card as T; + } + + if (path.startsWith('/api/cards/') && path.endsWith('/default') && method === 'PUT') { + const id = path.split('/')[3]; + demoState.cards = demoState.cards.map(card => ({ ...card, isDefault: card.id === id })); + demoState.profile.defaultCardId = id; + return { ok: true } as T; + } + + if (path.startsWith('/api/cards/') && method === 'DELETE') { + const id = path.split('/')[3]; + demoState.cards = demoState.cards.filter(card => card.id !== id); + if (!demoState.cards.some(card => card.id === demoState.profile.defaultCardId)) { + demoState.profile.defaultCardId = demoState.cards[0]?.id ?? null; + demoState.cards = demoState.cards.map((card, index) => ({ ...card, isDefault: index === 0 })); + } + return { ok: true } as T; + } + + if (path === '/api/profiles/me/links' && method === 'POST') { + const id = `link-${Date.now()}`; + const username = body?.username || 'demo-user'; + const platform = body?.platform || 'github'; + const link: DemoLink = { + id, + platform, + username, + url: body?.url || (platform === 'custom' ? username : `https://${platform}.com/${username}`), + displayOrder: demoState.links.length, + }; + demoState.links.push(link); + hydrateCards(); + return link as T; + } + + if (path.startsWith('/api/profiles/me/links/') && method === 'DELETE') { + const id = path.split('/')[5]; + demoState.links = demoState.links.filter(link => link.id !== id).map((link, index) => ({ ...link, displayOrder: index })); + hydrateCards(); + return { ok: true } as T; + } + + if (path === '/api/profiles/me/links/reorder' && method === 'PUT') { + const orderMap = new Map((body?.links || []).map((item: any) => [item.id, Number(item.displayOrder)])); + demoState.links = demoState.links + .map(link => ({ ...link, displayOrder: orderMap.get(link.id) ?? link.displayOrder })) + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((link, index) => ({ ...link, displayOrder: index })); + hydrateCards(); + return { ok: true } as T; + } + + if (path === '/api/profiles/me' && method === 'PUT') { + demoState.profile = { ...demoState.profile, ...(body || {}) }; + return demoState.profile as T; + } + + if (path === '/api/connect/status' && method === 'GET') { + return { github: true, linkedin: true, x: false, discord: false } as T; + } + + if (path.startsWith('/api/connect/') && method === 'DELETE') { + return { ok: true } as T; + } + + if (path === '/api/nfc/payload' && method === 'GET') { + return { url: `https://devcard.app/u/${demoState.profile.username}`, username: demoState.profile.username } as T; + } + + if (path.startsWith('/api/u/') && method === 'GET') { + const username = path.split('/')[3]; + return { + profile: { ...demoState.profile, username }, + links: demoState.links, + cards: demoState.cards, + } as T; + } + + if (path.startsWith('/api/events/') && method === 'GET') { + if (path.endsWith('/attendees')) { + return [{ id: 'demo-user-1', username: 'demo_dev', displayName: 'Demo Developer' }] as T; + } + const slug = path.split('/')[3]; + return { + id: slug, + slug, + title: 'Demo Builders Meetup', + description: 'Local event for testing flow and UX.', + location: 'Remote', + attendeeCount: 23, + isAttending: true, + } as T; + } + + if (path.startsWith('/api/events/') && (method === 'POST' || method === 'DELETE')) return { ok: true } as T; + if (path.startsWith('/api/teams/') && method === 'GET') { + const slug = path.split('/')[3]; + return { + id: slug, + slug, + name: 'Demo Team', + description: 'Demo collaboration team', + members: [{ id: 'demo-user-1', username: 'demo_dev', displayName: 'Demo Developer' }], + } as T; + } + + if (path.startsWith('/api/follow/') && (method === 'POST' || method === 'DELETE')) { + return { ok: true, redirectUrl: null } as T; + } + + return (null as unknown) as T; +} + type RequestOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; @@ -11,15 +284,19 @@ export async function apiRequest( path: string, { method = 'GET', body, token, onUnauthorized }: RequestOptions = {} ): Promise { + if (token === DEMO_TOKEN) { + return handleDemoRequest(path, method, body); + } + const headers: Record = { - 'Content-Type': 'application/json', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), }; const res = await fetch(`${API_BASE_URL}${path}`, { method, headers, - ...(body ? { body: JSON.stringify(body) } : {}), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); if (res.status === 401 || res.status === 403) { @@ -29,7 +306,7 @@ export async function apiRequest( if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as any)?.message ?? `Request failed: ${res.status}`); + throw new Error((err as any)?.message ?? (err as any)?.error ?? `Request failed: ${res.status}`); } // Some endpoints may return empty responses @@ -43,4 +320,5 @@ export const post = (path: string, body?: unknown, token?: string | null) => export const put = (path: string, body?: unknown, token?: string | null) => apiRequest(path, { method: 'PUT', body, token }); export const del = (path: string, body?: unknown, token?: string | null) => apiRequest(path, { method: 'DELETE', body, token }); +export { DEMO_TOKEN }; export default { apiRequest, get, post, put, del }; diff --git a/apps/mobile/src/services/backendAuth.ts b/apps/mobile/src/services/backendAuth.ts new file mode 100644 index 00000000..5deb69b8 --- /dev/null +++ b/apps/mobile/src/services/backendAuth.ts @@ -0,0 +1,36 @@ +import { Linking } from 'react-native'; +import { AUTH_BASE_URL, BACKEND_AUTH_ROUTES, OAUTH_REDIRECT_URI } from '../config'; + +type AuthProvider = keyof typeof BACKEND_AUTH_ROUTES; + +export function buildBackendAuthUrl(provider: AuthProvider) { + const route = BACKEND_AUTH_ROUTES[provider]; + const params = new URLSearchParams({ + client: 'mobile', + provider, + state: `mobile_${provider}`, + mobile_redirect_uri: OAUTH_REDIRECT_URI, + }); + + return `${AUTH_BASE_URL}${route}?${params.toString()}`; +} + +export async function startBackendOAuth(provider: AuthProvider) { + const url = buildBackendAuthUrl(provider); + const canOpen = await Linking.canOpenURL(url); + + if (!canOpen) { + throw new Error(`Cannot open auth URL for ${provider}.`); + } + + await Linking.openURL(url); +} + +export function getJwtFromCallbackUrl(url: string) { + if (!url.startsWith(OAUTH_REDIRECT_URI)) return null; + + const queryString = url.includes('#') ? url.split('#')[1] : url.split('?')[1] || ''; + const params = new URLSearchParams(queryString); + + return params.get('token') || params.get('jwt') || params.get('access_token'); +} diff --git a/packages/shared/src/__tests__/cards.test.ts b/packages/shared/src/__tests__/cards.test.ts index 0c1a6d1e..20c3e52c 100644 --- a/packages/shared/src/__tests__/cards.test.ts +++ b/packages/shared/src/__tests__/cards.test.ts @@ -69,4 +69,4 @@ describe('diffCardPlatforms', () => { expect(diff.removed).toEqual([]); expect(diff.unchanged).toEqual(['github']); }); -}); \ No newline at end of file +}); diff --git a/packages/shared/src/__tests__/platforms-url.test.ts b/packages/shared/src/__tests__/platforms-url.test.ts index cbfac373..2ddb7c2d 100644 --- a/packages/shared/src/__tests__/platforms-url.test.ts +++ b/packages/shared/src/__tests__/platforms-url.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from 'vitest'; import { getProfileUrl, getWebViewUrl, getDeepLinkUrl } from '../platforms'; -// ─── getProfileUrl Tests ─── - describe('getProfileUrl', () => { it('should return the correct GitHub profile URL', () => { expect(getProfileUrl('github', 'octocat')).toBe('https://github.com/octocat'); @@ -21,8 +19,6 @@ describe('getProfileUrl', () => { }); }); -// ─── getWebViewUrl Tests ─── - describe('getWebViewUrl', () => { it('should return the correct LinkedIn webview URL', () => { expect(getWebViewUrl('linkedin', 'john')).toBe('https://www.linkedin.com/in/john'); @@ -41,8 +37,6 @@ describe('getWebViewUrl', () => { }); }); -// ─── getDeepLinkUrl Tests ─── - describe('getDeepLinkUrl', () => { it('should return the correct Twitter deep link URL', () => { expect(getDeepLinkUrl('twitter', 'john')).toBe('twitter://user?screen_name=john'); diff --git a/packages/shared/src/cards.ts b/packages/shared/src/cards.ts index d9fa5130..0405d7e5 100644 --- a/packages/shared/src/cards.ts +++ b/packages/shared/src/cards.ts @@ -47,4 +47,4 @@ export function diffCardPlatforms( removed: oldCard.filter(p => !newSet.has(p)), unchanged: oldCard.filter(p => newSet.has(p)), }; -} \ No newline at end of file +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 409d3e76..074e7dca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,3 @@ export * from './platforms'; export * from './types'; -export * from './cards'; \ No newline at end of file +export * from './cards'; diff --git a/packages/shared/src/platforms.test.ts b/packages/shared/src/platforms.test.ts index 6ce07a0b..73ce0e33 100644 --- a/packages/shared/src/platforms.test.ts +++ b/packages/shared/src/platforms.test.ts @@ -8,8 +8,6 @@ import { getDeepLinkUrl, } from './platforms'; -// ─── StackOverflow Platform Tests ─── - describe('stackoverflow platform', () => { it('should exist in PLATFORMS registry', () => { expect(PLATFORMS.stackoverflow).toBeDefined(); @@ -44,9 +42,7 @@ describe('stackoverflow platform', () => { }); }); -// ─── getProfileUrl Tests for StackOverflow ─── - -describe('getProfileUrl – stackoverflow', () => { +describe('getProfileUrl - stackoverflow', () => { it('should generate correct URL with user ID and display name', () => { const url = getProfileUrl('stackoverflow', '1234/user'); expect(url).toBe('https://stackoverflow.com/users/1234/user'); @@ -63,9 +59,7 @@ describe('getProfileUrl – stackoverflow', () => { }); }); -// ─── getWebViewUrl / getDeepLinkUrl for StackOverflow ─── - -describe('getWebViewUrl / getDeepLinkUrl – stackoverflow', () => { +describe('getWebViewUrl / getDeepLinkUrl - stackoverflow', () => { it('should return null for webViewUrl (not supported)', () => { expect(getWebViewUrl('stackoverflow', '1234/user')).toBeNull(); }); @@ -75,15 +69,12 @@ describe('getWebViewUrl / getDeepLinkUrl – stackoverflow', () => { }); }); -// ─── validationRegex Tests ─── - describe('validationRegex logic', () => { it('should correctly validate github usernames', () => { const regex = PLATFORMS.github.validationRegex!; expect(regex.test('valid-user')).toBe(true); expect(regex.test('a')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid expect(regex.test('-invalid')).toBe(false); expect(regex.test('invalid-')).toBe(false); expect(regex.test('in--valid')).toBe(false); @@ -94,8 +85,7 @@ describe('validationRegex logic', () => { const regex = PLATFORMS.linkedin.validationRegex!; expect(regex.test('valid-user')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid - expect(regex.test('ab')).toBe(false); // Too short + expect(regex.test('ab')).toBe(false); expect(regex.test('user name')).toBe(false); }); @@ -103,9 +93,8 @@ describe('validationRegex logic', () => { const regex = PLATFORMS.twitter.validationRegex!; expect(regex.test('valid_user')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid - expect(regex.test('user-name')).toBe(false); // Hyphens not allowed - expect(regex.test('this_is_a_very_long_name_indeed')).toBe(false); // Too long + expect(regex.test('user-name')).toBe(false); + expect(regex.test('this_is_a_very_long_name_indeed')).toBe(false); expect(regex.test('user name')).toBe(false); }); }); diff --git a/packages/shared/src/platforms.ts b/packages/shared/src/platforms.ts index 81c81ab4..fd4f6df9 100644 --- a/packages/shared/src/platforms.ts +++ b/packages/shared/src/platforms.ts @@ -1,38 +1,20 @@ -// ─── Follow Strategy Types ─── - export type FollowStrategy = 'api' | 'webview' | 'link' | 'copy'; -// ─── Platform Definition ─── - export interface PlatformDef { - /** Unique platform identifier */ id: string; - /** Display name */ name: string; - /** Icon name (maps to platform icon set) */ icon: string; - /** Brand color hex */ color: string; - /** URL pattern — {username} is replaced */ urlPattern: string; - /** Deep link pattern for mobile — null if not supported */ deepLinkPattern: string | null; - /** WebView profile URL pattern (for Layer 2) */ webViewUrlPattern: string | null; - /** Follow/connect strategy */ followStrategy: FollowStrategy; - /** OAuth scopes needed for API follow (Layer 1) */ oauthScopes: string[]; - /** Placeholder text for username input */ usernamePlaceholder: string; - /** Whether the platform uses full URL instead of username */ usesFullUrl: boolean; - /** Regex pattern to validate usernames */ validationRegex?: RegExp; } -// ─── Platform Registry ─── - export const PLATFORMS: Record = { github: { id: 'github', @@ -198,7 +180,7 @@ export const PLATFORMS: Record = { name: 'Discord', icon: 'discord', color: '#5865F2', - urlPattern: '', // no profile URL — copy to clipboard + urlPattern: '', deepLinkPattern: null, webViewUrlPattern: null, followStrategy: 'copy', @@ -237,7 +219,7 @@ export const PLATFORMS: Record = { name: 'Portfolio', icon: 'globe', color: '#6366F1', - urlPattern: '{username}', // full URL provided + urlPattern: '{username}', deepLinkPattern: null, webViewUrlPattern: null, followStrategy: 'link', @@ -250,7 +232,7 @@ export const PLATFORMS: Record = { name: 'Custom Link', icon: 'link', color: '#8B5CF6', - urlPattern: '{username}', // full URL provided + urlPattern: '{username}', deepLinkPattern: null, webViewUrlPattern: null, followStrategy: 'link', @@ -260,33 +242,26 @@ export const PLATFORMS: Record = { }, }; -// ─── Helpers ─── - -/** Get ordered list of all platform definitions */ export function getAllPlatforms(): PlatformDef[] { return Object.values(PLATFORMS); } -/** Get a platform by ID */ export function getPlatform(id: string): PlatformDef | undefined { return PLATFORMS[id]; } -/** Get the profile URL for a given platform and username */ export function getProfileUrl(platformId: string, username: string): string { const platform = PLATFORMS[platformId]; if (!platform) return ''; return platform.urlPattern.replace(/{username}/g, username); } -/** Get the WebView URL for Layer 2 platforms */ export function getWebViewUrl(platformId: string, username: string): string | null { const platform = PLATFORMS[platformId]; if (!platform?.webViewUrlPattern) return null; return platform.webViewUrlPattern.replace(/{username}/g, username); } -/** Get the deep link URL for mobile */ export function getDeepLinkUrl(platformId: string, username: string): string | null { const platform = PLATFORMS[platformId]; if (!platform?.deepLinkPattern) return null; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4a4a9dcc..3847a64e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,5 +1,3 @@ -// ─── User Types ─── - export interface User { id: string; email: string; @@ -24,8 +22,6 @@ export interface UpdateProfilePayload { accentColor?: string; } -// ─── Platform Link Types ─── - export interface PlatformLink { id: string; platform: string; @@ -44,11 +40,16 @@ export interface ReorderLinksPayload { links: Array<{ id: string; displayOrder: number }>; } -// ─── Card Types ─── +export type CardVisibility = 'PUBLIC' | 'UNLISTED' | 'PRIVATE'; export interface Card { id: string; title: string; + description?: string | null; + slug?: string; + visibility?: CardVisibility; + qrEnabled?: boolean; + viewCount?: number; isDefault: boolean; links: PlatformLink[]; } @@ -56,15 +57,18 @@ export interface Card { export interface CreateCardPayload { title: string; linkIds: string[]; + description?: string; + visibility?: CardVisibility; } export interface UpdateCardPayload { title?: string; linkIds?: string[]; + description?: string; + visibility?: CardVisibility; + qrEnabled?: boolean; } -// ─── Public Profile Types ─── - export interface PublicProfile { username: string; displayName: string; @@ -92,8 +96,6 @@ export interface PublicCard { links: PlatformLink[]; } -// ─── Follow Engine Types ─── - export type FollowStatus = 'idle' | 'loading' | 'success' | 'error'; export interface FollowResult { @@ -103,8 +105,6 @@ export interface FollowResult { message?: string; } -// ─── Auth Types ─── - export interface AuthResponse { token: string; user: User;