Browse Source

feat: add book demo

sh 1 năm trước cách đây
mục cha
commit
2beb2e8ae7

+ 227 - 24
pnpm-lock.yaml

@@ -29,9 +29,18 @@ dependencies:
   '@react-native-community/netinfo':
     specifier: ~9.3.0
     version: 9.3.11(react-native@0.70.15)
+  '@react-native-community/push-notification-ios':
+    specifier: ^1.11.0
+    version: 1.11.0(react-native@0.70.15)(react@18.2.0)
   '@react-native-community/slider':
     specifier: ~4.3.2
     version: 4.3.3
+  '@react-native-firebase/app':
+    specifier: ^19.2.2
+    version: 19.3.0(expo@47.0.14)(react-native@0.70.15)(react@18.2.0)
+  '@react-native-firebase/messaging':
+    specifier: ^19.2.2
+    version: 19.3.0(@react-native-firebase/app@19.3.0)(expo@47.0.14)
   '@react-native-masked-view/masked-view':
     specifier: ~0.2.7
     version: 0.2.9(react-native@0.70.15)(react@18.2.0)
@@ -153,18 +162,15 @@ dependencies:
   intl:
     specifier: ^1.2.5
     version: 1.2.5
-  jcore-react-native:
-    specifier: ^2.1.5
-    version: 2.1.6(react-native@0.70.15)
-  jpush-react-native:
-    specifier: ^3.0.6
-    version: 3.0.6(jcore-react-native@2.1.6)(react-native@0.70.15)
   lodash:
     specifier: ^4.17.21
     version: 4.17.21
   mathjs:
     specifier: ^12.3.0
     version: 12.4.1
+  moment:
+    specifier: ^2.30.1
+    version: 2.30.1
   moment-timezone:
     specifier: ^0.5.45
     version: 0.5.45
@@ -189,24 +195,39 @@ dependencies:
   react-native-haptic-feedback:
     specifier: ^2.2.0
     version: 2.2.0(react-native@0.70.15)
+  react-native-leaflet-maps:
+    specifier: ^0.2.1
+    version: 0.2.1(leaflet.markercluster@1.5.3)(leaflet@1.9.4)(react-dom@18.2.0)(react-native@0.70.15)(react@18.2.0)
   react-native-linear-gradient:
     specifier: ^2.8.3
     version: 2.8.3(react-native@0.70.15)(react@18.2.0)
   react-native-localize:
     specifier: ^3.1.0
     version: 3.1.0(react-native@0.70.15)(react@18.2.0)
+  react-native-onesignal:
+    specifier: ^5.2.2
+    version: 5.2.8
   react-native-pager-view:
     specifier: ~6.0.1
     version: 6.0.2(react-native@0.70.15)(react@18.2.0)
   react-native-permissions:
     specifier: ^4.1.5
     version: 4.1.5(react-native@0.70.15)(react@18.2.0)
+  react-native-purchases:
+    specifier: ^7.27.4
+    version: 7.28.1(react-native@0.70.15)(react@18.2.0)
+  react-native-push-notification:
+    specifier: ^8.1.1
+    version: 8.1.1(@react-native-community/push-notification-ios@1.11.0)(react-native@0.70.15)
   react-native-safe-area-context:
     specifier: ~4.4.1
     version: 4.4.1(react-native@0.70.15)(react@18.2.0)
   react-native-screens:
     specifier: ^3.18.2
     version: 3.30.1(react-native@0.70.15)(react@18.2.0)
+  react-native-splash-screen:
+    specifier: ^3.3.0
+    version: 3.3.0(react-native@0.70.15)
   react-native-svg:
     specifier: ~13.7.0
     version: 13.7.0(react-native@0.70.15)(react@18.2.0)
@@ -4799,6 +4820,17 @@ packages:
       react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
     dev: false
 
+  /@react-native-community/push-notification-ios@1.11.0(react-native@0.70.15)(react@18.2.0):
+    resolution: {integrity: sha512-nfkUs8P2FeydOCR4r7BNmtGxAxI22YuGP6RmqWt6c8EEMUpqvIhNKWkRSFF3pHjkgJk2tpRb9wQhbezsqTyBvA==}
+    peerDependencies:
+      react: '>=16.6.3'
+      react-native: '>=0.58.4'
+    dependencies:
+      invariant: 2.2.4
+      react: 18.2.0
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+    dev: false
+
   /@react-native-community/segmented-control@2.2.2(react-native@0.70.15)(react@18.2.0):
     resolution: {integrity: sha512-14+4HnGVrg3USqMzcHCPCqPmPmaEj0ogQH4pHRFXjoVvJokzidXBcYyXl5yrwFcKGW6zTXI6Fx9Qgt4ydtS6tw==}
     peerDependencies:
@@ -4813,6 +4845,36 @@ packages:
     resolution: {integrity: sha512-eVhMaVR08wWlseVWlDS7zgdhbVY0n2i7BF1qRxK+2N1VIKd7NsTIRzL57sFLgHVjbUmu/+hHfxCzLHmEaGxIQg==}
     dev: false
 
+  /@react-native-firebase/app@19.3.0(expo@47.0.14)(react-native@0.70.15)(react@18.2.0):
+    resolution: {integrity: sha512-Y4Jlu7s7JasCcOktAN9LC+7SvMeAG04008OxtbhDK9rpatVznJqRSkMefIIwN2yBs94MWo9b61e+7v5IRMbQdw==}
+    peerDependencies:
+      expo: '>=47.0.0'
+      react: '*'
+      react-native: '*'
+    peerDependenciesMeta:
+      expo:
+        optional: true
+    dependencies:
+      expo: 47.0.14(@babel/core@7.24.4)
+      opencollective-postinstall: 2.0.3
+      react: 18.2.0
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+      superstruct: 0.6.2
+    dev: false
+
+  /@react-native-firebase/messaging@19.3.0(@react-native-firebase/app@19.3.0)(expo@47.0.14):
+    resolution: {integrity: sha512-5nxX2jzRVMG/LwHQygQhl0X/j23FYY8Hn6T8xV3VnYk/O6PXbiQZVpbN5dM7F21aVf4wCFXJnn3jkHIbMP/MYw==}
+    peerDependencies:
+      '@react-native-firebase/app': 19.3.0
+      expo: '>=47.0.0'
+    peerDependenciesMeta:
+      expo:
+        optional: true
+    dependencies:
+      '@react-native-firebase/app': 19.3.0(expo@47.0.14)(react-native@0.70.15)(react@18.2.0)
+      expo: 47.0.14(@babel/core@7.24.4)
+    dev: false
+
   /@react-native-masked-view/masked-view@0.2.9(react-native@0.70.15)(react@18.2.0):
     resolution: {integrity: sha512-Hs4vKBKj+15VxHZHFtMaFWSBxXoOE5Ea8saoigWhahp8Mepssm0ezU+2pTl7DK9z8Y9s5uOl/aPb4QmBZ3R3Zw==}
     peerDependencies:
@@ -4971,6 +5033,10 @@ packages:
       reselect: 4.1.8
     dev: false
 
+  /@revenuecat/purchases-typescript-internal@11.1.1:
+    resolution: {integrity: sha512-xYxv+vZJRPc//XqMAjxtu0fiN66WRmBORap37zA3DQymB9vKAJuTYqv9xMP2xSMglUYsA42M5DgdW2PMBCxfHA==}
+    dev: false
+
   /@rnx-kit/console@1.1.0:
     resolution: {integrity: sha512-N+zFhTSXroiK4eL26vs61Pmtl7wzTPAKLd4JKw9/fk5cNAHUscCXF/uclzuYN61Ye5AwygIvcwbm9wv4Jfa92A==}
 
@@ -9458,6 +9524,16 @@ packages:
       lazy-cache: 1.0.4
       shallow-clone: 0.1.2
 
+  /clone-deep@2.0.2:
+    resolution: {integrity: sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      for-own: 1.0.0
+      is-plain-object: 2.0.4
+      kind-of: 6.0.3
+      shallow-clone: 1.0.0
+    dev: false
+
   /clone-deep@4.0.1:
     resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
     engines: {node: '>=6'}
@@ -12530,6 +12606,13 @@ packages:
     dependencies:
       for-in: 1.0.2
 
+  /for-own@1.0.0:
+    resolution: {integrity: sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      for-in: 1.0.2
+    dev: false
+
   /foreground-child@3.1.1:
     resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
     engines: {node: '>=14'}
@@ -12846,6 +12929,7 @@ packages:
 
   /glob@6.0.4:
     resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==}
+    deprecated: Glob versions prior to v9 are no longer supported
     requiresBuild: true
     dependencies:
       inflight: 1.0.6
@@ -14314,14 +14398,6 @@ packages:
     resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==}
     dev: true
 
-  /jcore-react-native@2.1.6(react-native@0.70.15):
-    resolution: {integrity: sha512-vp8ZypCKkWnaC2eqWPpYUKR4YxSUUDMW6rqfwKmDFzzsUVaz2DNujhIUdvICWRAD5l+I5Sycvc67zxRyxMFYGg==}
-    peerDependencies:
-      react-native: '>= 0.60'
-    dependencies:
-      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
-    dev: false
-
   /jest-get-type@26.3.0:
     resolution: {integrity: sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==}
     engines: {node: '>= 10.14.2'}
@@ -14424,16 +14500,6 @@ packages:
     resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
     dev: false
 
-  /jpush-react-native@3.0.6(jcore-react-native@2.1.6)(react-native@0.70.15):
-    resolution: {integrity: sha512-ajOzIDaX67H/Aplmh42QhC6EtmZRaTgumEdncBQJG0LScRHEHrw1Rill+f705AZ8oGJ8Vjb6dLR4a37MzYkAnQ==}
-    peerDependencies:
-      jcore-react-native: '>= 1.9.3'
-      react-native: '>= 0.50'
-    dependencies:
-      jcore-react-native: 2.1.6(react-native@0.70.15)
-      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
-    dev: false
-
   /js-base64@2.6.4:
     resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==}
     dev: true
@@ -14742,6 +14808,11 @@ packages:
     dependencies:
       is-buffer: 1.1.6
 
+  /kind-of@5.1.0:
+    resolution: {integrity: sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /kind-of@6.0.3:
     resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
     engines: {node: '>=0.10.0'}
@@ -14782,6 +14853,18 @@ packages:
     resolution: {integrity: sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==}
     engines: {node: '>=0.10.0'}
 
+  /leaflet.markercluster@1.5.3(leaflet@1.9.4):
+    resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==}
+    peerDependencies:
+      leaflet: ^1.3.1
+    dependencies:
+      leaflet: 1.9.4
+    dev: false
+
+  /leaflet@1.9.4:
+    resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+    dev: false
+
   /less-loader@10.2.0(less@4.2.0)(webpack@5.91.0):
     resolution: {integrity: sha512-AV5KHWvCezW27GT90WATaDnfXBv99llDbtaj4bshq6DvAihMdNjaPDcUMa6EXKLRF+P2opFenJp89BXg91XLYg==}
     engines: {node: '>= 12.13.0'}
@@ -16620,6 +16703,11 @@ packages:
       is-docker: 2.2.1
       is-wsl: 2.2.0
 
+  /opencollective-postinstall@2.0.3:
+    resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
+    hasBin: true
+    dev: false
+
   /optionator@0.9.3:
     resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
     engines: {node: '>= 0.8.0'}
@@ -17985,6 +18073,34 @@ packages:
   /react-is@18.2.0:
     resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
 
+  /react-leaflet-markercluster@2.0.0(leaflet.markercluster@1.5.3)(leaflet@1.9.4)(react-leaflet@2.8.0):
+    resolution: {integrity: sha512-X1OuaMf4LeAQap638+X46+AN7ZqJKZse84o964brKj4AVVifs4fKCxTTFH6+MoyIarbWvF8x6tJ4vxT2BtxLYg==}
+    peerDependencies:
+      leaflet: ^1.6.0
+      leaflet.markercluster: ^1.4.1
+      react-leaflet: ^2.6.3
+    dependencies:
+      leaflet: 1.9.4
+      leaflet.markercluster: 1.5.3(leaflet@1.9.4)
+      react-leaflet: 2.8.0(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0)
+    dev: false
+
+  /react-leaflet@2.8.0(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-Y7oHtNrrlRH8muDttXf+jZ2Ga/X7jneSGi1GN8uEdeCfLProTqgG2Zoa5TfloS3ZnY20v7w+DIenMG59beFsQw==}
+    peerDependencies:
+      leaflet: ^1.6.0
+      react: ^16.8.0 || ^17.0.0
+      react-dom: ^16.8.0  || ^17.0.0
+    dependencies:
+      '@babel/runtime': 7.24.4
+      fast-deep-equal: 3.1.3
+      hoist-non-react-statics: 3.3.2
+      leaflet: 1.9.4
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      warning: 4.0.3
+    dev: false
+
   /react-lifecycles-compat@3.0.4:
     resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
     dev: false
@@ -18085,6 +18201,24 @@ packages:
   /react-native-known-styling-properties@1.3.0:
     resolution: {integrity: sha512-/sLwzlQnuA6uto4l/c00Z9yIBMDXJaYfoIDh7J92n0K9a6+6KBNXoN7e0TZu2qOtcJNnurk3NjOZFB00dplVsA==}
 
+  /react-native-leaflet-maps@0.2.1(leaflet.markercluster@1.5.3)(leaflet@1.9.4)(react-dom@18.2.0)(react-native@0.70.15)(react@18.2.0):
+    resolution: {integrity: sha512-cx7gqPxJWx7QQuxMJjTzTfrX0x9/x8dhtYJ62lgSGDV9zFpPxSfTvSax6MDgZWSFM4JiDR2dk0tv/q6wXDx87Q==}
+    engines: {node: '>= 16.0.0'}
+    peerDependencies:
+      react: '*'
+      react-native: '*'
+    dependencies:
+      react: 18.2.0
+      react-leaflet: 2.8.0(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0)
+      react-leaflet-markercluster: 2.0.0(leaflet.markercluster@1.5.3)(leaflet@1.9.4)(react-leaflet@2.8.0)
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+      react-native-webview: 11.26.1(react-native@0.70.15)(react@18.2.0)
+    transitivePeerDependencies:
+      - leaflet
+      - leaflet.markercluster
+      - react-dom
+    dev: false
+
   /react-native-linear-gradient@2.8.3(react-native@0.70.15)(react@18.2.0):
     resolution: {integrity: sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==}
     peerDependencies:
@@ -18149,6 +18283,12 @@ packages:
       react-native-animatable: 1.3.3
     dev: false
 
+  /react-native-onesignal@5.2.8:
+    resolution: {integrity: sha512-NZ0bq/z7Cjzb+mb/YJSC4atJ6S2yOGANkwzTQwUBAaMvhNuGzWJfWsj/gfhyS0AFTQScJV6Tsj1cR5nz2SEd3A==}
+    dependencies:
+      invariant: 2.2.4
+    dev: false
+
   /react-native-pager-view@6.0.2(react-native@0.70.15)(react@18.2.0):
     resolution: {integrity: sha512-XL3Qc9k7o0BykclGHtuRUz97FpF6rcKbP8LqszLeS2hKnINYcbUPYqg46EhbwVhFOUJE+XhT3idrSO1e/D6jtQ==}
     peerDependencies:
@@ -18173,6 +18313,27 @@ packages:
       react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
     dev: false
 
+  /react-native-purchases@7.28.1(react-native@0.70.15)(react@18.2.0):
+    resolution: {integrity: sha512-5nd8s79dq+O9O+B0Gn9QNrKHn63kMNm5NQx1mnN8awBaDe95YpjqBaAgmVI/K66BSEDAkzTR0ep2BzUMawNmtQ==}
+    peerDependencies:
+      react: '>= 16.6.3'
+      react-native: '*'
+    dependencies:
+      '@revenuecat/purchases-typescript-internal': 11.1.1
+      react: 18.2.0
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+    dev: false
+
+  /react-native-push-notification@8.1.1(@react-native-community/push-notification-ios@1.11.0)(react-native@0.70.15):
+    resolution: {integrity: sha512-XpBtG/w+a6WXTxu6l1dNYyTiHnbgnvjoc3KxPTxYkaIABRmvuJZkFxqruyGvfCw7ELAlZEAJO+dthdTabCe1XA==}
+    peerDependencies:
+      '@react-native-community/push-notification-ios': ^1.10.1
+      react-native: '>=0.33'
+    dependencies:
+      '@react-native-community/push-notification-ios': 1.11.0(react-native@0.70.15)(react@18.2.0)
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+    dev: false
+
   /react-native-root-siblings@4.1.1:
     resolution: {integrity: sha512-sdmLElNs5PDWqmZmj4/aNH4anyxreaPm61c4ZkRiR8SO/GzLg6KjAbb0e17RmMdnBdD0AIQbS38h/l55YKN4ZA==}
     dev: false
@@ -18210,6 +18371,14 @@ packages:
       warn-once: 0.1.1
     dev: false
 
+  /react-native-splash-screen@3.3.0(react-native@0.70.15):
+    resolution: {integrity: sha512-rGjt6HkoSXxMqH4SQUJ1gnPQlPJV8+J47+4yhgTIan4bVvAwJhEeJH7wWt9hXSdH4+VfwTS0GTaflj1Tw83IhA==}
+    peerDependencies:
+      react-native: '>=0.57.0'
+    dependencies:
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+    dev: false
+
   /react-native-stylekit@1.0.0:
     resolution: {integrity: sha512-iG7Fqpjo81QfAFwsj09XpexZlNPHTZTIclioaHqQDjcNt62ehlSwSgKFxNeQ2RkW9M4dgQbOJHN+pniFjSQ9rA==}
     dev: false
@@ -18256,6 +18425,18 @@ packages:
       react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
     dev: false
 
+  /react-native-webview@11.26.1(react-native@0.70.15)(react@18.2.0):
+    resolution: {integrity: sha512-hC7BkxOpf+z0UKhxFSFTPAM4shQzYmZHoELa6/8a/MspcjEP7ukYKpuSUTLDywQditT8yI9idfcKvfZDKQExGw==}
+    peerDependencies:
+      react: '*'
+      react-native: '*'
+    dependencies:
+      escape-string-regexp: 2.0.0
+      invariant: 2.2.4
+      react: 18.2.0
+      react-native: 0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0)
+    dev: false
+
   /react-native@0.70.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0):
     resolution: {integrity: sha512-pm2ZPpA+m0Kl0THAy2fptnp7B9+QPexpfad9fSXfqjPufrXG2alwW8kYCn2EO5ZUX6bomZjFEswz6RzdRN/p9A==}
     engines: {node: '>=14'}
@@ -19336,6 +19517,15 @@ packages:
       lazy-cache: 0.2.7
       mixin-object: 2.0.1
 
+  /shallow-clone@1.0.0:
+    resolution: {integrity: sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      is-extendable: 0.1.1
+      kind-of: 5.1.0
+      mixin-object: 2.0.1
+    dev: false
+
   /shallow-clone@3.0.1:
     resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
     engines: {node: '>=8'}
@@ -20118,6 +20308,13 @@ packages:
       postcss: 6.0.23
     dev: true
 
+  /superstruct@0.6.2:
+    resolution: {integrity: sha512-lvA97MFAJng3rfjcafT/zGTSWm6Tbpk++DP6It4Qg7oNaeM+2tdJMuVgGje21/bIpBEs6iQql1PJH6dKTjl4Ig==}
+    dependencies:
+      clone-deep: 2.0.2
+      kind-of: 6.0.3
+    dev: false
+
   /supports-color@2.0.0:
     resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}
     engines: {node: '>=0.8.0'}
@@ -21428,6 +21625,12 @@ packages:
     resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==}
     dev: false
 
+  /warning@4.0.3:
+    resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
+    dependencies:
+      loose-envify: 1.4.0
+    dev: false
+
   /watchpack@2.4.1:
     resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==}
     engines: {node: '>=10.13.0'}

+ 145 - 0
src/_book/pages/book_history.tsx

@@ -0,0 +1,145 @@
+import { Image, View } from "@tarojs/components";
+import { useEffect, useState } from "react";
+
+import { AtButton, AtCard, AtTag } from 'taro-ui'
+import "taro-ui/dist/style/components/tabs.scss"
+import "taro-ui/dist/style/components/card.scss"
+import "taro-ui/dist/style/components/flex.scss"
+import "taro-ui/dist/style/components/tag.scss"
+import "taro-ui/dist/style/components/button.scss"
+
+import { deleteCourseBookOrder, getCourseBookOrders } from "@services/book";
+import dayjs from "dayjs";
+import { jumpPage } from "@features/trackTimeDuration/hooks/Common";
+import Taro from "@tarojs/taro";
+
+export default function BookHistory() {
+  const [loading, setLoading] = useState(true)
+  const [deleteing, setDeleteing] = useState<{ [key: string]: boolean }>({})
+  const [dataList, setDataList] = useState<any[]>([])
+  const [reload, setReload] = useState(0)
+
+  useEffect(() => {
+  }, [])
+
+  useEffect(() => {
+    loadHistories()
+  }, [reload])
+
+  function loadHistories() {
+    setLoading(true)
+    getCourseBookOrders()
+      .then((res: any) => {
+        const data = res.data || []
+        setDataList(data.map(item => {
+          const start = dayjs(item.timestampFrom)
+          return {
+            ...item,
+            dateDesc: `${start.format('YYYY-MM-DD')}(${start.format('ddd')})`
+          }
+        }))
+      })
+      .finally(() => {
+        setLoading(false)
+      })
+  }
+
+  const onDeleteClick = (item: any) => {
+    setDeleteing({
+      ...deleteing,
+      [item.userCourse.id]: true
+    })
+    deleteCourseBookOrder(item.userCourse.id)
+      .then(() => {
+        Taro.showToast({ title: '删除成功', icon: 'none' })
+        setReload(reload + 1)
+      })
+      .catch(err => {
+        const errorMessage = err && err.errorMessage || "删除失败,请稍后再试"
+        Taro.showToast({ title: errorMessage, icon: 'none' })
+      })
+      .finally(() => {
+        setDeleteing({
+          ...deleteing,
+          [item.userCourse.id]: false
+        })
+      })
+  }
+
+  const viewCourseDetail = (item: any) => {
+    jumpPage(`/_book/pages/course_detail?id=${item.id}&userCourseId=${item.userCourse.id || ''}`)
+  }
+
+  return <View style={{ position: 'relative' }}>
+    <View style={{ padding: '10px' }}>
+      <AtButton type='primary' onClick={() => setReload(reload + 1)}>Reload {loading ? '' : dataList.length}</AtButton>
+    </View>
+    {loading && <View>Loading</View>}
+    {!loading && (
+      <View>
+        {dataList.length === 0 && <View>No data</View>}
+        {dataList.length > 0 && dataList.map((item: any, index) => {
+          return (
+            <View key={index} style={{ marginTop: '5px' }}>
+              <AtCard
+                title={`${item.dateDesc} ${item.timeFrom}-${item.timeTo} ${item.instructor.nicknameCn} ${item.instructor.nicknameEn}`}
+              >
+                <View className='at-row'>
+                  <View className='at-col at-col-3'>
+                    <Image
+                      style='width:100%;' mode='widthFix' src={item.instructor.avatar}
+                    />
+                  </View>
+                  <View className='at-col at-col-9' style={{ padding: '5px' }}>
+                    <View>{item.course.nameCn} {item.course.nameEn}</View>
+                    <View style={{ marginTop: '5px;' }}>
+                      {item.course.tags.map((tag, tagIndex) => {
+                        return <AtTag key={tagIndex} size='small'>{tag}</AtTag>
+                      })}
+                    </View>
+                    <View className='at-row' style={{ marginTop: '5px;' }}>
+                      <View
+                        className='at-col at-col-1 at-col--auto'
+                        style={{ paddingTop: '6px;', color: 'red', fontSize: '18px' }}
+                      >
+                        ¥ {item.fee * 0.01}
+                      </View>
+                      <View
+                        className='at-col'
+                        style={{ paddingTop: '6px;', paddingLeft: '10px', color: 'gray', fontSize: '12px' }}
+                      >
+                      </View>
+                      <View
+                        className='at-col at-col-1 at-col--auto'
+                        style={{ paddingTop: '6px;', paddingRight: '10px', color: 'gray', fontSize: '12px' }}
+                      >
+                        <AtButton
+                          loading={deleteing[item.userCourse.id]} type='secondary' size='small'
+                          style={{ width: '80px' }} onClick={() => onDeleteClick(item)}
+                        >
+                          删除
+                        </AtButton>
+                      </View>
+                      <View
+                        className='at-col at-col-1 at-col--auto'
+                        style={{ paddingTop: '6px;', color: 'red', fontSize: '18px' }}
+                      >
+                        <AtButton type='secondary' size='small' onClick={() => viewCourseDetail(item)}>
+                          {item.userCourse.status === 'WAIT_FOR_PAY' && '待支付'}
+                          {item.userCourse.status === 'TO_BE_VERIFIED' && '待核销'}
+                          {item.userCourse.status === 'COMPLETED' && '已完成'}
+                          {item.userCourse.status === 'CLOSED' && '已关闭'}
+                          {item.userCourse.status === 'CANCELLED' && '已取消'}
+                        </AtButton>
+                      </View>
+                    </View>
+                  </View>
+                </View>
+              </AtCard>
+            </View>
+          )
+        })}
+      </View>
+    )}
+  </View>
+}

+ 55 - 0
src/_book/pages/book_verification.tsx

@@ -0,0 +1,55 @@
+import { View } from "@tarojs/components";
+import { useState } from "react";
+
+import { AtButton } from 'taro-ui'
+import "taro-ui/dist/style/components/tabs.scss"
+import "taro-ui/dist/style/components/card.scss"
+import "taro-ui/dist/style/components/flex.scss"
+import "taro-ui/dist/style/components/tag.scss"
+import "taro-ui/dist/style/components/button.scss"
+import { verifyCourseBookOrder } from "@services/book";
+import Taro from "@tarojs/taro";
+
+export default function BookVerification() {
+  const [loading, setLoading] = useState(false)
+  const [result, setResult] = useState<any>({})
+  const [qrCode, setQrCode] = useState('')
+
+  function onScanClick() {
+    Taro.scanCode({
+      success: (res) => {
+        setQrCode(res.result);
+        verifyQrCode(res.result)
+      },
+      fail: () => {
+        Taro.showToast({
+          title: '扫码失败',
+          icon: 'none',
+          duration: 2000
+        });
+      }
+    });
+  }
+
+  const verifyQrCode = (verifyCode: string) => {
+    setLoading(true)
+    verifyCourseBookOrder(verifyCode)
+      .then((res: any) => {
+        setResult(res)
+      })
+      .finally(() => {
+        setLoading(false)
+      })
+  }
+
+  return <View style={{ position: 'relative' }}>
+    <AtButton loading={loading} type='primary' onClick={onScanClick}>Scan</AtButton>
+    <View style={{ textAlign: 'center', fontSize: '20px' }}>
+      {qrCode.length > 0 && '核销码:' + qrCode}
+    </View>
+    <View style={{ textAlign: 'center', fontSize: '20px', color: 'blue', padding: '10px' }}>
+      {loading && <View>Loading</View>}
+      {!loading && <View>{result.error}</View>}
+    </View>
+  </View>
+}

+ 238 - 0
src/_book/pages/course_detail.tsx

@@ -0,0 +1,238 @@
+import { View, Image } from "@tarojs/components";
+import { useEffect, useState } from "react";
+import dayjs from "dayjs";
+
+import Taro, { useRouter } from "@tarojs/taro";
+import { AtCard, AtTag, AtButton, AtCountdown } from 'taro-ui'
+import "taro-ui/dist/style/components/tabs.scss"
+import "taro-ui/dist/style/components/card.scss"
+import "taro-ui/dist/style/components/flex.scss"
+import "taro-ui/dist/style/components/tag.scss"
+import "taro-ui/dist/style/components/button.scss"
+import "taro-ui/dist/style/components/countdown.scss"
+
+import { cancelCourseBookOrder, createCourseBookOrder, getCourseBookOrder, getCourseDetail } from "@services/book";
+
+let _Reload_Timer
+
+export default function CourseDetail() {
+  const router = useRouter()
+  const { id, userCourseId } = router.params
+
+  const [dateDesc, setDateDesc] = useState('')
+  const [course, setCourse] = useState<any>({})
+  const [_, setOrder] = useState<any>({})
+
+  const [loading, setLoading] = useState(true)
+  const [booking, setBooking] = useState(false)
+  const [cancelling, setCancelling] = useState(false)
+  const [reload, setReload] = useState(0)
+  const [count, setCount] = useState({
+    day: 0,
+    hours: 0,
+    minutes: 0,
+    seconds: 0
+  })
+
+  useEffect(() => {
+    _Reload_Timer = setInterval(() => {
+      loadCourseDetail(false)
+    }, 2000)
+
+    return () => {
+      _Reload_Timer && clearInterval(_Reload_Timer)
+    }
+  }, []);
+
+  useEffect(() => {
+    loadCourseDetail()
+  }, [reload])
+
+  useEffect(() => {
+    const now = dayjs()
+    if (course && course.timestampFrom > 0 && now.valueOf() < course.timestampFrom) {
+      const diff = Math.abs(course.timestampFrom - now.valueOf());
+      const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+      const remainingMs = diff % (1000 * 60 * 60 * 24)
+      const hours = Math.floor(remainingMs / (1000 * 60 * 60))
+      const remainingMsAfterHours = remainingMs % (1000 * 60 * 60)
+      const minutes = Math.floor(remainingMsAfterHours / (1000 * 60))
+      const remainingMsAfterMinutes = remainingMsAfterHours % (1000 * 60)
+      const seconds = Math.floor(remainingMsAfterMinutes / 1000)
+      setCount({
+        day: days,
+        hours: hours,
+        minutes: minutes,
+        seconds: seconds
+      })
+    }
+  }, [course]);
+
+  function loadCourseDetail(showLoading: boolean = true) {
+    if (!id && !userCourseId) {
+      return
+    }
+    showLoading && setLoading(true)
+    if (userCourseId && userCourseId.length > 0) {
+      getCourseBookOrder(userCourseId || '')
+        .then((res: any) => {
+          setCourse(res)
+          const start = dayjs(res.timestampFrom)
+          setDateDesc(`${start.format('YYYY-MM-DD')}(${start.format('ddd')})`)
+        })
+        .finally(() => {
+          setLoading(false)
+        })
+    } else {
+      getCourseDetail(id || '')
+        .then((res: any) => {
+          setCourse(res)
+          const start = dayjs(res.timestampFrom)
+          setDateDesc(`${start.format('YYYY-MM-DD')}(${start.format('ddd')})`)
+        })
+        .finally(() => {
+          setLoading(false)
+        })
+    }
+  }
+
+  const onBookClick = () => {
+    setBooking(true)
+    createCourseBookOrder(course.id)
+      .then((res: any) => {
+        setOrder(res)
+        const credential = res.credential || {}
+        Taro.requestPayment({
+          ...credential,
+          success() {
+            console.log('pay success')
+            setReload(reload + 1)
+          },
+          fail() {
+            console.log('pay fail')
+            setReload(reload + 1)
+          },
+        })
+      })
+      .catch(err => {
+        const errorMessage = err && err.errorMessage || "下单失败,请稍后再试"
+        Taro.showToast({ title: errorMessage, icon: 'none' })
+      })
+      .finally(() => {
+        setBooking(false)
+      })
+  }
+
+  const onCancelClick = () => {
+    setCancelling(true)
+    cancelCourseBookOrder(course.userCourse.id)
+      .then(() => {
+        Taro.showToast({ title: '取消成功', icon: 'none' })
+        setReload(reload + 1)
+      })
+      .catch(err => {
+        const errorMessage = err && err.errorMessage || "取消失败,请稍后再试"
+        Taro.showToast({ title: errorMessage, icon: 'none' })
+      })
+      .finally(() => {
+        setCancelling(false)
+      })
+  }
+
+  return (
+    <View style={{ position: 'relative' }}>
+      {loading && <View>Loading</View>}
+      {!loading && (
+        <View style={{ marginTop: '10px' }}>
+          {course.userCourse && course.userCourse.status === 'TO_BE_VERIFIED' && (
+            <View style={{ textAlign: 'center' }}>
+              <View>待核销</View>
+              <View>
+                <Image
+                  src={`https://api.fast.dev.liveplus.fun/api/qr?data=${course.userCourse.orderNo}`}
+                  style={{ width: '200px', height: '200px' }}
+                />
+              </View>
+            </View>
+          )}
+          {course.userCourse && course.userCourse.status === 'COMPLETED' && (
+            <View style={{ textAlign: 'center' }}>
+              <View>已完成</View>
+            </View>
+          )}
+          {course.userCourse && course.userCourse.status === 'CANCELLED' && (
+            <View style={{ textAlign: 'center' }}>
+              <View>已取消</View>
+            </View>
+          )}
+          <AtCard
+            title={`${dateDesc} ${course.timeFrom}-${course.timeTo} ${course.instructor.nicknameCn} ${course.instructor.nicknameEn}`}
+            isFull
+          >
+            <View className='at-row'>
+              <View className='at-col at-col-3'>
+                <Image
+                  style='width:100%;' mode='widthFix' src={course.instructor.avatar}
+                />
+              </View>
+              <View className='at-col at-col-9' style={{ padding: '5px' }}>
+                <View>{course.course.nameCn} {course.course.nameEn}</View>
+                <View style={{ marginTop: '5px;' }}>
+                  {course.course.tags.map((tag, tagIndex) => {
+                    return <AtTag key={tagIndex} size='small'>{tag}</AtTag>
+                  })}
+                </View>
+                <View className='at-row' style={{ marginTop: '5px;' }}>
+                  <View
+                    className='at-col at-col-1 at-col--auto'
+                    style={{ paddingTop: '6px;', color: 'red', fontSize: '18px' }}
+                  >
+                    ¥ {course.fee * 0.01}
+                  </View>
+                  <View
+                    className='at-col'
+                    style={{ paddingTop: '10px;', paddingLeft: '10px', color: 'gray', fontSize: '12px' }}
+                  >
+                    最后{course.quotaTotal - course.quotaLocked}个名额
+                  </View>
+                  <View className='at-col at-col-1 at-col--auto'>
+
+                  </View>
+                </View>
+              </View>
+            </View>
+          </AtCard>
+          <View style={{ marginTop: '5px', marginBottom: '5px' }}>
+            距开课还有
+            <AtCountdown
+              isCard
+              isShowDay
+              day={count.day}
+              hours={count.hours}
+              minutes={count.minutes}
+              seconds={count.seconds}
+            />
+          </View>
+          <View>
+            {course.isReserved && (
+              <View>
+                {course.userCourse.status == 'WAIT_FOR_PAY' && course.status == 'AVAILABLE' &&
+                  <AtButton full loading={booking} type='primary' onClick={onBookClick}>Book</AtButton>}
+                {course.userCourse.status == 'TO_BE_VERIFIED' &&
+                  <AtButton full loading={cancelling} type='primary' onClick={onCancelClick}>Cancel</AtButton>}
+                {course.userCourse.status == 'CANCELLED' && course.status == 'AVAILABLE' &&
+                  <AtButton full loading={booking} type='primary' onClick={onBookClick}>Re Book</AtButton>}
+              </View>
+            )}
+            {!course.isReserved && course.status == 'AVAILABLE' && (
+              <AtButton full loading={booking} type='primary' onClick={onBookClick}>Book</AtButton>
+            )}
+            {!course.isReserved && course.status == 'AFTER' && (
+              <AtButton full disabled>已过预约时间</AtButton>
+            )}
+          </View>
+        </View>
+      )}
+    </View>
+  )
+}

+ 211 - 0
src/_book/pages/course_edit.tsx

@@ -0,0 +1,211 @@
+import { View } from "@tarojs/components"
+import { useEffect, useState } from "react";
+import dayjs from "dayjs";
+
+import { AtButton, AtRadio, AtInput, AtInputNumber } from 'taro-ui'
+import "taro-ui/dist/style/components/tabs.scss"
+import "taro-ui/dist/style/components/card.scss"
+import "taro-ui/dist/style/components/flex.scss"
+import "taro-ui/dist/style/components/tag.scss"
+import "taro-ui/dist/style/components/button.scss"
+import "taro-ui/dist/style/components/radio.scss"
+import "taro-ui/dist/style/components/icon.scss"
+import "taro-ui/dist/style/components/input.scss"
+import "taro-ui/dist/style/components/input-number.scss"
+
+import {
+  getCourseCatalogs,
+  getGyms, getInstructors, saveCourseSchedule
+} from "@services/book";
+import Taro from "@tarojs/taro";
+
+export default function CourseEdit() {
+  const [loading, setLoading] = useState(false)
+  const [gyms, setGyms] = useState<any[]>([])
+  const [instructors, setInstructors] = useState<any[]>([])
+  const [courseCatalogs, setCourseCatalogs] = useState<any[]>([])
+  const [data, setData] = useState<any>({
+    fee: 1,
+    quotaTotal: 1
+  })
+  const [dates, setDates] = useState<DateInfo[]>([])
+
+  useEffect(() => {
+    loadData()
+    setDates(generateDates(5))
+  }, [])
+
+  function loadData() {
+    getGyms().then((res: any) => {
+      setGyms(res.data || [])
+    })
+    getInstructors().then((res: any) => {
+      setInstructors(res.data || [])
+    })
+    getCourseCatalogs().then((res: any) => {
+      setCourseCatalogs(res.data || [])
+    })
+  }
+
+  const generateDates = (days: number): DateInfo[] => {
+    const dateInfos: DateInfo[] = []
+    const now = dayjs()
+    for (let i = 0; i < days; i++) {
+      const day = now.add(i, 'day')
+      const date = day.format('YYYYMMDD')
+      const subTitle = day.format('MM/DD')
+      let title = day.format('ddd')
+      if (i === 0) {
+        title = 'Today'
+      } else if (i === 1) {
+        title = 'Tomorrow'
+      }
+      dateInfos.push({
+        date: parseInt(date),
+        subTitle,
+        title
+      })
+    }
+    return dateInfos;
+  }
+
+  const onSaveClick = () => {
+    setLoading(true)
+    saveCourseSchedule(data)
+      .then(() => {
+        Taro.showToast({ title: '保存成功', icon: 'none' })
+        Taro.navigateBack({ delta: 1 })
+      })
+      .catch(err => {
+        const errorMessage = err && err.errorMessage || "保存失败,请稍后再试"
+        Taro.showToast({ title: errorMessage, icon: 'none' })
+      })
+      .finally(() => {
+        setLoading(false)
+      })
+  }
+
+  return (
+    <View style={{ position: 'relative' }}>
+      <View>
+        <View style={{ padding: '10px' }}>课程目录</View>
+        <AtRadio
+          options={courseCatalogs.map((item: any) => {
+            return { label: item.nameCn, value: item.id, desc: item.nameEn }
+          })}
+          value={data.courseCatalogId}
+          onClick={(value: any) => {
+            setData({ ...data, courseCatalogId: value })
+          }}
+        />
+
+        <View style={{ padding: '10px' }}>教练</View>
+        <AtRadio
+          options={instructors.map((item: any) => {
+            return { label: item.nicknameCn, value: item.id, desc: item.nicknameEn }
+          })}
+          value={data.instructorId}
+          onClick={(value: any) => {
+            setData({ ...data, instructorId: value })
+          }}
+        />
+
+        <View style={{ padding: '10px' }}>场地</View>
+        <AtRadio
+          options={gyms.map((item: any) => {
+            return { label: item.nameCn, value: item.id, desc: `${item.businessTimeFrom}-${item.businessTimeTo}` }
+          })}
+          value={data.gymId}
+          onClick={(value: any) => {
+            setData({ ...data, gymId: value })
+          }}
+        />
+      </View>
+
+      <View style={{ padding: '10px' }}>日期</View>
+      <AtRadio
+        options={dates.map((item: any) => {
+          return { label: `${item.title} ${item.subTitle}`, value: item.date }
+        })}
+        value={data.date}
+        onClick={(value: any) => {
+          setData({ ...data, date: value })
+        }}
+      />
+
+      <View style={{ padding: '10px' }}>
+        <View className='at-row'>
+          <View className='at-col at-col-1 at-col--auto' style={{ paddingTop: '10px' }}>
+            开始时间 HH:mm
+          </View>
+          <View className='at-col'>
+            <AtInput
+              name='timeFrom'
+              title=''
+              type='text'
+              placeholder='HH:mm'
+              value={data.timeFrom}
+              onChange={(value) => {
+                setData({ ...data, timeFrom: value })
+              }}
+            />
+          </View>
+        </View>
+
+        <View className='at-row'>
+          <View className='at-col at-col-1 at-col--auto' style={{ paddingTop: '10px' }}>
+            结束时间 HH:mm
+          </View>
+          <View className='at-col'>
+            <AtInput
+              name='timeTo'
+              title=''
+              type='text'
+              placeholder='HH:mm'
+              value={data.timeTo}
+              onChange={(value) => {
+                setData({ ...data, timeTo: value })
+              }}
+            />
+          </View>
+        </View>
+
+        <View className='at-row'>
+          <View className='at-col at-col-1 at-col--auto' style={{ paddingTop: '2px' }}>
+            费用(分)
+          </View>
+          <View className='at-col'>
+            <AtInputNumber
+              type='number'
+              min={1}
+              step={1}
+              value={data.fee}
+              onChange={(value) => {
+                setData({ ...data, fee: value })
+              }}
+            />
+          </View>
+        </View>
+
+        <View className='at-row'>
+          <View className='at-col at-col-1 at-col--auto' style={{ paddingTop: '2px' }}>
+            名额
+          </View>
+          <View className='at-col'>
+            <AtInputNumber
+              type='number'
+              min={1}
+              step={1}
+              value={data.quotaTotal}
+              onChange={(value) => {
+                setData({ ...data, quotaTotal: value })
+              }}
+            />
+          </View>
+        </View>
+      </View>
+
+      <AtButton full loading={loading} type='primary' onClick={onSaveClick}>Save</AtButton>
+    </View>
+  )
+}

+ 165 - 0
src/_book/pages/course_list.tsx

@@ -0,0 +1,165 @@
+import { View, Image } from "@tarojs/components";
+import { useEffect, useState } from "react";
+import dayjs from "dayjs";
+
+import { AtTabs, AtCard, AtTag, AtButton } from 'taro-ui'
+import "taro-ui/dist/style/components/tabs.scss"
+import "taro-ui/dist/style/components/card.scss"
+import "taro-ui/dist/style/components/flex.scss"
+import "taro-ui/dist/style/components/tag.scss"
+import "taro-ui/dist/style/components/button.scss"
+
+import { getBookListByDate } from "@services/book";
+import { TabItem } from "taro-ui/types/tabs";
+import { jumpPage } from "@features/trackTimeDuration/hooks/Common";
+
+export default function CourseList() {
+  const [loading, setLoading] = useState(true)
+  const [tabIndex, setTabIndex] = useState(0)
+  const [dates, setDates] = useState<DateInfo[]>([])
+  const [courses, setCourses] = useState<any[]>([])
+  const [reload, setReload] = useState(0)
+
+  useEffect(() => {
+    const dateInfos = generateDates(10)
+    setDates(dateInfos)
+    loadBookListByDate(dateInfos[0].date)
+  }, [])
+
+  useEffect(() => {
+    if (reload > 0) {
+      loadBookListByDate(dates[tabIndex].date)
+    }
+  }, [reload])
+
+  const generateDates = (days: number): DateInfo[] => {
+    const dateInfos: DateInfo[] = []
+    const now = dayjs()
+    for (let i = 0; i < days; i++) {
+      const day = now.add(i, 'day')
+      const date = day.format('YYYYMMDD')
+      const subTitle = day.format('MM/DD')
+      let title = day.format('ddd')
+      if (i === 0) {
+        title = 'Today'
+      } else if (i === 1) {
+        title = 'Tomorrow'
+      }
+      dateInfos.push({
+        date: parseInt(date),
+        subTitle,
+        title
+      })
+    }
+    return dateInfos;
+  }
+
+  function loadBookListByDate(date: number) {
+    setLoading(true)
+    getBookListByDate(date)
+      .then((res: any) => {
+        setCourses(res.data)
+      })
+      .finally(() => {
+        setLoading(false)
+      })
+  }
+
+  function onTabClick(index: number) {
+    setTabIndex(index)
+    const dateInfo = dates[index]
+    loadBookListByDate(dateInfo.date)
+  }
+
+  const tabList: TabItem[] = dates.map((dateInfo) => {
+    return { title: `${dateInfo.title} ${dateInfo.subTitle}` }
+  })
+
+  const viewCourseDetail = (item: any) => {
+    jumpPage(`/_book/pages/course_detail?id=${item.id}&myReservedId=${item.myReservedId || ''}`)
+  }
+
+  const openCourseEdit = () => {
+    jumpPage(`/_book/pages/course_edit`)
+  }
+
+  return <View style={{ position: 'relative' }}>
+    <View style={{ padding: '10px' }}>
+      <View className='at-row' style={{ marginTop: '5px;' }}>
+        <View className='at-col at-col-6' style={{ padding: '8px' }}>
+          <AtButton type='primary' onClick={() => setReload(reload + 1)}>Reload</AtButton>
+        </View>
+        <View className='at-col at-col-6' style={{ padding: '8px' }}>
+          <AtButton type='primary' onClick={openCourseEdit}>Add</AtButton>
+        </View>
+      </View>
+    </View>
+    <AtTabs
+      scroll current={tabIndex} tabList={tabList} onClick={onTabClick}
+    />
+    {loading && <View>Loading</View>}
+    {!loading && courses.length == 0 && <View>No Course</View>}
+    {!loading && courses.length > 0 && courses.map((course, index) => {
+      return (
+        <View key={index} style={{ marginTop: '5px' }}>
+          <AtCard
+            title={`${course.timeFrom}-${course.timeTo} ${course.instructor.nicknameCn} ${course.instructor.nicknameEn}`}
+          >
+            <View className='at-row'>
+              <View className='at-col at-col-3'>
+                <Image
+                  style='width:100%;' mode='widthFix' src={course.instructor.avatar}
+                />
+              </View>
+              <View className='at-col at-col-9' style={{ padding: '5px' }}>
+                <View>{course.course.nameCn} {course.course.nameEn}</View>
+                <View style={{ marginTop: '5px;' }}>
+                  {course.course.tags.map((tag, tagIndex) => {
+                    return <AtTag key={tagIndex} size='small'>{tag}</AtTag>
+                  })}
+                </View>
+                <View className='at-row' style={{ marginTop: '5px;' }}>
+                  <View
+                    className='at-col at-col-1 at-col--auto'
+                    style={{ paddingTop: '6px;', color: 'red', fontSize: '18px' }}
+                  >
+                    ¥ {course.fee * 0.01}
+                  </View>
+                  <View
+                    className='at-col'
+                    style={{ paddingTop: '10px;', paddingLeft: '10px', color: 'gray', fontSize: '12px' }}
+                  >
+                    最后{course.quotaTotal - course.quotaLocked}个名额
+                  </View>
+                  <View className='at-col at-col-1 at-col--auto'>
+                    {course.isReserved && (
+                      <AtButton type='secondary' size='small' onClick={() => viewCourseDetail(course)}>
+                        {course.myReservedStatus == 'TO_BE_VERIFIED' || course.myReservedStatus == 'COMPLETED' ? 'Booked' : (
+                          course.status == 'AFTER' ? (course.myReservedStatus == 'CANCELLED' ? 'Cancelled' : '已过预约时间') : 'Re Book'
+                        )}
+                      </AtButton>
+                    )}
+                    {!course.isReserved && course.status == 'AVAILABLE' && (
+                      <AtButton type='secondary' size='small' onClick={() => viewCourseDetail(course)}>
+                        Book
+                      </AtButton>
+                    )}
+                    {!course.isReserved && course.status == 'AFTER' && (
+                      <AtButton
+                        type='secondary'
+                        size='small'
+                        onClick={() => viewCourseDetail(course)}
+                      >
+                        已过预约时间
+                      </AtButton>
+                    )}
+                  </View>
+                </View>
+              </View>
+            </View>
+          </AtCard>
+        </View>
+      )
+    })}
+  </View>
+}

+ 6 - 0
src/_book/pages/types.ts

@@ -0,0 +1,6 @@
+
+type DateInfo = {
+  date: number,
+  title: string,
+  subTitle: string
+}

+ 10 - 1
src/app.config.ts

@@ -2,7 +2,6 @@ import { kIsIOS } from "./utils/tools";
 
 const appConfig = defineAppConfig({
   pages: [
-
     'pages/clock/Clock',
     'pages/common/H5',
     'pages/common/RecordsHistory',
@@ -26,6 +25,16 @@ const appConfig = defineAppConfig({
   //   }
   // ],
   subPackages: [
+    {
+      root: '_book',
+      pages: [
+        'pages/course_list',
+        'pages/course_detail',
+        'pages/course_edit',
+        'pages/book_history',
+        'pages/book_verification',
+      ]
+    },
     {
       root: '_health',
       pages: [

+ 28 - 2
src/pages/account/Profile.tsx

@@ -402,7 +402,7 @@ export default function Page() {
                 showLine
                 icon={<Image src={require('@assets/_health/setting_album.png')} className="profile_cell_icon" />}
             />
-            
+
             <IconTitleCell
                 onClick={() => {
                     jumpPage('/pages/account/Journal')
@@ -467,6 +467,32 @@ export default function Page() {
                     icon={<Image src={require('@assets/_health/setting_more.png')} className="profile_cell_icon" />}
                 />
             }
+
+          {
+            user.isLogin && user.test_user && <>
+              <IconTitleCell
+                onClick={() => {
+                  jumpPage('/_book/pages/course_list')
+                }}
+                title='Book'
+                icon={<Image src={require('@assets/_health/setting_more.png')} className="profile_cell_icon" />}
+              />
+              <IconTitleCell
+                onClick={() => {
+                  jumpPage('/_book/pages/book_history')
+                }}
+                title='Book Histories'
+                icon={<Image src={require('@assets/_health/setting_more.png')} className="profile_cell_icon" />}
+              />
+              <IconTitleCell
+                onClick={() => {
+                  jumpPage('/_book/pages/book_verification')
+                }}
+                title='Verification'
+                icon={<Image src={require('@assets/_health/setting_more.png')} className="profile_cell_icon" />}
+              />
+            </>
+          }
             {/* <View className="a1">测试ABCDabcd1234 weight 100</View>
             <View className="a2">测试ABCDabcd1234 weight 200</View>
             <View className="a3">测试ABCDabcd1234 weight 300</View>
@@ -503,4 +529,4 @@ export default function Page() {
         </Layout>
         <Tabbar index={3} />
     </View>
-}
+}

+ 153 - 0
src/services/book.tsx

@@ -0,0 +1,153 @@
+import { request } from "@services/http/request";
+
+// const _HOST = 'http://192.168.1.141:8866'
+const _HOST = 'https://api.fast.dev.liveplus.fun'
+
+export const saveCourseSchedule = (data: any) => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/course-schedules`
+    request({
+      url: url, method: 'POST', data: data
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getBookListByDate = (date) => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/course-schedules?date=${date}`
+    request({
+      url: url, method: 'GET', data: {}
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getCourseDetail = (id: string) => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/course-schedules/${id}`
+    request({
+      url: url, method: 'GET', data: {}
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const createCourseBookOrder = (courseScheduleId: string) => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/user-courses`
+    request({
+      url: url, method: 'POST', data: {
+        courseScheduleId: courseScheduleId,
+        payChannel: 'wx_lite'
+      }
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const cancelCourseBookOrder = (userCourseId: string) => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/user-courses/${userCourseId}/cancel`
+    request({
+      url: url, method: 'POST', data: {}
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getCourseBookOrders = () => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/user-courses`
+    request({
+      url: url, method: 'GET', data: {}
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getCourseBookOrder = (id: string) => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/user-courses/${id}`
+    request({
+      url: url, method: 'GET', data: {}
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const deleteCourseBookOrder = (id: string) => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/user-courses/${id}`
+    request({
+      url: url, method: 'DELETE', data: {}
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const verifyCourseBookOrder = (qrCode: string) => {
+  return new Promise((resolve, reject) => {
+    const url = `${_HOST}/api/book/verifies`
+    request({
+      url: url, method: 'POST', data: {
+        verifyCode: qrCode
+      }
+    }).catch(err => {
+      reject(err)
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getGyms = () => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/gyms`
+    request({
+      url: url, method: 'GET', data: {}
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getInstructors = () => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/instructors`
+    request({
+      url: url, method: 'GET', data: {}
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}
+
+export const getCourseCatalogs = () => {
+  return new Promise((resolve) => {
+    const url = `${_HOST}/api/book/course-catalogs`
+    request({
+      url: url, method: 'GET', data: {}
+    }).then(res => {
+      resolve(res);
+    })
+  })
+}