Compare commits
324 Commits
main
...
keycloak-u
| Author | SHA1 | Date | |
|---|---|---|---|
| aa441d4055 | |||
| c882a24415 | |||
| 077aaee181 | |||
| 57a998d215 | |||
| f01829de96 | |||
| 7815a1c011 | |||
| 96eea95fb9 | |||
| 6495d1c4a3 | |||
| a9612ad992 | |||
| ab2176bc69 | |||
| a89d8d3153 | |||
| 1b29cb222b | |||
| dfcfdb8f97 | |||
| d87d08aaea | |||
| c83694222d | |||
| c08fe26b36 | |||
| d3202a2cb2 | |||
| 2435e6f113 | |||
| 54856b0e45 | |||
| 95e72bb3f8 | |||
| 3d268b6061 | |||
| 5c6280ec42 | |||
| 0de9dcf7e3 | |||
| f3a6fb627c | |||
| c1746c6c20 | |||
| c6f56f6e11 | |||
| a6b246cdf3 | |||
| a705dea9cf | |||
| 8485d2e9a3 | |||
| c356f69656 | |||
| cacd23be40 | |||
| f8765dce49 | |||
| 42bebb6d93 | |||
| 354a0c04ee | |||
| 72bb9a33ef | |||
| 771c28f8ef | |||
|
|
e9497c3d52 | ||
| 70188f297f | |||
| e0940a1600 | |||
| 65eb6c780d | |||
| 6e9cde6b95 | |||
| 3a062eaa9c | |||
| c07acb85a2 | |||
| 5c8f8b73d7 | |||
| 5b52da0a62 | |||
|
|
21375cf55f | ||
|
|
9122acc89f | ||
|
|
152ec5f36b | ||
|
|
5176ecdddc | ||
| 1de564b917 | |||
| a2c080914a | |||
| 40ffbf29a5 | |||
| b046b6ab16 | |||
|
|
8cb9d7552c | ||
|
|
cdb638f046 | ||
|
|
a587b222b9 | ||
|
|
612673ddf9 | ||
|
|
3230dd7608 | ||
|
|
e4d7c874cc | ||
|
|
b7926f79a9 | ||
|
|
9d62a2d86f | ||
|
|
3498406a0c | ||
|
|
56a832b96b | ||
|
|
568104eaff | ||
|
|
2eac3a6836 | ||
| 2df366c14a | |||
|
|
6ce60eacc7 | ||
|
|
94dd00b9ca | ||
|
|
6e76e75e85 | ||
| 71c3e2ef1e | |||
| d83319a094 | |||
|
|
505ef0d91b | ||
|
|
fce4355be8 | ||
|
|
da9658ad7a | ||
|
|
e023d54a2a | ||
|
|
bdaacbe78a | ||
|
|
5479f310d7 | ||
|
|
5cf29447b3 | ||
|
|
7d1400fba6 | ||
|
|
5ee161f482 | ||
| 815b877b5b | |||
| 4588d11d5a | |||
| e0688eb539 | |||
| 0c28f9681b | |||
| 70b85dc967 | |||
| 71f5a25632 | |||
| d072226a5c | |||
| da7f574234 | |||
| b3505c1627 | |||
|
|
ad28352fc8 | ||
| b931035c3b | |||
| f723633646 | |||
| 6e6ad13b52 | |||
|
|
31bc4b6cff | ||
|
|
86dc221cc6 | ||
| 3ddc0a2716 | |||
| 49eeaa1876 | |||
| 2ffad84100 | |||
| b4985afdf0 | |||
| a37c4cad9c | |||
| 47ab39172e | |||
| d760f86632 | |||
|
|
5159325e6d | ||
| 794d5b7079 | |||
| a547503887 | |||
| 94439432fd | |||
| a03db16092 | |||
|
|
8c6ce29c23 | ||
| 380363922c | |||
| 68e5101885 | |||
| 7087c9a6da | |||
| 2b2a0b8bc4 | |||
|
|
f33f2b6db4 | ||
|
|
db6d29c208 | ||
|
|
5d92c34259 | ||
|
|
f5abf2d21e | ||
|
|
dedb42f15b | ||
|
|
54df7aae69 | ||
|
|
5663f41f22 | ||
|
|
38d8ca9cfb | ||
|
|
d017834b8c | ||
|
|
4b7d5ff7bb | ||
|
|
2ac2e517f2 | ||
|
|
a088da28d8 | ||
|
|
d57c691a71 | ||
|
|
7ac31991c0 | ||
|
|
81a32199e8 | ||
|
|
63c5caca3d | ||
|
|
e0a8173368 | ||
|
|
56ffd7591e | ||
| da8b5aa59f | |||
| 828f32ee81 | |||
| eb1eeb690b | |||
| 811e860965 | |||
| 89d6035151 | |||
| 5108087cc5 | |||
| 4e6f12a9a0 | |||
| 1d1b10d094 | |||
| e8e762e7ee | |||
| b6a0940506 | |||
| 38d73e27ad | |||
|
|
195af1db3d | ||
|
|
ee86bb8829 | ||
|
|
d8c617eb34 | ||
|
|
9a5cf3ea84 | ||
|
|
1fb8df7c89 | ||
|
|
b1b0846234 | ||
|
|
492327c5c7 | ||
|
|
cdf1491e53 | ||
|
|
6f67a21cc1 | ||
|
|
8dfa0e5f4a | ||
|
|
2b79a9e401 | ||
|
|
937f3e4a71 | ||
|
|
1427bcd5a9 | ||
| 64f8c44c74 | |||
|
|
9995c7d170 | ||
|
|
c48a4b482f | ||
| 07ffd6814e | |||
|
|
2f732b254f | ||
|
|
abf455117d | ||
|
|
9b50b0f92a | ||
| 8a6e805afc | |||
|
|
ec866c7f29 | ||
|
|
43117985dd | ||
| e6d768d6ed | |||
|
|
d65a6e8a4a | ||
| 44670965ae | |||
| bf61763452 | |||
| 4c5ec1cfac | |||
| 465d2e0a00 | |||
| 5ff0500a9a | |||
| 1285b02f9c | |||
|
|
b4b75438d2 | ||
|
|
bbb5e83ed9 | ||
|
|
536f915c69 | ||
| d3b393156a | |||
| 87751e10b4 | |||
| 8e9da80e5c | |||
|
|
c22667dab4 | ||
| 5bd1483aa8 | |||
|
|
3c6c86c7c8 | ||
| 5d6aa96324 | |||
|
|
ce7b04fda8 | ||
|
|
00bfb3574e | ||
|
|
e085a69442 | ||
|
|
b4a811e781 | ||
|
|
b3620fbb19 | ||
|
|
4d1cf6a84a | ||
|
|
4c38da445c | ||
|
|
3295ce7d04 | ||
| 7094523d7e | |||
|
|
801f5f45e2 | ||
|
|
c3cab7c06d | ||
|
|
99ba14bab2 | ||
|
|
9298abe354 | ||
| 2c78b921b9 | |||
| 91e0987831 | |||
| 4d76dd83ad | |||
| 6e63646944 | |||
| 5264455793 | |||
| 63b86c0abb | |||
| d582e222ce | |||
| 556d7bd321 | |||
| 37f233e363 | |||
| c8621c4453 | |||
| 83fff1ffe7 | |||
| 49fc75b17b | |||
|
|
aba4596edd | ||
|
|
2850478443 | ||
| 09155ae110 | |||
| d9b619d560 | |||
| 7f1596b141 | |||
| e375f22b57 | |||
| 4617c483fa | |||
| d68ed20ca9 | |||
|
|
90e0049d65 | ||
| 47675252ed | |||
| bd78b28f3c | |||
| e0064b9d55 | |||
|
|
cb7f4f7ba5 | ||
| edfc9776d3 | |||
| 77d58ef5b1 | |||
| 0ba23b6c8e | |||
| 74ccbfd4d7 | |||
| 9854a65484 | |||
| ff3335d331 | |||
| 4b198cd10c | |||
| e8dae56d7e | |||
| 0c6bda607d | |||
| 84b629ab0b | |||
| 0a4be89fc9 | |||
| 30c7d192fa | |||
| 8790b515d5 | |||
| 36816cfcd5 | |||
| 58e2b8dc81 | |||
| b2003ad30d | |||
| 853bd75119 | |||
| 0d1e255fe2 | |||
| 7c3aa45ec7 | |||
| c02cb046a5 | |||
| f4dcc44428 | |||
| 606fd55a42 | |||
| 0e9d9a212a | |||
| 69f41430e2 | |||
| 91f6dd11be | |||
| 08950896e5 | |||
| dfce4047cd | |||
| 5966fb2a77 | |||
| c681063e8d | |||
| 12cd61840c | |||
| 959646a319 | |||
| ee60a78335 | |||
| b665678dbb | |||
| 8c4df731aa | |||
| d7d089c11d | |||
| 12465e18fb | |||
| 43493c261c | |||
|
|
5746e1b087 | ||
|
|
0254805743 | ||
|
|
6ba33a99b2 | ||
|
|
7031bc1176 | ||
|
|
a39c21e4e7 | ||
| 2caf847296 | |||
| 23a07658c9 | |||
| c91adc7521 | |||
|
|
87363051cd | ||
|
|
a85f26d55b | ||
| 1d746e8e6d | |||
| 6374bfb602 | |||
| 790103a55f | |||
|
|
f0e956a7e8 | ||
|
|
65f0114ae4 | ||
| 894cf1b454 | |||
| 3341dbd306 | |||
| d7499fbc13 | |||
| 6dcb9ae4db | |||
| f26085e362 | |||
| 349e3e3143 | |||
| 66760adc06 | |||
| 57e30911e9 | |||
| b9f40d6a2d | |||
| 23f656ccc9 | |||
| e59ad6af00 | |||
| 8633ac4bce | |||
|
|
0bd67ec4e8 | ||
|
|
86b86c6752 | ||
|
|
2db67b7f20 | ||
|
|
c8be061ddc | ||
|
|
aa62aa69f3 | ||
|
|
8ee9271c2e | ||
|
|
8e4d634a7d | ||
| 93e44cda83 | |||
| 185a8a8293 | |||
| 5d53e53ba8 | |||
| 15ed539283 | |||
|
|
eec8a12db2 | ||
| 8c090f9d68 | |||
| c8e5d8835b | |||
|
|
2cb378a88a | ||
|
|
3a5058fc44 | ||
| 5487242bbe | |||
|
|
dca5f04739 | ||
| 7ccc45ce77 | |||
| 25b4e06b89 | |||
| 55869ea16f | |||
| 736194bb0b | |||
| 6426e2a79b | |||
| ea3fb22f1d | |||
| 02bb9f7d34 | |||
| 00b079e170 | |||
| 1d0808018b | |||
|
|
79b6652748 | ||
|
|
2689b87977 | ||
|
|
6232ffc50d | ||
|
|
b028aae1ba | ||
|
|
d572ba52a8 | ||
|
|
4e0ef7b9de | ||
|
|
571637f96a | ||
|
|
53e9349f37 | ||
|
|
87dfcf55e6 | ||
| 7b71da3825 | |||
| ad213dd4f8 | |||
| 0a0b1f0dee | |||
| bbcf0b6d83 |
23
.env
23
.env
@@ -5,7 +5,6 @@
|
|||||||
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
|
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
|
||||||
|
|
||||||
# SITE
|
# SITE
|
||||||
NEXT_PUBLIC_SITE_URL=https://localhost:3000
|
|
||||||
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
||||||
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
||||||
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
||||||
@@ -14,12 +13,12 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
|||||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
# BILLING
|
# BILLING
|
||||||
NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
NEXT_PUBLIC_BILLING_PROVIDER=montonio
|
||||||
|
|
||||||
# CMS
|
# CMS
|
||||||
CMS_CLIENT=keystatic
|
CMS_CLIENT=keystatic
|
||||||
@@ -35,13 +34,25 @@ NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
|
|||||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
|
||||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
||||||
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
|
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
|
||||||
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
|
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
|
||||||
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
|
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
|
||||||
|
|
||||||
|
WEBHOOK_SENDER_PROVIDER=postgres
|
||||||
|
|
||||||
|
# MAILER DEV
|
||||||
|
CONTACT_EMAIL=info@medreport.ee
|
||||||
|
MAILER_PROVIDER=nodemailer
|
||||||
|
EMAIL_SENDER=info@medreport.ee
|
||||||
|
EMAIL_USER= # refer to your email provider's documentation
|
||||||
|
EMAIL_PASSWORD= # refer to your email provider's documentation
|
||||||
|
EMAIL_HOST=localhost # refer to your email provider's documentation
|
||||||
|
EMAIL_PORT=1025 # or 465 for SSL
|
||||||
|
EMAIL_TLS=true
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
||||||
# NEXTJS
|
# NEXTJS
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -53,5 +64,7 @@ NEXT_PUBLIC_DEFAULT_LOCALE=et
|
|||||||
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
||||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||||
|
|
||||||
# MEDUSA
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
# Configure Medusa password secret for Keycloak users
|
||||||
|
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# This file is used to define environment variables for the development environment.
|
# This file is used to define environment variables for the development environment.
|
||||||
# These values are only used when running the app in development mode.
|
# These values are only used when running the app in development mode.
|
||||||
|
|
||||||
# SUPABASE
|
# SITE
|
||||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
|
||||||
|
# SUPABASE DEVELOPMENT
|
||||||
|
|
||||||
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
||||||
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
||||||
@@ -14,14 +15,25 @@ SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
|||||||
# CONTACT FORM
|
# CONTACT FORM
|
||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
# STRIPE
|
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
|
||||||
|
|
||||||
# MAILER
|
# MAILER
|
||||||
MAILER_PROVIDER=nodemailer
|
MAILER_PROVIDER=nodemailer
|
||||||
# EMAIL_SENDER=
|
EMAIL_SENDER=info@medreport.ee
|
||||||
# EMAIL_USER= # refer to your email provider's documentation
|
EMAIL_USER= # refer to your email provider's documentation
|
||||||
# EMAIL_PASSWORD= # refer to your email provider's documentation
|
EMAIL_PASSWORD= # refer to your email provider's documentation
|
||||||
# EMAIL_HOST= # refer to your email provider's documentation
|
EMAIL_HOST=localhost # refer to your email provider's documentation
|
||||||
# EMAIL_PORT= # or 465 for SSL
|
EMAIL_PORT=1025 # or 465 for SSL
|
||||||
# EMAIL_TLS= # or false for SSL (see provider documentation)
|
EMAIL_TLS=false
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
||||||
|
# MEDUSA
|
||||||
|
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||||
|
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
|
# MONTONIO
|
||||||
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
|
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
||||||
|
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||||
|
|
||||||
|
# JOBS
|
||||||
|
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# https://app.supabase.com/project/_/settings/api
|
# https://app.supabase.com/project/_/settings/api
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
MEDIPOST_URL=your-medipost-url
|
MEDIPOST_URL=your-medipost-url
|
||||||
MEDIPOST_USER=your-medipost-user
|
MEDIPOST_USER=your-medipost-user
|
||||||
@@ -19,3 +19,9 @@ EMAIL_PORT= # or 465 for SSL
|
|||||||
EMAIL_TLS= # or false for SSL (see provider documentation)
|
EMAIL_TLS= # or false for SSL (see provider documentation)
|
||||||
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
|
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
||||||
|
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||||
|
|
||||||
|
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||||
|
|
||||||
# SUPABASE
|
# SUPABASE
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||||
|
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
||||||
|
|
||||||
# STRIPE
|
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
|
||||||
|
# # MONTONIO
|
||||||
|
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
|
|||||||
15
.env.staging
Normal file
15
.env.staging
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# PRODUCTION ENVIRONMENT VARIABLES
|
||||||
|
|
||||||
|
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE
|
||||||
|
## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO
|
||||||
|
## AVOID PLACING SENSITIVE DATA IN THIS FILE.
|
||||||
|
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||||
|
|
||||||
|
# SUPABASE
|
||||||
|
# NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
|
||||||
|
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
||||||
|
|
||||||
|
# # MONTONIO
|
||||||
|
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
dump.sql
|
dump.sql
|
||||||
|
data.sql
|
||||||
44
Dockerfile
44
Dockerfile
@@ -1,17 +1,55 @@
|
|||||||
|
# --- Stage 1: Build ---
|
||||||
FROM node:20-alpine as builder
|
FROM node:20-alpine as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@9
|
RUN npm install -g pnpm@9
|
||||||
|
RUN npm install -g dotenv-cli
|
||||||
|
|
||||||
# Copy necessary files for workspace resolution
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY packages packages
|
COPY packages packages
|
||||||
COPY tooling tooling
|
COPY tooling tooling
|
||||||
|
COPY .env .env
|
||||||
|
COPY .env.production .env.production
|
||||||
|
COPY .env.staging .env.staging
|
||||||
|
|
||||||
# Install all dependencies
|
# Load env file and echo a specific variable
|
||||||
|
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pnpm build
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN set -a \
|
||||||
|
&& . .env \
|
||||||
|
&& . .env.production \
|
||||||
|
&& . .env.staging \
|
||||||
|
&& set +a \
|
||||||
|
&& node check-env.js \
|
||||||
|
&& pnpm build
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stage 2: Runtime ---
|
||||||
|
FROM node:20-alpine
|
||||||
|
ARG APP_ENV=production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
|
RUN cp ".env.${APP_ENV}" .env.local
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@9 \
|
||||||
|
&& pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# 🔍 Optional: Log key envs for debug
|
||||||
|
RUN echo "📄 .env contents:" && cat .env.local \
|
||||||
|
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
|
||||||
|
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -85,5 +85,58 @@ To access admin pages follow these steps:
|
|||||||
|
|
||||||
- Register new user
|
- Register new user
|
||||||
- Go to Profile and add Multi-Factor Authentication
|
- Go to Profile and add Multi-Factor Authentication
|
||||||
- Sign out and Sign in
|
|
||||||
- Authenticate with mfa (at current time profile page prompts it again)
|
- Authenticate with mfa (at current time profile page prompts it again)
|
||||||
|
- update your role. look at `supabase/sql/super-admin.sql`
|
||||||
|
- Sign out and Sign in
|
||||||
|
|
||||||
|
## Company User
|
||||||
|
|
||||||
|
- With admin account go to `http://localhost:3000/admin/accounts`
|
||||||
|
- For Create Company Account to work you need to have rows in `medreport.roles` table. For that you can sql in `supabase/sql/super-admin.sql`
|
||||||
|
|
||||||
|
## Start email server
|
||||||
|
|
||||||
|
`docker run -p 1080:1080 -p 1025:1025 maildev/maildev`
|
||||||
|
|
||||||
|
- [View emails](http://localhost:1080/#/)
|
||||||
|
- Mail server is running on `localhost:1025` without password
|
||||||
|
|
||||||
|
## Medipost flow
|
||||||
|
|
||||||
|
1. Customer adds analysis to cart in **B2B** storefront
|
||||||
|
2. Customer checks out from cart and is redirected to **Montonio**
|
||||||
|
3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT`
|
||||||
|
|
||||||
|
- **Medusa** order is created and cart is emptied
|
||||||
|
- email is sent to customer
|
||||||
|
- B2B sends order XML as private message to Medipost.
|
||||||
|
|
||||||
|
When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured.
|
||||||
|
|
||||||
|
In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**.
|
||||||
|
|
||||||
|
In local dev environment, you can create a private message with analysis responses in **Medipost** system for a submitted order:
|
||||||
|
`POST B2B/api/order/medipost-test-response body={medusaOrderId:'input here'}`
|
||||||
|
After that run `POST /api/job/sync-analysis-results` and analysis results should be synced.
|
||||||
|
|
||||||
|
In local dev environment, you can import products from B2B to Medusa with this API:
|
||||||
|
|
||||||
|
- `POST /api/job/sync-analysis-groups-store`
|
||||||
|
- Syncs required data of `analyses`, `analysis_elements` data from **B2B** to **Medusa** and creates relevant products and categories.
|
||||||
|
If product or category already exists, then it is not recreated. Old entries are not deleted either currently.
|
||||||
|
|
||||||
|
## Jobs
|
||||||
|
|
||||||
|
Required headers:
|
||||||
|
|
||||||
|
- `x-jobs-api-key` in UUID format
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `POST /api/job/sync-analysis-groups`
|
||||||
|
- Queries **Medipost** for public messages list and takes analysis info from the latest event.
|
||||||
|
Updates `analyses` and `analysis_elements` lists in **B2B**.
|
||||||
|
- `POST /api/job/sync-analysis-results`
|
||||||
|
- Queries **Medipost** for latest private message that has a response and updates order analysis results from lab results data.
|
||||||
|
- `POST /api/job/sync-connected-online`
|
||||||
|
- TODO
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
|||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
|||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
import { appConfig } from '@kit/shared/config';
|
||||||
import { Footer } from '@kit/ui/marketing';
|
import { Footer } from '@kit/ui/marketing';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
|
||||||
import appConfig from '~/config/app.config';
|
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<Footer
|
<Footer
|
||||||
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
||||||
description={<Trans i18nKey="marketing:footerDescription" />}
|
description=""
|
||||||
copyright={
|
copyright={
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="marketing:copyright"
|
i18nKey="marketing:copyright"
|
||||||
@@ -19,22 +18,6 @@ export function SiteFooter() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sections={[
|
sections={[
|
||||||
{
|
|
||||||
heading: <Trans i18nKey="marketing:about" />,
|
|
||||||
links: [
|
|
||||||
{ href: '/blog', label: <Trans i18nKey="marketing:blog" /> },
|
|
||||||
{ href: '/contact', label: <Trans i18nKey="marketing:contact" /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: <Trans i18nKey="marketing:product" />,
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
href: '/docs',
|
|
||||||
label: <Trans i18nKey="marketing:documentation" />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
heading: <Trans i18nKey="marketing:legal" />,
|
heading: <Trans i18nKey="marketing:legal" />,
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||||
@@ -12,8 +13,7 @@ import { Button } from '@kit/ui/button';
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
import { authConfig, featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||||
import pathsConfig from '~/config/paths.config';
|
|
||||||
|
|
||||||
const ModeToggle = dynamic(() =>
|
const ModeToggle = dynamic(() =>
|
||||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||||
@@ -23,13 +23,20 @@ const ModeToggle = dynamic(() =>
|
|||||||
|
|
||||||
const paths = {
|
const paths = {
|
||||||
home: pathsConfig.app.home,
|
home: pathsConfig.app.home,
|
||||||
|
admin: pathsConfig.app.admin,
|
||||||
|
doctor: pathsConfig.app.doctor,
|
||||||
|
personalAccountSettings: pathsConfig.app.personalAccountSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
|
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SiteHeaderAccountSection() {
|
export function SiteHeaderAccountSection({
|
||||||
|
accounts,
|
||||||
|
}: {
|
||||||
|
accounts: UserWorkspace['accounts'];
|
||||||
|
}) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const signOut = useSignOut();
|
const signOut = useSignOut();
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@ export function SiteHeaderAccountSection() {
|
|||||||
features={features}
|
features={features}
|
||||||
user={session.data.user}
|
user={session.data.user}
|
||||||
signOutRequested={() => signOut.mutateAsync()}
|
signOutRequested={() => signOut.mutateAsync()}
|
||||||
|
accounts={accounts}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,17 +66,19 @@ function AuthButtons() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex gap-x-2.5'}>
|
<div className={'flex gap-x-2.5'}>
|
||||||
<Button className={'hidden md:block'} asChild variant={'ghost'}>
|
<Button className={'block'} asChild variant={'ghost'}>
|
||||||
<Link href={pathsConfig.auth.signIn}>
|
<Link href={pathsConfig.auth.signIn}>
|
||||||
<Trans i18nKey={'auth:signIn'} />
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
{authConfig.providers.password && (
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
<Trans i18nKey={'auth:signUp'} />
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
</Link>
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
|
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||||
|
|
||||||
import { Header } from '@kit/ui/marketing';
|
import { Header } from '@kit/ui/marketing';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
import { SiteHeaderAccountSection } from './site-header-account-section';
|
import { SiteHeaderAccountSection } from './site-header-account-section';
|
||||||
import { SiteNavigation } from './site-navigation';
|
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader({
|
||||||
|
accounts,
|
||||||
|
}: {
|
||||||
|
accounts: UserWorkspace['accounts'];
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Header
|
<Header
|
||||||
logo={<AppLogo />}
|
logo={<AppLogo />}
|
||||||
navigation={<SiteNavigation />}
|
actions={<SiteHeaderAccountSection accounts={accounts} />}
|
||||||
actions={<SiteHeaderAccountSection />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ const links = {
|
|||||||
label: 'marketing:documentation',
|
label: 'marketing:documentation',
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
},
|
},
|
||||||
Pricing: {
|
|
||||||
label: 'marketing:pricing',
|
|
||||||
path: '/pricing',
|
|
||||||
},
|
|
||||||
FAQ: {
|
FAQ: {
|
||||||
label: 'marketing:faq',
|
label: 'marketing:faq',
|
||||||
path: '/faq',
|
path: '/faq',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createCmsClient } from '@kit/cms';
|
|||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
import { Post } from '../../blog/_components/post';
|
import { Post } from '../../blog/_components/post';
|
||||||
|
|
||||||
interface BlogPageProps {
|
interface BlogPageProps {
|
||||||
|
|||||||
@@ -9,18 +9,16 @@ import { ContactEmailSchema } from '../contact-email.schema';
|
|||||||
|
|
||||||
const contactEmail = z
|
const contactEmail = z
|
||||||
.string({
|
.string({
|
||||||
description: `The email where you want to receive the contact form submissions.`,
|
error:
|
||||||
required_error:
|
|
||||||
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||||
})
|
}).describe(`The email where you want to receive the contact form submissions.`)
|
||||||
.parse(process.env.CONTACT_EMAIL);
|
.parse(process.env.CONTACT_EMAIL);
|
||||||
|
|
||||||
const emailFrom = z
|
const emailFrom = z
|
||||||
.string({
|
.string({
|
||||||
description: `The email sending address.`,
|
error:
|
||||||
required_error:
|
|
||||||
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||||
})
|
}).describe(`The email sending address.`)
|
||||||
.parse(process.env.EMAIL_SENDER);
|
.parse(process.env.EMAIL_SENDER);
|
||||||
|
|
||||||
export const sendContactEmail = enhanceAction(
|
export const sendContactEmail = enhanceAction(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
|
|||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
|||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
||||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
|
||||||
|
|
||||||
function SiteLayout(props: React.PropsWithChildren) {
|
function SiteLayout(props: React.PropsWithChildren) {
|
||||||
|
const accounts = use(loadCurrentUserAccounts());
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col'}>
|
<div className={'flex min-h-[100vh] flex-col'}>
|
||||||
<SiteHeader />
|
<SiteHeader accounts={accounts} />
|
||||||
|
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { MedReportLogo } from '@/components/med-report-logo';
|
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||||
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex space-x-4'}>
|
<div className={'flex space-x-4'}>
|
||||||
<CtaButton>
|
<CtaButton>
|
||||||
<Link href={'/auth/sign-up'}>
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
<span className={'flex items-center space-x-0.5'}>
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'common:getStarted'} />
|
<Trans i18nKey={'common:getStarted'} />
|
||||||
@@ -49,8 +50,8 @@ function MainCallToActionButton() {
|
|||||||
</CtaButton>
|
</CtaButton>
|
||||||
|
|
||||||
<CtaButton variant={'link'}>
|
<CtaButton variant={'link'}>
|
||||||
<Link href={'/company-offer'}>
|
<Link href="/company-offer">
|
||||||
<Trans i18nKey={'account:createCompanyAccount'} />
|
<Trans i18nKey="account:createCompanyAccount" />
|
||||||
</Link>
|
</Link>
|
||||||
</CtaButton>
|
</CtaButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { SubmitButton } from '@/components/ui/submit-button';
|
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
|
||||||
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
|
|
||||||
import { CompanySubmitData } from '@/lib/types/company';
|
import { CompanySubmitData } from '@/lib/types/company';
|
||||||
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { renderCompanyOfferEmail } from '@kit/email-templates';
|
||||||
|
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
||||||
import { FormItem } from '@kit/ui/form';
|
import { FormItem } from '@kit/ui/form';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
|
import { Spinner } from '@kit/ui/spinner';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
const CompanyOfferForm = () => {
|
const CompanyOfferForm = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const language = useTranslation().i18n.language;
|
const language = useTranslation().i18n.language;
|
||||||
const {
|
const {
|
||||||
@@ -28,16 +33,29 @@ const CompanyOfferForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: CompanySubmitData) => {
|
const onSubmit = async (data: CompanySubmitData) => {
|
||||||
|
setIsLoading(true);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
if (value !== undefined) formData.append(key, value);
|
if (value !== undefined) formData.append(key, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendCompanyOfferEmail(data, language)
|
sendEmailFromTemplate(
|
||||||
|
renderCompanyOfferEmail,
|
||||||
|
{
|
||||||
|
companyData: data,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
process.env.CONTACT_EMAIL!,
|
||||||
|
)
|
||||||
.then(() => router.push('/company-offer/success'))
|
.then(() => router.push('/company-offer/success'))
|
||||||
.catch((error) => alert('error: ' + error));
|
.catch((error) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
alert('Could not send email at this time');
|
||||||
|
console.warn('error: ' + error);
|
||||||
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
setIsLoading(false);
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
console.warn('Server validation error: ' + err.message);
|
console.warn('Server validation error: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -76,11 +94,15 @@ const CompanyOfferForm = () => {
|
|||||||
<Input type="tel" {...register('phone')} />
|
<Input type="tel" {...register('phone')} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={isLoading || !isValid || isSubmitting}
|
||||||
pendingText="Saatmine..."
|
pendingText="Saatmine..."
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<Trans i18nKey={'account:requestCompanyAccount:button'} />
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey={'account:requestCompanyAccount:button'} />
|
||||||
|
)}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { MedReportLogo } from '@/components/med-report-logo';
|
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||||
import { withI18n } from '@/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import CompanyOfferForm from './_components/company-offer-form';
|
import CompanyOfferForm from './_components/company-offer-form';
|
||||||
@@ -10,7 +9,7 @@ import CompanyOfferForm from './_components/company-offer-form';
|
|||||||
function CompanyOffer() {
|
function CompanyOffer() {
|
||||||
return (
|
return (
|
||||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||||
<div className="flex w-1/2 flex-col px-12 py-14 text-center">
|
<div className="flex flex-col px-12 py-14 text-center md:w-1/2">
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
<h1 className="pt-8">
|
<h1 className="pt-8">
|
||||||
<Trans i18nKey={'account:requestCompanyAccount:title'} />
|
<Trans i18nKey={'account:requestCompanyAccount:title'} />
|
||||||
@@ -20,7 +19,7 @@ function CompanyOffer() {
|
|||||||
</p>
|
</p>
|
||||||
<CompanyOfferForm />
|
<CompanyOfferForm />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
|
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
function SiteLayout(props: React.PropsWithChildren) {
|
function SiteLayout(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col justify-center items-center'}>
|
<div className={'flex min-h-[100vh] flex-col justify-center items-center'}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||||
import { LayoutDashboard, Users } from 'lucide-react';
|
import { LayoutDashboard, Users } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,13 +19,16 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@kit/ui/shadcn-sidebar';
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
|
||||||
|
|
||||||
export function AdminSidebar() {
|
export function AdminSidebar({
|
||||||
|
accounts,
|
||||||
|
}: {
|
||||||
|
accounts: UserWorkspace['accounts'];
|
||||||
|
}) {
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const { open } = useSidebar();
|
const { open } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className={'m-2'}>
|
<SidebarHeader className={'m-2'}>
|
||||||
@@ -62,7 +66,7 @@ export function AdminSidebar() {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<ProfileAccountDropdownContainer />
|
<ProfileAccountDropdownContainer accounts={accounts} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cache } from 'react';
|
|||||||
|
|
||||||
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
||||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getAccount } from '~/lib/services/account.service';
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -28,21 +28,4 @@ async function AccountPage(props: Params) {
|
|||||||
|
|
||||||
export default AdminGuard(AccountPage);
|
export default AdminGuard(AccountPage);
|
||||||
|
|
||||||
const loadAccount = cache(accountLoader);
|
const loadAccount = cache(getAccount);
|
||||||
|
|
||||||
async function accountLoader(id: string) {
|
|
||||||
const client = getSupabaseServerClient();
|
|
||||||
|
|
||||||
const { data, error } = await client
|
|
||||||
.schema('medreport')
|
|
||||||
.from('accounts')
|
|
||||||
.select('*, memberships: accounts_memberships (*)')
|
|
||||||
.eq('id', id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
||||||
|
|
||||||
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
||||||
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
|
||||||
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
|
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
|
||||||
|
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
||||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
@@ -24,7 +24,7 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function AccountsPage(props: AdminAccountsPageProps) {
|
async function AccountsPage(props: AdminAccountsPageProps) {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient().schema('medreport');
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
||||||
|
|
||||||
@@ -33,18 +33,19 @@ async function AccountsPage(props: AdminAccountsPageProps) {
|
|||||||
<PageHeader description={<AppBreadcrumbs />}>
|
<PageHeader description={<AppBreadcrumbs />}>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<AdminCreateUserDialog>
|
<AdminCreateUserDialog>
|
||||||
<Button data-test="admin-create-user-button">Create Personal Account</Button>
|
<Button data-test="admin-create-user-button">
|
||||||
|
Create Personal Account
|
||||||
|
</Button>
|
||||||
</AdminCreateUserDialog>
|
</AdminCreateUserDialog>
|
||||||
<AdminCreateCompanyDialog>
|
<AdminCreateCompanyDialog>
|
||||||
<Button>Create Company Account</Button>
|
<Button>Create Company Account</Button>
|
||||||
</AdminCreateCompanyDialog>
|
</AdminCreateCompanyDialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<ServerDataLoader
|
<ServerDataLoader
|
||||||
table={'accounts'}
|
table="accounts"
|
||||||
client={client}
|
client={client}
|
||||||
page={page}
|
page={page}
|
||||||
where={(queryBuilder) => {
|
where={(queryBuilder) => {
|
||||||
@@ -55,7 +56,9 @@ async function AccountsPage(props: AdminAccountsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`);
|
queryBuilder.or(
|
||||||
|
`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryBuilder;
|
return queryBuilder;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
|||||||
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
||||||
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
||||||
|
|
||||||
|
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: `Super Admin`,
|
title: `Super Admin`,
|
||||||
};
|
};
|
||||||
@@ -16,12 +18,13 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default function AdminLayout(props: React.PropsWithChildren) {
|
export default function AdminLayout(props: React.PropsWithChildren) {
|
||||||
const state = use(getLayoutState());
|
const state = use(getLayoutState());
|
||||||
|
const workspace = use(loadUserWorkspace());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={state.open}>
|
<SidebarProvider defaultOpen={state.open}>
|
||||||
<Page style={'sidebar'}>
|
<Page style={'sidebar'}>
|
||||||
<PageNavigation>
|
<PageNavigation>
|
||||||
<AdminSidebar />
|
<AdminSidebar accounts={workspace.accounts} />
|
||||||
</PageNavigation>
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation>
|
<PageMobileNavigation>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { enhanceRouteHandler } from '@kit/next/routes';
|
|||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
import billingConfig from '~/config/billing.config';
|
import { billingConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Handle the webhooks from Stripe related to checkouts
|
* @description Handle the webhooks from Stripe related to checkouts
|
||||||
|
|||||||
8
app/api/job/handler/load-env.ts
Normal file
8
app/api/job/handler/load-env.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
export default function loadEnv() {
|
||||||
|
config({ path: `.env` });
|
||||||
|
if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) {
|
||||||
|
config({ path: `.env.${process.env.NODE_ENV}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { renderNewJobsAvailableEmail } from '@kit/email-templates';
|
||||||
|
|
||||||
|
import { getDoctorAccounts } from '~/lib/services/account.service';
|
||||||
|
import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service';
|
||||||
|
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
|
||||||
|
|
||||||
|
export default async function sendOpenJobsEmails() {
|
||||||
|
const analysisResponseIds = await getOpenJobAnalysisResponseIds();
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderNewJobsAvailableEmail,
|
||||||
|
{
|
||||||
|
language: 'et',
|
||||||
|
analysisResponseIds,
|
||||||
|
},
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
}
|
||||||
241
app/api/job/handler/sync-analysis-groups-store.ts
Normal file
241
app/api/job/handler/sync-analysis-groups-store.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import Medusa from "@medusajs/js-sdk"
|
||||||
|
import type { AdminProductCategory } from "@medusajs/types";
|
||||||
|
import { listProductTypes } from "@lib/data/products";
|
||||||
|
import { getAnalysisElements } from "~/lib/services/analysis-element.service";
|
||||||
|
import { getAnalysisGroups } from "~/lib/services/analysis-group.service";
|
||||||
|
import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service";
|
||||||
|
|
||||||
|
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
|
||||||
|
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
|
||||||
|
|
||||||
|
const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
|
||||||
|
|
||||||
|
const getAdminSdk = () => {
|
||||||
|
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
|
||||||
|
const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
|
||||||
|
const key = process.env.MEDUSA_SECRET_API_KEY!;
|
||||||
|
|
||||||
|
if (!medusaBackendUrl || !medusaPublishableApiKey) {
|
||||||
|
throw new Error('Medusa environment variables not set');
|
||||||
|
}
|
||||||
|
return new Medusa({
|
||||||
|
baseUrl: medusaBackendUrl,
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
apiKey: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProductCategories({
|
||||||
|
medusa,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
}) {
|
||||||
|
const { product_categories: existingProductCategories } = await medusa.admin.productCategory.list();
|
||||||
|
const parentCategory = existingProductCategories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE);
|
||||||
|
|
||||||
|
if (!parentCategory) {
|
||||||
|
throw new Error('Parent category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysisGroups = await getAnalysisGroups();
|
||||||
|
if (!analysisGroups) {
|
||||||
|
throw new Error('Analysis groups not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdCategories: AdminProductCategory[] = [];
|
||||||
|
for (const analysisGroup of analysisGroups) {
|
||||||
|
console.info(`Processing analysis group '${analysisGroup.name}'`);
|
||||||
|
|
||||||
|
const isExisting = existingProductCategories.find(({ name }) => name === analysisGroup.name);
|
||||||
|
const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name);
|
||||||
|
if (isExisting || isNewlyCreated) {
|
||||||
|
console.info(`Analysis group '${analysisGroup.name}' already exists`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createResponse = await medusa.admin.productCategory.create({
|
||||||
|
name: analysisGroup.name,
|
||||||
|
handle: analysisGroup.name,
|
||||||
|
parent_category_id: parentCategory.id,
|
||||||
|
is_active: true,
|
||||||
|
metadata: {
|
||||||
|
analysisGroupOriginalId: analysisGroup.original_id,
|
||||||
|
analysisGroupId: analysisGroup.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.info(`Successfully created category, id=${createResponse.product_category.id}`);
|
||||||
|
createdCategories.push(createResponse.product_category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChildProductCategories({
|
||||||
|
medusa,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
}) {
|
||||||
|
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
|
||||||
|
const childCategories = allCategories.filter(({ parent_category_id }) => parent_category_id !== null);
|
||||||
|
return childCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProductCategories({
|
||||||
|
medusa,
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
categories: AdminProductCategory[];
|
||||||
|
}) {
|
||||||
|
for (const category of categories) {
|
||||||
|
await medusa.admin.productCategory.delete(category.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In case a reset is needed
|
||||||
|
*/
|
||||||
|
async function deleteProducts({
|
||||||
|
medusa,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
}) {
|
||||||
|
const { products: existingProducts } = await medusa.admin.product.list({
|
||||||
|
fields: 'id,collection_id',
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(existingProducts.filter((a) => !a.collection_id).map(({ id }) => medusa.admin.product.delete(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAnalysisPackagesType() {
|
||||||
|
const { productTypes } = await listProductTypes();
|
||||||
|
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE);
|
||||||
|
if (!analysisPackagesType) {
|
||||||
|
throw new Error('Synlab analysis packages type not found');
|
||||||
|
}
|
||||||
|
return analysisPackagesType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductDefaultFields({
|
||||||
|
medusa,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
}) {
|
||||||
|
const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE })
|
||||||
|
const baseProduct = baseProductsResponse.products[0];
|
||||||
|
if (!baseProduct) {
|
||||||
|
throw new Error('Base product not found');
|
||||||
|
}
|
||||||
|
const defaultSalesChannels = baseProduct.sales_channels;
|
||||||
|
if (!Array.isArray(defaultSalesChannels)) {
|
||||||
|
throw new Error('Base analysis product has no required sales channels');
|
||||||
|
}
|
||||||
|
const defaultProductOption = baseProduct.options;
|
||||||
|
if (!Array.isArray(defaultProductOption)) {
|
||||||
|
throw new Error('Base analysis product has no required options');
|
||||||
|
}
|
||||||
|
const defaultProductVariant = baseProduct.variants?.[0];
|
||||||
|
if (!defaultProductVariant) {
|
||||||
|
throw new Error('Base analysis product has no required variant');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultSalesChannels,
|
||||||
|
defaultProductOption,
|
||||||
|
defaultProductVariant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProducts({
|
||||||
|
medusa,
|
||||||
|
}: {
|
||||||
|
medusa: Medusa;
|
||||||
|
}) {
|
||||||
|
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ products: existingProducts },
|
||||||
|
analysisElements,
|
||||||
|
analysisPackagesType,
|
||||||
|
{
|
||||||
|
defaultSalesChannels,
|
||||||
|
defaultProductOption,
|
||||||
|
defaultProductVariant,
|
||||||
|
}
|
||||||
|
] = await Promise.all([
|
||||||
|
medusa.admin.product.list({
|
||||||
|
category_id: allCategories.map(({ id }) => id),
|
||||||
|
}),
|
||||||
|
getAnalysisElements({}),
|
||||||
|
getAnalysisPackagesType(),
|
||||||
|
getProductDefaultFields({ medusa }),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const analysisElement of analysisElements) {
|
||||||
|
const { analysis_id_original: originalId } = analysisElement;
|
||||||
|
const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId);
|
||||||
|
if (isExisting) {
|
||||||
|
console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { analysis_name_lab: name } = analysisElement;
|
||||||
|
if (!name) {
|
||||||
|
console.error(`Analysis element '${originalId}' has no name`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id);
|
||||||
|
if (!category) {
|
||||||
|
console.error(`Category not found for analysis element '${name}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await medusa.admin.product.create({
|
||||||
|
title: name,
|
||||||
|
handle: `analysis-element-${analysisElement.id}`,
|
||||||
|
categories: [{ id: category.id }],
|
||||||
|
options: defaultProductOption.map(({ id, title, values }) => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
values: values?.map(({ value }) => value) ?? [],
|
||||||
|
})),
|
||||||
|
metadata: {
|
||||||
|
analysisIdOriginal: originalId,
|
||||||
|
},
|
||||||
|
is_giftcard: false,
|
||||||
|
discountable: false,
|
||||||
|
status: 'published',
|
||||||
|
sales_channels: defaultSalesChannels.map(({ id }) => ({ id })),
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: defaultProductVariant.title!,
|
||||||
|
prices: defaultProductVariant.prices!,
|
||||||
|
manage_inventory: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type_id: analysisPackagesType.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function syncAnalysisGroupsStore() {
|
||||||
|
const medusa = getAdminSdk();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createProductCategories({ medusa });
|
||||||
|
|
||||||
|
// const categories = await getChildProductCategories({ medusa });
|
||||||
|
// await deleteProductCategories({ medusa, categories });
|
||||||
|
// await deleteProducts({ medusa });
|
||||||
|
// return;
|
||||||
|
|
||||||
|
await createProducts({ medusa });
|
||||||
|
|
||||||
|
await createMedusaSyncSuccessEntry();
|
||||||
|
} catch (e) {
|
||||||
|
await createMedusaSyncFailEntry(JSON.stringify(e));
|
||||||
|
console.error(e);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to sync analyses to Medusa, error: ${JSON.stringify(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/api/job/handler/sync-analysis-groups.ts
Normal file
178
app/api/job/handler/sync-analysis-groups.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service';
|
||||||
|
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types';
|
||||||
|
import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service';
|
||||||
|
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
|
||||||
|
import { createAnalysisElement } from '~/lib/services/analysis-element.service';
|
||||||
|
import { createCodes } from '~/lib/services/codes.service';
|
||||||
|
import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service';
|
||||||
|
import type { ICode } from '~/lib/types/code';
|
||||||
|
|
||||||
|
function toArray<T>(input?: T | T[] | null): T[] {
|
||||||
|
if (!input) return [];
|
||||||
|
return Array.isArray(input) ? input : [input];
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRITE_XML_TO_FILE = false as boolean;
|
||||||
|
|
||||||
|
export default async function syncAnalysisGroups() {
|
||||||
|
const baseUrl = process.env.MEDIPOST_URL;
|
||||||
|
const user = process.env.MEDIPOST_USER;
|
||||||
|
const password = process.env.MEDIPOST_PASSWORD;
|
||||||
|
const sender = process.env.MEDIPOST_MESSAGE_SENDER;
|
||||||
|
|
||||||
|
if (!baseUrl || !user || !password || !sender) {
|
||||||
|
throw new Error('Could not access all necessary environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.info('Getting latest public message id');
|
||||||
|
const lastCheckedDate = await getLastCheckedDate();
|
||||||
|
|
||||||
|
const latestMessage = await getLatestPublicMessageListItem();
|
||||||
|
if (!latestMessage) {
|
||||||
|
console.info('No new data received');
|
||||||
|
await createNoNewDataReceivedEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.info('Getting public message with id: ', latestMessage.messageId);
|
||||||
|
|
||||||
|
const { data: publicMessageData } = await axios.get(baseUrl, {
|
||||||
|
params: {
|
||||||
|
Action: 'GetPublicMessage',
|
||||||
|
User: user,
|
||||||
|
Password: password,
|
||||||
|
MessageId: latestMessage.messageId,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/xml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (WRITE_XML_TO_FILE) {
|
||||||
|
fs.writeFileSync('public-messages-list-response.xml', publicMessageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false });
|
||||||
|
const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData);
|
||||||
|
|
||||||
|
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get public message (id: ${latestMessage.messageId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAnalysisGroups = await getAnalysisGroups();
|
||||||
|
|
||||||
|
// SAVE PUBLIC MESSAGE DATA
|
||||||
|
|
||||||
|
const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja);
|
||||||
|
const analysisGroups = providers.flatMap((provider) =>
|
||||||
|
toArray(provider.UuringuGrupp),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!parsed || !analysisGroups.length) {
|
||||||
|
console.info('No analysis groups data received');
|
||||||
|
await createNoDataReceivedEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes: ICode[] = [];
|
||||||
|
for (const analysisGroup of analysisGroups) {
|
||||||
|
const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId);
|
||||||
|
if (existingAnalysisGroup) {
|
||||||
|
console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAVE ANALYSIS GROUP
|
||||||
|
const analysisGroupId = await createAnalysisGroup({
|
||||||
|
id: analysisGroup.UuringuGruppId,
|
||||||
|
name: analysisGroup.UuringuGruppNimi,
|
||||||
|
order: analysisGroup.UuringuGruppJarjekord,
|
||||||
|
});
|
||||||
|
|
||||||
|
const analysisGroupCodes = toArray(analysisGroup.Kood);
|
||||||
|
codes.push(
|
||||||
|
...analysisGroupCodes.map((kood) => ({
|
||||||
|
hk_code: kood.HkKood,
|
||||||
|
hk_code_multiplier: kood.HkKoodiKordaja,
|
||||||
|
coefficient: kood.Koefitsient,
|
||||||
|
price: kood.Hind,
|
||||||
|
analysis_group_id: analysisGroupId,
|
||||||
|
analysis_element_id: null,
|
||||||
|
analysis_id: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const analysisGroupItems = toArray(analysisGroup.Uuring);
|
||||||
|
|
||||||
|
for (const item of analysisGroupItems) {
|
||||||
|
const analysisElement = item.UuringuElement;
|
||||||
|
|
||||||
|
const insertedAnalysisElementId = await createAnalysisElement({
|
||||||
|
analysisElement,
|
||||||
|
analysisGroupId,
|
||||||
|
materialGroups: toArray(item.MaterjalideGrupp),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (analysisElement.Kood) {
|
||||||
|
const analysisElementCodes = toArray(analysisElement.Kood);
|
||||||
|
codes.push(
|
||||||
|
...analysisElementCodes.map((kood) => ({
|
||||||
|
hk_code: kood.HkKood,
|
||||||
|
hk_code_multiplier: kood.HkKoodiKordaja,
|
||||||
|
coefficient: kood.Koefitsient,
|
||||||
|
price: kood.Hind,
|
||||||
|
analysis_group_id: null,
|
||||||
|
analysis_element_id: insertedAnalysisElementId,
|
||||||
|
analysis_id: null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyses = analysisElement.UuringuElement;
|
||||||
|
if (analyses?.length) {
|
||||||
|
for (const analysis of analyses) {
|
||||||
|
const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId);
|
||||||
|
|
||||||
|
if (analysis.Kood) {
|
||||||
|
const analysisCodes = toArray(analysis.Kood);
|
||||||
|
|
||||||
|
codes.push(
|
||||||
|
...analysisCodes.map((kood) => ({
|
||||||
|
hk_code: kood.HkKood,
|
||||||
|
hk_code_multiplier: kood.HkKoodiKordaja,
|
||||||
|
coefficient: kood.Koefitsient,
|
||||||
|
price: kood.Hind,
|
||||||
|
analysis_group_id: null,
|
||||||
|
analysis_element_id: null,
|
||||||
|
analysis_id: insertedAnalysisId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('Inserting codes');
|
||||||
|
await createCodes(codes);
|
||||||
|
|
||||||
|
console.info('Inserting sync entry');
|
||||||
|
await createSyncSuccessEntry();
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
await createSyncFailEntry(JSON.stringify({
|
||||||
|
message: errorMessage,
|
||||||
|
stack: e instanceof Error ? e.stack : undefined,
|
||||||
|
name: e instanceof Error ? e.name : 'Unknown',
|
||||||
|
}, null, 2));
|
||||||
|
console.error('Sync failed:', e);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to sync public message data, error: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/job/handler/sync-analysis-results.ts
Normal file
62
app/api/job/handler/sync-analysis-results.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { readPrivateMessageResponse } from "~/lib/services/medipost.service";
|
||||||
|
|
||||||
|
type ProcessedMessage = {
|
||||||
|
messageId: string;
|
||||||
|
hasAnalysisResponse: boolean;
|
||||||
|
hasPartialAnalysisResponse: boolean;
|
||||||
|
hasFullAnalysisResponse: boolean;
|
||||||
|
medusaOrderId: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupedResults = {
|
||||||
|
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
||||||
|
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function syncAnalysisResults() {
|
||||||
|
console.info("Syncing analysis results");
|
||||||
|
|
||||||
|
let processedMessages: ProcessedMessage[] = [];
|
||||||
|
const excludedMessageIds: string[] = [];
|
||||||
|
while (true) {
|
||||||
|
const result = await readPrivateMessageResponse({ excludedMessageIds });
|
||||||
|
if (result.messageId) {
|
||||||
|
processedMessages.push(result as ProcessedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.messageId) {
|
||||||
|
console.info("No more messages to process");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!excludedMessageIds.includes(result.messageId)) {
|
||||||
|
excludedMessageIds.push(result.messageId);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedResults = processedMessages.reduce((acc, result) => {
|
||||||
|
if (result.medusaOrderId) {
|
||||||
|
if (result.hasAnalysisResponse) {
|
||||||
|
if (!acc.processed) {
|
||||||
|
acc.processed = [];
|
||||||
|
}
|
||||||
|
acc.processed.push({
|
||||||
|
messageId: result.messageId,
|
||||||
|
medusaOrderId: result.medusaOrderId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!acc.waitingForResults) {
|
||||||
|
acc.waitingForResults = [];
|
||||||
|
}
|
||||||
|
acc.waitingForResults.push({
|
||||||
|
messageId: result.messageId,
|
||||||
|
medusaOrderId: result.medusaOrderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as GroupedResults);
|
||||||
|
console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`);
|
||||||
|
}
|
||||||
@@ -1,46 +1,29 @@
|
|||||||
import { createClient as createCustomClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { config } from 'dotenv';
|
|
||||||
|
|
||||||
async function syncData() {
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
if (process.env.NODE_ENV === 'local') {
|
|
||||||
config({ path: `.env.${process.env.NODE_ENV}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
import type { ISearchLoadResponse } from '~/lib/types/connected-online';
|
||||||
|
|
||||||
|
export default async function syncConnectedOnline() {
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
const baseUrl = process.env.CONNECTED_ONLINE_URL;
|
const baseUrl = process.env.CONNECTED_ONLINE_URL;
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseServiceRoleKey =
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!baseUrl || !supabaseUrl || !supabaseServiceRoleKey) {
|
if (!baseUrl) {
|
||||||
throw new Error('Could not access all necessary environment variables');
|
throw new Error('Could not access all necessary environment variables');
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, {
|
const supabase = getSupabaseServerAdminClient();
|
||||||
auth: {
|
|
||||||
persistSession: false,
|
|
||||||
autoRefreshToken: false,
|
|
||||||
detectSessionInUrl: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${baseUrl}/Search_Load`, {
|
const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
},
|
},
|
||||||
param: "{'Value':'|et|-1'}", // get all available services in Estonian
|
param: "{'Value':'|et|-1'}", // get all available services in Estonian
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData: {
|
const responseData: ISearchLoadResponse = JSON.parse(response.data.d);
|
||||||
Value: any;
|
|
||||||
Data: any;
|
|
||||||
ErrorCode: number;
|
|
||||||
ErrorMessage: string;
|
|
||||||
} = JSON.parse(response.data.d);
|
|
||||||
|
|
||||||
if (responseData?.ErrorCode !== 0) {
|
if (responseData?.ErrorCode !== 0) {
|
||||||
throw new Error('Failed to get Connected Online data');
|
throw new Error('Failed to get Connected Online data');
|
||||||
@@ -88,19 +71,19 @@ async function syncData() {
|
|||||||
return {
|
return {
|
||||||
id: service.ID,
|
id: service.ID,
|
||||||
clinic_id: service.ClinicID,
|
clinic_id: service.ClinicID,
|
||||||
code: service.Code,
|
sync_id: service.SyncID,
|
||||||
description: service.Description || null,
|
|
||||||
display: service.Display,
|
|
||||||
duration: service.Duration,
|
|
||||||
has_free_codes: !!service.HasFreeCodes,
|
|
||||||
name: service.Name,
|
name: service.Name,
|
||||||
|
description: service.Description || null,
|
||||||
|
price: service.Price,
|
||||||
|
requires_payment: !!service.RequiresPayment,
|
||||||
|
duration: service.Duration,
|
||||||
neto_duration: service.NetoDuration,
|
neto_duration: service.NetoDuration,
|
||||||
|
display: service.Display,
|
||||||
|
price_periods: service.PricePeriods || null,
|
||||||
online_hide_duration: service.OnlineHideDuration,
|
online_hide_duration: service.OnlineHideDuration,
|
||||||
online_hide_price: service.OnlineHidePrice,
|
online_hide_price: service.OnlineHidePrice,
|
||||||
price: service.Price,
|
code: service.Code,
|
||||||
price_periods: service.PricePeriods || null,
|
has_free_codes: !!service.HasFreeCodes,
|
||||||
requires_payment: !!service.RequiresPayment,
|
|
||||||
sync_id: service.SyncID,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,5 +131,3 @@ async function syncData() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncData();
|
|
||||||
9
app/api/job/handler/validate-api-key.ts
Normal file
9
app/api/job/handler/validate-api-key.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export default function validateApiKey(request: NextRequest) {
|
||||||
|
const envApiKey = process.env.JOBS_API_TOKEN;
|
||||||
|
const requestApiKey = request.headers.get('x-jobs-api-key');
|
||||||
|
if (requestApiKey !== envApiKey) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/api/job/medipost-retry-dispatch/route.ts
Normal file
40
app/api/job/medipost-retry-dispatch/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
import { getOrderedAnalysisIds, sendOrderToMedipost } from "~/lib/services/medipost.service";
|
||||||
|
import { retrieveOrder } from "@lib/data/orders";
|
||||||
|
import { getMedipostDispatchTries } from "~/lib/services/audit.service";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const { medusaOrderId } = await request.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tries = await getMedipostDispatchTries(medusaOrderId);
|
||||||
|
if (tries >= 3) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Order has been retried too many times',
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const medusaOrder = await retrieveOrder(medusaOrderId);
|
||||||
|
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
|
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||||
|
console.info("Successfully sent order to medipost");
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Successfully sent order to medipost',
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error sending order to medipost", e);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Failed to send order to medipost',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NotificationAction,
|
||||||
|
createNotificationLog,
|
||||||
|
} from '~/lib/services/audit/notificationEntries.service';
|
||||||
|
import loadEnv from '../handler/load-env';
|
||||||
|
import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
|
||||||
|
import validateApiKey from '../handler/validate-api-key';
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOpenJobsEmails();
|
||||||
|
console.info(
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(
|
||||||
|
'Error sending out open job notification emails to doctors.',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Failed to send out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
27
app/api/job/sync-analysis-groups-store/route.ts
Normal file
27
app/api/job/sync-analysis-groups-store/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAnalysisGroupsStore();
|
||||||
|
console.info("Successfully synced analysis groups store");
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Successfully synced analysis groups store',
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error syncing analysis groups store", e);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Failed to sync analysis groups store',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
27
app/api/job/sync-analysis-groups/route.ts
Normal file
27
app/api/job/sync-analysis-groups/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import syncAnalysisGroups from "../handler/sync-analysis-groups";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAnalysisGroups();
|
||||||
|
console.info("Successfully synced analysis groups");
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Successfully synced analysis groups',
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error syncing analysis groups", e);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Failed to sync analysis groups',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
27
app/api/job/sync-analysis-results/route.ts
Normal file
27
app/api/job/sync-analysis-results/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
import syncAnalysisResults from "../handler/sync-analysis-results";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncAnalysisResults();
|
||||||
|
console.info("Successfully synced analysis results");
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Successfully synced analysis results',
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error syncing analysis results", e);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Failed to sync analysis results',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
27
app/api/job/sync-connected-online/route.ts
Normal file
27
app/api/job/sync-connected-online/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
import syncConnectedOnline from "../handler/sync-connected-online";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncConnectedOnline();
|
||||||
|
console.info("Successfully synced connected-online");
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Successfully synced connected-online',
|
||||||
|
}, { status: 200 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error syncing connected-online", e);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Failed to sync connected-online',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
52
app/api/job/test-medipost-responses/route.ts
Normal file
52
app/api/job/test-medipost-responses/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
|
||||||
|
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||||
|
import { retrieveOrder } from "@lib/data";
|
||||||
|
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||||
|
import { getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
||||||
|
import loadEnv from "../handler/load-env";
|
||||||
|
import validateApiKey from "../handler/validate-api-key";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateApiKey(request);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'PROCESSING' });
|
||||||
|
|
||||||
|
console.error(`Sending test responses for ${analysisOrders.length} analysis orders`);
|
||||||
|
for (const medreportOrder of analysisOrders) {
|
||||||
|
const medusaOrderId = medreportOrder.medusa_order_id;
|
||||||
|
const medusaOrder = await retrieveOrder(medusaOrderId)
|
||||||
|
|
||||||
|
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||||
|
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
|
|
||||||
|
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
||||||
|
const idsToSend = orderedAnalysisElementsIds;
|
||||||
|
const messageXml = await composeOrderTestResponseXML({
|
||||||
|
person: {
|
||||||
|
idCode: account.personal_code!,
|
||||||
|
firstName: account.name ?? '',
|
||||||
|
lastName: account.last_name ?? '',
|
||||||
|
phone: account.phone ?? '',
|
||||||
|
},
|
||||||
|
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||||
|
orderedAnalysesIds: idsToSend.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||||
|
orderId: medusaOrderId,
|
||||||
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendPrivateMessageTestResponse({ messageXml });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending private message test response: ", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
97
app/api/montonio/verify-token/route.ts
Normal file
97
app/api/montonio/verify-token/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
|
||||||
|
interface MontonioOrderToken {
|
||||||
|
uuid: string;
|
||||||
|
accessKey: string;
|
||||||
|
merchantReference: string;
|
||||||
|
merchantReferenceDisplay: string;
|
||||||
|
paymentStatus:
|
||||||
|
| 'PAID'
|
||||||
|
| 'FAILED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'PENDING'
|
||||||
|
| 'EXPIRED'
|
||||||
|
| 'REFUNDED';
|
||||||
|
paymentMethod: string;
|
||||||
|
grandTotal: number;
|
||||||
|
currency: string;
|
||||||
|
senderIban?: string;
|
||||||
|
senderName?: string;
|
||||||
|
paymentProviderName?: string;
|
||||||
|
paymentLinkUuid: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = enhanceRouteHandler(
|
||||||
|
async ({ request }) => {
|
||||||
|
const logger = await getLogger();
|
||||||
|
const body = await request.json();
|
||||||
|
const namespace = 'montonio.verify-token';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { token } = BodySchema.parse(body);
|
||||||
|
|
||||||
|
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
||||||
|
|
||||||
|
if (!secretKey) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
},
|
||||||
|
`Missing MONTONIO_SECRET_KEY`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error('Server misconfiguration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, secretKey, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as MontonioOrderToken;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
status: decoded.paymentStatus,
|
||||||
|
orderId: decoded.uuid,
|
||||||
|
},
|
||||||
|
`Successfully verified Montonio token.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: decoded.paymentStatus,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
name: namespace,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
`Failed to verify Montonio token`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid token';
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
48
app/api/order/medipost-test-response/route.ts
Normal file
48
app/api/order/medipost-test-response/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getOrder } from "~/lib/services/order.service";
|
||||||
|
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||||
|
import { retrieveOrder } from "@lib/data";
|
||||||
|
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||||
|
import { createMedipostActionLog, getOrderedAnalysisIds } from "~/lib/services/medipost.service";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
// if (!isDev) {
|
||||||
|
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { medusaOrderId } = await request.json();
|
||||||
|
|
||||||
|
const medusaOrder = await retrieveOrder(medusaOrderId)
|
||||||
|
const medreportOrder = await getOrder({ medusaOrderId });
|
||||||
|
|
||||||
|
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
|
||||||
|
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
|
|
||||||
|
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`);
|
||||||
|
const messageXml = await composeOrderTestResponseXML({
|
||||||
|
person: {
|
||||||
|
idCode: account.personal_code!,
|
||||||
|
firstName: account.name ?? '',
|
||||||
|
lastName: account.last_name ?? '',
|
||||||
|
phone: account.phone ?? '',
|
||||||
|
},
|
||||||
|
orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[],
|
||||||
|
orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[],
|
||||||
|
orderId: medusaOrderId,
|
||||||
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'send_fake_analysis_results_to_medipost',
|
||||||
|
xml: messageXml,
|
||||||
|
medusaOrderId,
|
||||||
|
});
|
||||||
|
await sendPrivateMessageTestResponse({ messageXml });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending private message test response: ", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
interface AuthCallbackErrorPageProps {
|
interface AuthCallbackErrorPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
error: string;
|
error: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
import { createAuthCallbackService, getErrorURLParameters } from '@kit/supabase/auth';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
||||||
|
|
||||||
|
const ERROR_PATH = '/auth/callback/error';
|
||||||
|
|
||||||
|
const redirectOnError = (searchParams?: string) => {
|
||||||
|
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
if (error) {
|
||||||
|
const { searchParams } = getErrorURLParameters({ error });
|
||||||
|
return redirectOnError(searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authCode = searchParams.get('code');
|
||||||
|
if (!authCode) {
|
||||||
|
return redirectOnError();
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
|
||||||
|
// if we have an invite token, we redirect to the join team page
|
||||||
|
// instead of the default next url. This is because the user is trying
|
||||||
|
// to join a team and we want to make sure they are redirected to the
|
||||||
|
// correct page.
|
||||||
|
const inviteToken = searchParams.get('invite_token');
|
||||||
|
if (inviteToken) {
|
||||||
|
const urlParams = new URLSearchParams({
|
||||||
|
invite_token: inviteToken,
|
||||||
|
email: searchParams.get('email') ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
const oauthResult = await service.exchangeCodeForSession(authCode);
|
||||||
|
if (!("isSuccess" in oauthResult)) {
|
||||||
|
return redirectOnError(oauthResult.searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
const { nextPath } = await service.exchangeCodeForSession(request, {
|
const api = createAccountsApi(getSupabaseServerClient());
|
||||||
joinTeamPath: pathsConfig.app.joinTeam,
|
|
||||||
redirectPath: pathsConfig.app.home,
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect(nextPath);
|
const account = await api.getPersonalAccountByUserId(
|
||||||
|
oauthResult.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account.email || !account.name || !account.last_name) {
|
||||||
|
return redirect(pathsConfig.auth.updateAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(redirectPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import pathsConfig from '@/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
||||||
@@ -25,7 +25,7 @@ const MembershipConfirmationNotification: React.FC<{
|
|||||||
descriptionKey="account:membershipConfirmation:successDescription"
|
descriptionKey="account:membershipConfirmation:successDescription"
|
||||||
buttonProps={{
|
buttonProps={{
|
||||||
buttonTitleKey: 'account:membershipConfirmation:successButton',
|
buttonTitleKey: 'account:membershipConfirmation:successButton',
|
||||||
href: pathsConfig.app.selectPackage,
|
href: pathsConfig.app.home,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
async function SiteLayout(props: React.PropsWithChildren) {
|
async function SiteLayout(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import pathsConfig from '@/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
import {
|
||||||
|
PageViewAction,
|
||||||
|
createPageViewLog,
|
||||||
|
} from '~/lib/services/audit/pageView.service';
|
||||||
|
|
||||||
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
|
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
|
||||||
|
|
||||||
async function UpdateAccountSuccess() {
|
async function MembershipConfirmation() {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -18,8 +21,12 @@ async function UpdateAccountSuccess() {
|
|||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
redirect(pathsConfig.app.home);
|
redirect(pathsConfig.app.home);
|
||||||
}
|
}
|
||||||
|
await createPageViewLog({
|
||||||
|
accountId: user.id,
|
||||||
|
action: PageViewAction.REGISTRATION_SUCCESS,
|
||||||
|
});
|
||||||
|
|
||||||
return <MembershipConfirmationNotification userId={user.id} />;
|
return <MembershipConfirmationNotification userId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(UpdateAccountSuccess);
|
export default withI18n(MembershipConfirmation);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { Button } from '@kit/ui/button';
|
|||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const { t } = await createI18nServerInstance();
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
|||||||
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
54
app/auth/sign-in/components/PasswordOption.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||||
|
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export default function PasswordOption({
|
||||||
|
inviteToken,
|
||||||
|
returnPath,
|
||||||
|
}: {
|
||||||
|
inviteToken?: string;
|
||||||
|
returnPath?: string;
|
||||||
|
}) {
|
||||||
|
const signUpPath =
|
||||||
|
pathsConfig.auth.signUp +
|
||||||
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
callback: pathsConfig.auth.callback,
|
||||||
|
returnPath: returnPath ?? pathsConfig.app.home,
|
||||||
|
joinTeam: pathsConfig.app.joinTeam,
|
||||||
|
updateAccount: pathsConfig.auth.updateAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signInHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signInSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignInMethodsContainer
|
||||||
|
inviteToken={inviteToken}
|
||||||
|
paths={paths}
|
||||||
|
providers={authConfig.providers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={'flex justify-center'}>
|
||||||
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
|
<Link href={signUpPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
37
app/auth/sign-in/components/SignInPageClientRedirect.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Loading from '@/app/home/loading';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function SignInPageClientRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function signIn() {
|
||||||
|
const { data, error } = await getSupabaseBrowserClient()
|
||||||
|
.auth
|
||||||
|
.signInWithOAuth({
|
||||||
|
provider: 'keycloak',
|
||||||
|
options: {
|
||||||
|
redirectTo: `${window.location.origin}/auth/callback`,
|
||||||
|
queryParams: {
|
||||||
|
prompt: 'login',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('OAuth error', error);
|
||||||
|
router.push('/');
|
||||||
|
} else if (data.url) {
|
||||||
|
router.push(data.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signIn();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import Link from 'next/link';
|
import { pathsConfig, authConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { register } from 'module';
|
|
||||||
|
|
||||||
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Heading } from '@kit/ui/heading';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
import authConfig from '~/config/auth.config';
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
||||||
|
import PasswordOption from './components/PasswordOption';
|
||||||
|
|
||||||
interface SignInPageProps {
|
interface SignInPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -28,47 +21,14 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
const signUpPath =
|
if (authConfig.providers.password) {
|
||||||
pathsConfig.auth.signUp +
|
return <PasswordOption inviteToken={inviteToken} returnPath={returnPath} />;
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
}
|
||||||
|
|
||||||
const paths = {
|
return <SignInPageClientRedirect />;
|
||||||
callback: pathsConfig.auth.callback,
|
|
||||||
returnPath: next ?? pathsConfig.app.home,
|
|
||||||
joinTeam: pathsConfig.app.joinTeam,
|
|
||||||
updateAccount: pathsConfig.auth.updateAccount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
|
||||||
<Heading level={4} className={'tracking-tight'}>
|
|
||||||
<Trans i18nKey={'auth:signInHeading'} />
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<p className={'text-muted-foreground text-sm'}>
|
|
||||||
<Trans i18nKey={'auth:signInSubheading'} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SignInMethodsContainer
|
|
||||||
inviteToken={inviteToken}
|
|
||||||
paths={paths}
|
|
||||||
providers={authConfig.providers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
|
||||||
<Link href={signUpPath} prefetch={true}>
|
|
||||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(SignInPage);
|
export default withI18n(SignInPage);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||||
|
import { authConfig, pathsConfig } from '@kit/shared/config';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import authConfig from '~/config/auth.config';
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
@@ -37,6 +38,10 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
pathsConfig.auth.signIn +
|
pathsConfig.auth.signIn +
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
|
if (!authConfig.providers.password) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { User } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
import { ExternalLink } from '@/public/assets/external-link';
|
import { ExternalLink } from '@/public/assets/external-link';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -21,33 +19,54 @@ import {
|
|||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { UpdateAccountSchema } from '../schemas/update-account.schema';
|
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema';
|
||||||
import { onUpdateAccount } from '../server/actions/update-account-actions';
|
import { onUpdateAccount } from '../_lib/server/update-account';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export function UpdateAccountForm({ user }: { user: User }) {
|
type UpdateAccountFormValues = z.infer<typeof UpdateAccountSchema>;
|
||||||
|
|
||||||
|
export function UpdateAccountForm({
|
||||||
|
defaultValues,
|
||||||
|
}: {
|
||||||
|
defaultValues: UpdateAccountFormValues,
|
||||||
|
}) {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(UpdateAccountSchema),
|
resolver: zodResolver(UpdateAccountSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
personalCode: '',
|
|
||||||
email: user.email,
|
|
||||||
phone: '',
|
|
||||||
city: '',
|
|
||||||
weight: 0,
|
|
||||||
height: 0,
|
|
||||||
userConsent: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { firstName, lastName, personalCode, email, weight, height, userConsent } = defaultValues;
|
||||||
|
|
||||||
|
const hasFirstName = !!firstName;
|
||||||
|
const hasLastName = !!lastName;
|
||||||
|
const hasPersonalCode = !!personalCode;
|
||||||
|
const hasEmail = !!email;
|
||||||
|
const hasWeight = !!weight;
|
||||||
|
const hasHeight = !!height;
|
||||||
|
const hasUserConsent = !!userConsent;
|
||||||
|
|
||||||
|
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) =>
|
||||||
|
onUpdateAccount({
|
||||||
|
...values,
|
||||||
|
...(hasFirstName && { firstName }),
|
||||||
|
...(hasLastName && { lastName }),
|
||||||
|
...(hasPersonalCode && { personalCode }),
|
||||||
|
...(hasEmail && { email }),
|
||||||
|
...(hasWeight && { weight: values.weight ?? weight }),
|
||||||
|
...(hasHeight && { height: values.height ?? height }),
|
||||||
|
...(hasUserConsent && { userConsent: values.userConsent ?? userConsent }),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
||||||
onSubmit={form.handleSubmit(onUpdateAccount)}
|
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
name="firstName"
|
name="firstName"
|
||||||
|
disabled={hasFirstName}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -63,6 +82,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="lastName"
|
name="lastName"
|
||||||
|
disabled={hasLastName}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -78,6 +98,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="personalCode"
|
name="personalCode"
|
||||||
|
disabled={hasPersonalCode}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -93,13 +114,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="email"
|
name="email"
|
||||||
|
disabled={hasEmail}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans i18nKey={'common:formField:email'} />
|
<Trans i18nKey={'common:formField:email'} />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} disabled />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import parsePhoneNumber from 'libphonenumber-js/min';
|
||||||
|
|
||||||
|
export const UpdateAccountSchema = z.object({
|
||||||
|
firstName: z
|
||||||
|
.string({
|
||||||
|
error: 'First name is required',
|
||||||
|
})
|
||||||
|
.nonempty(),
|
||||||
|
lastName: z
|
||||||
|
.string({
|
||||||
|
error: 'Last name is required',
|
||||||
|
})
|
||||||
|
.nonempty(),
|
||||||
|
personalCode: z
|
||||||
|
.string({
|
||||||
|
error: 'Personal code is required',
|
||||||
|
})
|
||||||
|
.nonempty(),
|
||||||
|
email: z.string().email({
|
||||||
|
message: 'Email is required',
|
||||||
|
}),
|
||||||
|
phone: z
|
||||||
|
.string({
|
||||||
|
error: 'Phone number is required',
|
||||||
|
})
|
||||||
|
.nonempty()
|
||||||
|
.refine(
|
||||||
|
(phone) => {
|
||||||
|
try {
|
||||||
|
const phoneNumber = parsePhoneNumber(phone);
|
||||||
|
return !!phoneNumber && phoneNumber.isValid() && phoneNumber.country === 'EE';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'common:formFieldError.invalidPhoneNumber',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
city: z.string().optional(),
|
||||||
|
weight: z
|
||||||
|
.number({
|
||||||
|
error: (issue) =>
|
||||||
|
issue.input === undefined
|
||||||
|
? 'Weight is required'
|
||||||
|
: 'Weight must be a number',
|
||||||
|
})
|
||||||
|
.gt(0, { message: 'Weight must be greater than 0' }),
|
||||||
|
|
||||||
|
height: z
|
||||||
|
.number({
|
||||||
|
error: (issue) =>
|
||||||
|
issue.input === undefined
|
||||||
|
? 'Height is required'
|
||||||
|
: 'Height must be a number',
|
||||||
|
})
|
||||||
|
.gt(0, { message: 'Height must be greater than 0' }),
|
||||||
|
userConsent: z.boolean().refine((val) => val === true, {
|
||||||
|
message: 'Must be true',
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -2,25 +2,16 @@
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { updateCustomer } from '@lib/data/customer';
|
||||||
|
|
||||||
|
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { UpdateAccountSchema } from '../../schemas/update-account.schema';
|
|
||||||
import { createAuthApi } from '../api';
|
|
||||||
|
|
||||||
export interface AccountSubmitData {
|
import { UpdateAccountSchema } from '../schemas/update-account.schema';
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
personalCode: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
city?: string;
|
|
||||||
weight: number | null;
|
|
||||||
height: number | null;
|
|
||||||
userConsent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onUpdateAccount = enhanceAction(
|
export const onUpdateAccount = enhanceAction(
|
||||||
async (params: AccountSubmitData) => {
|
async (params: AccountSubmitData) => {
|
||||||
@@ -36,6 +27,17 @@ export const onUpdateAccount = enhanceAction(
|
|||||||
}
|
}
|
||||||
console.warn('On update account error: ', err);
|
console.warn('On update account error: ', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCustomer({
|
||||||
|
first_name: params.firstName,
|
||||||
|
last_name: params.lastName,
|
||||||
|
phone: params.phone,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update Medusa customer", e);
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnseenMembershipConfirmation =
|
const hasUnseenMembershipConfirmation =
|
||||||
await api.hasUnseenMembershipConfirmation();
|
await api.hasUnseenMembershipConfirmation();
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
async function SiteLayout(props: React.PropsWithChildren) {
|
async function SiteLayout(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
||||||
|
|||||||
@@ -1,30 +1,52 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { BackButton } from '@/components/back-button';
|
|
||||||
import { MedReportLogo } from '@/components/med-report-logo';
|
|
||||||
import pathsConfig from '@/config/paths.config';
|
|
||||||
import { signOutAction } from '@/lib/actions/sign-out';
|
import { signOutAction } from '@/lib/actions/sign-out';
|
||||||
import { UpdateAccountForm } from '@/packages/features/auth/src/components/update-account-form';
|
|
||||||
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
||||||
|
|
||||||
|
import { BackButton } from '@kit/shared/components/back-button';
|
||||||
|
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { UpdateAccountForm } from './_components/update-account-form';
|
||||||
|
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
||||||
|
import { toTitleCase } from '~/lib/utils';
|
||||||
|
|
||||||
async function UpdateAccount() {
|
async function UpdateAccount() {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await client.auth.getUser();
|
} = await client.auth.getUser();
|
||||||
|
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(pathsConfig.auth.signIn);
|
redirect(pathsConfig.auth.signIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
firstName: account?.name ? toTitleCase(account.name) : '',
|
||||||
|
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
|
||||||
|
personalCode: account?.personal_code ?? '',
|
||||||
|
email: (() => {
|
||||||
|
if (isKeycloakUser) {
|
||||||
|
return account?.email ?? '';
|
||||||
|
}
|
||||||
|
return account?.email ?? user?.email ?? '';
|
||||||
|
})(),
|
||||||
|
phone: account?.phone ?? '',
|
||||||
|
city: account?.city ?? '',
|
||||||
|
weight: account?.accountParams?.weight ?? 0,
|
||||||
|
height: account?.accountParams?.height ?? 0,
|
||||||
|
userConsent: account?.has_consent_personal_data ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||||
<div className="relative flex w-1/2 min-w-md flex-col px-12 pt-7 pb-22 text-center">
|
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2">
|
||||||
<BackButton onBack={signOutAction} />
|
<BackButton onBack={signOutAction} />
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
<h1 className="pt-8">
|
<h1 className="pt-8">
|
||||||
@@ -33,9 +55,9 @@ async function UpdateAccount() {
|
|||||||
<p className="text-muted-foreground pt-1 text-sm">
|
<p className="text-muted-foreground pt-1 text-sm">
|
||||||
<Trans i18nKey={'account:updateAccount:description'} />
|
<Trans i18nKey={'account:updateAccount:description'} />
|
||||||
</p>
|
</p>
|
||||||
<UpdateAccountForm user={user} />
|
<UpdateAccountForm defaultValues={defaultValues} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
|
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
|
|||||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ async function VerifyPage(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextPath = (await props.searchParams).next;
|
const nextPath = (await props.searchParams).next;
|
||||||
const redirectPath = nextPath ?? pathsConfig.app.home;
|
const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiFactorChallengeContainer
|
<MultiFactorChallengeContainer
|
||||||
|
|||||||
289
app/doctor/_components/analysis-view.tsx
Normal file
289
app/doctor/_components/analysis-view.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
||||||
|
import {
|
||||||
|
getDOBWithAgeStringFromPersonalCode,
|
||||||
|
getResultSetName,
|
||||||
|
} from '@kit/doctor/lib/helpers';
|
||||||
|
import {
|
||||||
|
AnalysisResponse,
|
||||||
|
DoctorFeedback,
|
||||||
|
Order,
|
||||||
|
Patient,
|
||||||
|
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
|
||||||
|
import {
|
||||||
|
DoctorAnalysisFeedbackForm,
|
||||||
|
doctorAnalysisFeedbackFormSchema,
|
||||||
|
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
|
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
||||||
|
import {
|
||||||
|
useCurrentLocaleLanguageNames
|
||||||
|
} from '@kit/shared/hooks';
|
||||||
|
import { getFullName } from '@kit/shared/utils';
|
||||||
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@kit/ui/form';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Textarea } from '@kit/ui/textarea';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { bmiFromMetric } from '~/lib/utils';
|
||||||
|
|
||||||
|
import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
|
||||||
|
import DoctorJobSelect from './doctor-job-select';
|
||||||
|
|
||||||
|
export default function AnalysisView({
|
||||||
|
patient,
|
||||||
|
order,
|
||||||
|
analyses,
|
||||||
|
feedback,
|
||||||
|
}: {
|
||||||
|
patient: Patient;
|
||||||
|
order: Order;
|
||||||
|
analyses: AnalysisResponse[];
|
||||||
|
feedback?: DoctorFeedback;
|
||||||
|
}) {
|
||||||
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||||
|
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
|
const isInProgress = !!(
|
||||||
|
!!feedback?.status &&
|
||||||
|
feedback?.doctor_user_id &&
|
||||||
|
feedback?.status !== 'COMPLETED'
|
||||||
|
);
|
||||||
|
const isCurrentDoctorJob =
|
||||||
|
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
|
||||||
|
const isReadOnly =
|
||||||
|
!isInProgress ||
|
||||||
|
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
feedbackValue: feedback?.value ?? '',
|
||||||
|
userId: patient.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
if (!patient || !order || !analyses) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
data: DoctorAnalysisFeedbackForm,
|
||||||
|
status: 'DRAFT' | 'COMPLETED',
|
||||||
|
) => {
|
||||||
|
const result = await giveFeedbackAction({
|
||||||
|
...data,
|
||||||
|
analysisOrderId: order.analysisOrderId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return toast.error(<Trans i18nKey="common:genericServerError" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
|
||||||
|
|
||||||
|
return setIsConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftSubmit = async (e: React.FormEvent) => {
|
||||||
|
setIsDraftSubmitting(true);
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
form.formState.errors.feedbackValue = undefined;
|
||||||
|
const formData = form.getValues();
|
||||||
|
await onSubmit(formData, 'DRAFT');
|
||||||
|
setIsDraftSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteSubmit = form.handleSubmit(async () => {
|
||||||
|
setIsConfirmOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmComplete = form.handleSubmit(async (data) => {
|
||||||
|
await onSubmit(data, 'COMPLETED');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="xs:flex xs:justify-between">
|
||||||
|
<h3>
|
||||||
|
<Trans
|
||||||
|
i18nKey={getResultSetName(
|
||||||
|
order.title,
|
||||||
|
order.isPackage,
|
||||||
|
Object.keys(analyses)?.length,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<div className="xs:flex hidden">
|
||||||
|
<DoctorJobSelect
|
||||||
|
analysisOrderId={order.analysisOrderId}
|
||||||
|
userId={patient.userId}
|
||||||
|
doctorUserId={feedback?.doctor_user_id}
|
||||||
|
isRemovable={isCurrentDoctorJob && isInProgress}
|
||||||
|
onJobUpdate={() =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xs:grid-cols-2 grid">
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:name" />
|
||||||
|
</div>
|
||||||
|
<div>{getFullName(patient.firstName, patient.lastName)}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:personalCode" />
|
||||||
|
</div>
|
||||||
|
<div>{patient.personalCode ?? ''}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:dobAndAge" />
|
||||||
|
</div>
|
||||||
|
<div>{getDOBWithAgeStringFromPersonalCode(patient.personalCode)}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:height" />
|
||||||
|
</div>
|
||||||
|
<div>{patient.height}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:weight" />
|
||||||
|
</div>
|
||||||
|
<div>{patient.weight}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:bmi" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{patient?.weight && patient?.height
|
||||||
|
? bmiFromMetric(patient.weight, patient.height)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:smoking" />
|
||||||
|
</div>
|
||||||
|
<div>-</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:phone" />
|
||||||
|
</div>
|
||||||
|
<div>{patient.phone}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="doctor:email" />
|
||||||
|
</div>
|
||||||
|
<div>{patient.email}</div>
|
||||||
|
<div className="font-bold">
|
||||||
|
<Trans i18nKey="common:language" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xs:hidden block">
|
||||||
|
<DoctorJobSelect
|
||||||
|
className="w-full"
|
||||||
|
analysisOrderId={order.analysisOrderId}
|
||||||
|
userId={patient.userId}
|
||||||
|
doctorUserId={feedback?.doctor_user_id}
|
||||||
|
isRemovable={isCurrentDoctorJob && isInProgress}
|
||||||
|
onJobUpdate={() =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey="doctor:results" />
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{analyses.map((analysisData) => {
|
||||||
|
return (
|
||||||
|
<DoctorAnalysisWrapper
|
||||||
|
key={analysisData.id}
|
||||||
|
analysisData={analysisData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey="doctor:feedback" />
|
||||||
|
</h3>
|
||||||
|
<p>{feedback?.value ?? '-'}</p>
|
||||||
|
{!isReadOnly && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="space-y-4 lg:w-1/2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="feedbackValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} disabled={isReadOnly} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="xs:flex block justify-end gap-2 space-y-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDraftSubmit}
|
||||||
|
disabled={
|
||||||
|
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
|
||||||
|
}
|
||||||
|
className="xs:w-1/4 w-full"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="common:saveAsDraft" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCompleteSubmit}
|
||||||
|
disabled={
|
||||||
|
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
|
||||||
|
}
|
||||||
|
className="xs:w-1/4 w-full"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="common:save" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={isConfirmOpen}
|
||||||
|
onClose={() => setIsConfirmOpen(false)}
|
||||||
|
onConfirm={confirmComplete}
|
||||||
|
titleKey="doctor:confirmFeedbackModal.title"
|
||||||
|
descriptionKey="doctor:confirmFeedbackModal.description"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
app/doctor/_components/doctor-analysis-wrapper.tsx
Normal file
103
app/doctor/_components/doctor-analysis-wrapper.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { AnalysisResponse } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
|
||||||
|
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||||
|
import { formatDate } from '@kit/shared/utils';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@kit/ui/collapsible';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
|
||||||
|
|
||||||
|
export default function DoctorAnalysisWrapper({
|
||||||
|
analysisData,
|
||||||
|
}: {
|
||||||
|
analysisData: AnalysisResponse;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible className="w-full" key={analysisData.id}>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
disabled={!analysisData.latestPreviousAnalysis}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
|
||||||
|
<Analysis
|
||||||
|
startIcon={
|
||||||
|
analysisData.latestPreviousAnalysis && (
|
||||||
|
<CaretDownIcon className="caret-icon transition-transform duration-200" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
endIcon={
|
||||||
|
analysisData.comment && (
|
||||||
|
<>
|
||||||
|
<div className="xs:flex hidden">
|
||||||
|
<InfoTooltip
|
||||||
|
content={analysisData.comment}
|
||||||
|
icon={
|
||||||
|
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="xs:hidden">
|
||||||
|
<strong>
|
||||||
|
<Trans i18nKey="doctor:labComment" />:
|
||||||
|
</strong>{' '}
|
||||||
|
{analysisData.comment}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
analysisElement={{
|
||||||
|
analysis_name_lab: analysisData.analysis_name,
|
||||||
|
}}
|
||||||
|
results={analysisData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
{analysisData.latestPreviousAnalysis && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="my-1 flex flex-col">
|
||||||
|
<Analysis
|
||||||
|
endIcon={
|
||||||
|
analysisData.latestPreviousAnalysis.comment && (
|
||||||
|
<>
|
||||||
|
<div className="xs:flex hidden">
|
||||||
|
<InfoTooltip
|
||||||
|
content={analysisData.latestPreviousAnalysis.comment}
|
||||||
|
icon={
|
||||||
|
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="xs:hidden">
|
||||||
|
<strong>
|
||||||
|
<Trans i18nKey="doctor:labComment" />:{' '}
|
||||||
|
</strong>
|
||||||
|
{analysisData.latestPreviousAnalysis.comment}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
analysisElement={{
|
||||||
|
analysis_name_lab: t('doctor:previousResults', {
|
||||||
|
date: formatDate(
|
||||||
|
analysisData.latestPreviousAnalysis.response_time,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
results={analysisData.latestPreviousAnalysis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/doctor/_components/doctor-dashboard.tsx
Normal file
36
app/doctor/_components/doctor-dashboard.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getOpenResponsesAction,
|
||||||
|
getOtherResponsesAction,
|
||||||
|
getUserDoneResponsesAction,
|
||||||
|
getUserInProgressResponsesAction,
|
||||||
|
} from '@kit/doctor/actions/table-data-fetching-actions';
|
||||||
|
import ResultsTableWrapper from './results-table-wrapper';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:openReviews"
|
||||||
|
action={getOpenResponsesAction}
|
||||||
|
queryKey="doctor-open-jobs"
|
||||||
|
/>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:myReviews"
|
||||||
|
action={getUserInProgressResponsesAction}
|
||||||
|
queryKey="doctor-in-progress-jobs"
|
||||||
|
/>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:completedReviews"
|
||||||
|
action={getUserDoneResponsesAction}
|
||||||
|
queryKey="doctor-done-jobs"
|
||||||
|
/>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:otherReviews"
|
||||||
|
action={getOtherResponsesAction}
|
||||||
|
queryKey="doctor-other-jobs"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
app/doctor/_components/doctor-guard.tsx
Normal file
28
app/doctor/_components/doctor-guard.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
|
||||||
|
|
||||||
|
|
||||||
|
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
|
||||||
|
* If the user is not a doctor, we redirect to a 404.
|
||||||
|
* @param Component - The Page or Layout component to wrap
|
||||||
|
*/
|
||||||
|
export function DoctorGuard<Params extends object>(
|
||||||
|
Component: LayoutOrPageComponent<Params>,
|
||||||
|
) {
|
||||||
|
return async function DoctorGuardServerComponentWrapper(params: Params) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const isUserDoctor = await isDoctor(client);
|
||||||
|
|
||||||
|
// if the user is not a super-admin, we redirect to a 404
|
||||||
|
if (!isUserDoctor) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component {...params} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
120
app/doctor/_components/doctor-job-select.tsx
Normal file
120
app/doctor/_components/doctor-job-select.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectJobAction,
|
||||||
|
unselectJobAction,
|
||||||
|
} from '@kit/doctor/actions/doctor-server-actions';
|
||||||
|
import { ErrorReason } from '@kit/doctor/schema/error.type';
|
||||||
|
import { Button, ButtonProps } from '@kit/ui/button';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
export default function DoctorJobSelect({
|
||||||
|
className,
|
||||||
|
size = 'sm',
|
||||||
|
doctorUserId,
|
||||||
|
doctorName,
|
||||||
|
analysisOrderId,
|
||||||
|
userId,
|
||||||
|
isRemovable,
|
||||||
|
onJobUpdate,
|
||||||
|
linkTo,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
size?: ButtonProps['size'];
|
||||||
|
doctorUserId?: string | null;
|
||||||
|
doctorName?: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
userId: string;
|
||||||
|
isRemovable?: boolean;
|
||||||
|
linkTo?: string;
|
||||||
|
onJobUpdate: () => void;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSelectJob = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await selectJobAction({
|
||||||
|
analysisOrderId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
onJobUpdate();
|
||||||
|
linkTo && router.push(linkTo);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
<Trans
|
||||||
|
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
if (result.reason === ErrorReason.JOB_ASSIGNED) {
|
||||||
|
onJobUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnselectJob = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await unselectJobAction({
|
||||||
|
analysisOrderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
onJobUpdate();
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
<Trans
|
||||||
|
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRemovable) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn('w-16', className)}
|
||||||
|
size={size}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleUnselectJob}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="doctor:unselectJob" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doctorUserId) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn('w-16', className)}
|
||||||
|
size={size}
|
||||||
|
onClick={handleSelectJob}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="doctor:selectJob" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{doctorName}</>;
|
||||||
|
}
|
||||||
103
app/doctor/_components/doctor-sidebar.tsx
Normal file
103
app/doctor/_components/doctor-sidebar.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||||
|
import { LayoutDashboard } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
useSidebar,
|
||||||
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export function DoctorSidebar({
|
||||||
|
accounts,
|
||||||
|
}: {
|
||||||
|
accounts: UserWorkspace['accounts'];
|
||||||
|
}) {
|
||||||
|
const path = usePathname();
|
||||||
|
const { open } = useSidebar();
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className={'m-2'}>
|
||||||
|
<AppLogo
|
||||||
|
href={pathsConfig.app.doctor}
|
||||||
|
className="max-w-full"
|
||||||
|
compact={!open}
|
||||||
|
/>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>
|
||||||
|
<Trans i18nKey="common:doctor" />
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path === pathsConfig.app.doctor}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link className={'flex gap-2.5'} href={pathsConfig.app.doctor}>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<Trans i18nKey={'doctor:sidebar.dashboard'} />
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path === pathsConfig.app.openJobs}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={'flex gap-2.5'}
|
||||||
|
href={pathsConfig.app.openJobs}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<Trans i18nKey={'doctor:sidebar.openReviews'} />
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path === pathsConfig.app.myJobs}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link className={'flex gap-2.5'} href={pathsConfig.app.myJobs}>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<Trans i18nKey={'doctor:sidebar.myReviews'} />
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path === pathsConfig.app.completedJobs}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={'flex gap-2.5'}
|
||||||
|
href={pathsConfig.app.completedJobs}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<ProfileAccountDropdownContainer accounts={accounts} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
app/doctor/_components/mobile-navigation.tsx
Normal file
50
app/doctor/_components/mobile-navigation.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@kit/ui/dropdown-menu';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export function DoctorMobileNavigation() {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Menu className={'h-8 w-8'} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={pathsConfig.app.home}>
|
||||||
|
<Trans i18nKey={'common:routes.home'} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={pathsConfig.app.doctor}>
|
||||||
|
<Trans i18nKey={'doctor:sidebar.dashboard'} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={pathsConfig.app.openJobs}>
|
||||||
|
<Trans i18nKey={'doctor:sidebar.openReviews'} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={pathsConfig.app.myJobs}>
|
||||||
|
<Trans i18nKey={'doctor:sidebar.myReviews'} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={pathsConfig.app.completedJobs}>
|
||||||
|
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
app/doctor/_components/results-table-wrapper.tsx
Normal file
120
app/doctor/_components/results-table-wrapper.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PaginatedData,
|
||||||
|
ResponseTable,
|
||||||
|
ServerActionResponse,
|
||||||
|
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
|
import TableSkeleton from '@kit/shared/components/table-skeleton';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import ResultsTable from './results-table';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function ResultsTableWrapper({
|
||||||
|
titleKey,
|
||||||
|
action,
|
||||||
|
queryKey,
|
||||||
|
}: {
|
||||||
|
titleKey: string;
|
||||||
|
action: ({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => Promise<ServerActionResponse<PaginatedData<ResponseTable>>>;
|
||||||
|
queryKey: string;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: jobs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [queryKey, 'doctor-jobs', page],
|
||||||
|
queryFn: async () => await action({ page: page, pageSize: PAGE_SIZE }),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const onJobUpdate = () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPageChangeHandler = (setPage: (page: number) => void) => {
|
||||||
|
return async ({ page }: { page: number; pageSize: number }) => {
|
||||||
|
setPage(page);
|
||||||
|
return { success: true, data: null };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey={titleKey} />
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`transition-opacity duration-200 ${
|
||||||
|
isFetching ? 'opacity-50' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TableSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey={titleKey} />
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-lg text-red-600">
|
||||||
|
<Trans i18nKey="common:genericServerError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey={titleKey} />
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn('opacity-100 transition-opacity duration-200', {
|
||||||
|
'opacity-50': isFetching,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ResultsTable
|
||||||
|
results={jobs?.data?.data || []}
|
||||||
|
pagination={jobs?.data?.pagination}
|
||||||
|
fetchAction={createPageChangeHandler(setPage)}
|
||||||
|
onJobUpdate={onJobUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
app/doctor/_components/results-table.tsx
Normal file
243
app/doctor/_components/results-table.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
||||||
|
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
|
||||||
|
import { getFullName } from '@kit/shared/utils';
|
||||||
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@kit/ui/table';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import DoctorJobSelect from './doctor-job-select';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
|
||||||
|
export default function ResultsTable({
|
||||||
|
results = [],
|
||||||
|
pagination = {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
fetchAction,
|
||||||
|
onJobUpdate,
|
||||||
|
}: {
|
||||||
|
results: ResponseTable[] | null;
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
fetchAction: ({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: null;
|
||||||
|
}>;
|
||||||
|
onJobUpdate: () => void;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const { data: currentUser } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
|
const fetchPage = async (page: number) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await fetchAction({
|
||||||
|
page,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error('common.genericServerError');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (pagination.currentPage < pagination.totalPages) {
|
||||||
|
fetchPage(pagination.currentPage + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
if (pagination.currentPage > 1) {
|
||||||
|
fetchPage(pagination.currentPage - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJobUpdate = () => {
|
||||||
|
onJobUpdate();
|
||||||
|
fetchPage(pagination.currentPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!results?.length) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="common:noData" />.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table className="border-separate rounded-lg border">
|
||||||
|
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-6"></TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.patientName" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.serviceName" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.orderNr" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.time" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.language" />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{results
|
||||||
|
?.sort((a, b) =>
|
||||||
|
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
|
||||||
|
)
|
||||||
|
.map((result) => {
|
||||||
|
const isCompleted = result.feedback?.status === 'COMPLETED';
|
||||||
|
const isCurrentDoctorJob =
|
||||||
|
!!result.doctor?.primary_owner_user_id &&
|
||||||
|
result.doctor?.primary_owner_user_id === currentUser?.id;
|
||||||
|
|
||||||
|
const resultsReceived = result.elements.length || 0;
|
||||||
|
const elementsInOrder = Array.from(
|
||||||
|
new Set(result.analysis_order_id.analysis_element_ids),
|
||||||
|
)?.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={result.order_number}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Link
|
||||||
|
href={`/${pathsConfig.app.analysisDetails}/${result.id}`}
|
||||||
|
>
|
||||||
|
<Eye />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getFullName(
|
||||||
|
result.patient?.name,
|
||||||
|
result.patient?.last_name,
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Trans
|
||||||
|
i18nKey={getResultSetName(
|
||||||
|
result.order?.title ?? '-',
|
||||||
|
result.order?.isPackage,
|
||||||
|
result.elements?.length || 0,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{result.order_number}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{result.firstSampleGivenAt
|
||||||
|
? format(result.firstSampleGivenAt, 'dd.MM.yyyy HH:mm')
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Trans
|
||||||
|
i18nKey={
|
||||||
|
resultsReceived === elementsInOrder
|
||||||
|
? 'doctor:resultsTable.responsesReceived'
|
||||||
|
: 'doctor:resultsTable.waitingForNr'
|
||||||
|
}
|
||||||
|
values={{
|
||||||
|
nr: elementsInOrder - resultsReceived,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{capitalize(
|
||||||
|
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DoctorJobSelect
|
||||||
|
doctorUserId={result.doctor?.primary_owner_user_id}
|
||||||
|
doctorName={getFullName(
|
||||||
|
result.doctor?.name,
|
||||||
|
result.doctor?.last_name,
|
||||||
|
)}
|
||||||
|
analysisOrderId={result.analysis_order_id?.id}
|
||||||
|
userId={result.patient?.id}
|
||||||
|
isRemovable={!isCompleted && isCurrentDoctorJob}
|
||||||
|
onJobUpdate={handleJobUpdate}
|
||||||
|
linkTo={`/${pathsConfig.app.analysisDetails}/${result.id}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={pagination.currentPage === 1 || isPending}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="common:previous" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
<Trans
|
||||||
|
i18nKey={'common:pageOfPages'}
|
||||||
|
values={{
|
||||||
|
page: pagination.currentPage,
|
||||||
|
total: pagination.totalPages,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={
|
||||||
|
pagination.currentPage === pagination.totalPages || isPending
|
||||||
|
}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Trans i18nKey="common:next" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/doctor/analysis/[id]/page.tsx
Normal file
54
app/doctor/analysis/[id]/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoctorPageViewAction,
|
||||||
|
createDoctorPageViewLog,
|
||||||
|
} from '~/lib/services/audit/doctorPageView.service';
|
||||||
|
|
||||||
|
import AnalysisView from '../../_components/analysis-view';
|
||||||
|
import { DoctorGuard } from '../../_components/doctor-guard';
|
||||||
|
|
||||||
|
async function AnalysisPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const { id: analysisOrderId } = await params;
|
||||||
|
const analysisResultDetails = await loadResult(Number(analysisOrderId));
|
||||||
|
|
||||||
|
if (!analysisResultDetails) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysisResultDetails) {
|
||||||
|
await createDoctorPageViewLog({
|
||||||
|
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||||
|
recordKey: analysisOrderId,
|
||||||
|
dataOwnerUserId: analysisResultDetails.patient.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody>
|
||||||
|
<AnalysisView
|
||||||
|
patient={analysisResultDetails.patient}
|
||||||
|
order={analysisResultDetails.order}
|
||||||
|
analyses={analysisResultDetails.analysisResponse}
|
||||||
|
feedback={analysisResultDetails.doctorFeedback}
|
||||||
|
/>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorGuard(AnalysisPage);
|
||||||
|
const loadResult = cache(getAnalysisResultsForDoctor);
|
||||||
|
|
||||||
|
|
||||||
31
app/doctor/completed-jobs/page.tsx
Normal file
31
app/doctor/completed-jobs/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { getUserDoneResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoctorPageViewAction,
|
||||||
|
createDoctorPageViewLog,
|
||||||
|
} from '~/lib/services/audit/doctorPageView.service';
|
||||||
|
|
||||||
|
import { DoctorGuard } from '../_components/doctor-guard';
|
||||||
|
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
||||||
|
|
||||||
|
async function CompletedJobsPage() {
|
||||||
|
await createDoctorPageViewLog({
|
||||||
|
action: DoctorPageViewAction.VIEW_DONE_JOBS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:completedReviews"
|
||||||
|
action={getUserDoneResponsesAction}
|
||||||
|
queryKey="doctor-done-jobs"
|
||||||
|
/>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorGuard(CompletedJobsPage);
|
||||||
47
app/doctor/layout.tsx
Normal file
47
app/doctor/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
|
||||||
|
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
|
||||||
|
import { DoctorSidebar } from './_components/doctor-sidebar';
|
||||||
|
import { DoctorMobileNavigation } from './_components/mobile-navigation';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: `Doctor`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function DoctorLayout(props: React.PropsWithChildren) {
|
||||||
|
const state = use(getLayoutState());
|
||||||
|
const workspace = use(loadUserWorkspace());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider defaultOpen={state.open}>
|
||||||
|
<Page style={'sidebar'}>
|
||||||
|
<PageNavigation>
|
||||||
|
<DoctorSidebar accounts={workspace.accounts} />
|
||||||
|
</PageNavigation>
|
||||||
|
|
||||||
|
<PageMobileNavigation>
|
||||||
|
<DoctorMobileNavigation />
|
||||||
|
</PageMobileNavigation>
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</Page>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLayoutState() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: sidebarOpenCookie?.value !== 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
3
app/doctor/loading.tsx
Normal file
3
app/doctor/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||||
|
|
||||||
|
export default GlobalLoader;
|
||||||
31
app/doctor/my-jobs/page.tsx
Normal file
31
app/doctor/my-jobs/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { getUserInProgressResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoctorPageViewAction,
|
||||||
|
createDoctorPageViewLog,
|
||||||
|
} from '~/lib/services/audit/doctorPageView.service';
|
||||||
|
|
||||||
|
import { DoctorGuard } from '../_components/doctor-guard';
|
||||||
|
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
||||||
|
|
||||||
|
async function MyReviewsPage() {
|
||||||
|
await createDoctorPageViewLog({
|
||||||
|
action: DoctorPageViewAction.VIEW_OWN_JOBS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:myReviews"
|
||||||
|
action={getUserInProgressResponsesAction}
|
||||||
|
queryKey="doctor-in-progress-jobs"
|
||||||
|
/>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorGuard(MyReviewsPage);
|
||||||
31
app/doctor/open-jobs/page.tsx
Normal file
31
app/doctor/open-jobs/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { getOpenResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoctorPageViewAction,
|
||||||
|
createDoctorPageViewLog,
|
||||||
|
} from '~/lib/services/audit/doctorPageView.service';
|
||||||
|
|
||||||
|
import { DoctorGuard } from '../_components/doctor-guard';
|
||||||
|
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
||||||
|
|
||||||
|
async function OpenJobsPage() {
|
||||||
|
await createDoctorPageViewLog({
|
||||||
|
action: DoctorPageViewAction.VIEW_OPEN_JOBS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody>
|
||||||
|
<ResultsTableWrapper
|
||||||
|
titleKey="doctor:openReviews"
|
||||||
|
action={getOpenResponsesAction}
|
||||||
|
queryKey="doctor-open-jobs"
|
||||||
|
/>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorGuard(OpenJobsPage);
|
||||||
26
app/doctor/page.tsx
Normal file
26
app/doctor/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoctorPageViewAction,
|
||||||
|
createDoctorPageViewLog,
|
||||||
|
} from '~/lib/services/audit/doctorPageView.service';
|
||||||
|
|
||||||
|
import Dashboard from './_components/doctor-dashboard';
|
||||||
|
import { DoctorGuard } from './_components/doctor-guard';
|
||||||
|
|
||||||
|
async function DoctorPage() {
|
||||||
|
await createDoctorPageViewLog({
|
||||||
|
action: DoctorPageViewAction.VIEW_DASHBOARD,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody>
|
||||||
|
<Dashboard />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DoctorGuard(DoctorPage);
|
||||||
@@ -10,7 +10,7 @@ import { Heading } from '@kit/ui/heading';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
import { RootProviders } from '~/components/root-providers';
|
import { RootProviders } from '@kit/shared/components/root-providers';
|
||||||
|
|
||||||
const GlobalErrorPage = ({
|
const GlobalErrorPage = ({
|
||||||
error,
|
error,
|
||||||
|
|||||||
7
app/health/page.tsx
Normal file
7
app/health/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const HealthPage = () => {
|
||||||
|
const commit = process.env.NEXT_PUBLIC_COMMIT_HASH;
|
||||||
|
|
||||||
|
return <div>{commit}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthPage;
|
||||||
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal file
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
|
||||||
|
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
|
||||||
|
import {
|
||||||
|
PageViewAction,
|
||||||
|
createPageViewLog,
|
||||||
|
} from '~/lib/services/audit/pageView.service';
|
||||||
|
|
||||||
|
import Analysis from '../_components/analysis';
|
||||||
|
|
||||||
|
export default async function AnalysisResultsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
|
||||||
|
const { id: analysisResponseId } = await params;
|
||||||
|
|
||||||
|
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
|
||||||
|
|
||||||
|
if (!account?.id || !analysisResponse) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createPageViewLog({
|
||||||
|
accountId: account.id,
|
||||||
|
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<PageBody className="gap-4">
|
||||||
|
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
|
||||||
|
<div>
|
||||||
|
<h4>
|
||||||
|
<Trans i18nKey="analysis-results:pageTitle" />
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{analysisResponse?.elements &&
|
||||||
|
analysisResponse.elements?.length > 0 ? (
|
||||||
|
<Trans i18nKey="analysis-results:description" />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="analysis-results:descriptionEmpty" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={pathsConfig.app.orderAnalysisPackage}>
|
||||||
|
<Trans i18nKey="analysis-results:orderNewAnalysis" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h4>
|
||||||
|
<Trans
|
||||||
|
i18nKey="analysis-results:orderTitle"
|
||||||
|
values={{ orderNumber: analysisResponse.order.medusa_order_id }}
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<h5>
|
||||||
|
<Trans
|
||||||
|
i18nKey={`orders:status.${analysisResponse.order.status}`}
|
||||||
|
/>
|
||||||
|
<ButtonTooltip
|
||||||
|
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
|
||||||
|
className="ml-6"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{analysisResponse?.summary?.value && (
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
<Trans i18nKey="account:doctorAnalysisSummary" />
|
||||||
|
</strong>
|
||||||
|
<p>{analysisResponse.summary.value}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{analysisResponse.elements ? (
|
||||||
|
analysisResponse.elements.map((element, index) => (
|
||||||
|
<Analysis
|
||||||
|
key={index}
|
||||||
|
analysisElement={{ analysis_name_lab: element.analysis_name }}
|
||||||
|
results={element}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="analysis-results:noAnalysisElements" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
import { AnalysisResultForDisplay } from './analysis';
|
||||||
|
|
||||||
|
export enum AnalysisResultLevel {
|
||||||
|
VERY_LOW = 0,
|
||||||
|
LOW = 1,
|
||||||
|
NORMAL = 2,
|
||||||
|
HIGH = 3,
|
||||||
|
VERY_HIGH = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Level = ({
|
||||||
|
isActive = false,
|
||||||
|
color,
|
||||||
|
isFirst = false,
|
||||||
|
isLast = false,
|
||||||
|
arrowLocation,
|
||||||
|
}: {
|
||||||
|
isActive?: boolean;
|
||||||
|
color: 'destructive' | 'success' | 'warning' | 'gray-200';
|
||||||
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
arrowLocation?: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(`bg-${color} relative h-3 flex-1`, {
|
||||||
|
'opacity-20': !isActive,
|
||||||
|
'rounded-l-lg': isFirst,
|
||||||
|
'rounded-r-lg': isLast,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
|
||||||
|
style={{ left: `${arrowLocation}%` }}
|
||||||
|
>
|
||||||
|
<ArrowDown strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalysisLevelBarSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
|
||||||
|
<Level color="gray-200" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnalysisLevelBar = ({
|
||||||
|
normLowerIncluded = true,
|
||||||
|
normUpperIncluded = true,
|
||||||
|
level,
|
||||||
|
results,
|
||||||
|
}: {
|
||||||
|
normLowerIncluded?: boolean;
|
||||||
|
normUpperIncluded?: boolean;
|
||||||
|
level: AnalysisResultLevel;
|
||||||
|
results: AnalysisResultForDisplay;
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const { norm_lower: lower, norm_upper: upper, response_value: value } = results;
|
||||||
|
const arrowLocation = useMemo(() => {
|
||||||
|
if (value < lower!) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normLowerIncluded || normUpperIncluded) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
|
||||||
|
|
||||||
|
if (calculated > 100) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculated;
|
||||||
|
}, [value, upper, lower]);
|
||||||
|
|
||||||
|
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [
|
||||||
|
level === AnalysisResultLevel.VERY_LOW,
|
||||||
|
level === AnalysisResultLevel.LOW,
|
||||||
|
level === AnalysisResultLevel.HIGH,
|
||||||
|
level === AnalysisResultLevel.VERY_HIGH,
|
||||||
|
], [level, value, upper, lower]);
|
||||||
|
|
||||||
|
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
|
||||||
|
{normLowerIncluded && (
|
||||||
|
<>
|
||||||
|
<Level
|
||||||
|
isActive={isVeryLow}
|
||||||
|
color="destructive"
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
<Level isActive={isLow} color="warning" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Level
|
||||||
|
isFirst={!normLowerIncluded}
|
||||||
|
isLast={!normUpperIncluded}
|
||||||
|
{...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
|
||||||
|
arrowLocation={arrowLocation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{normUpperIncluded && (
|
||||||
|
<>
|
||||||
|
<Level
|
||||||
|
isActive={isHigh}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<Level
|
||||||
|
isActive={isVeryHigh}
|
||||||
|
color="destructive"
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalysisLevelBar;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
|
import { AnalysisElement } from '~/lib/services/analysis-element.service';
|
||||||
|
|
||||||
|
import AnalysisLevelBar, {
|
||||||
|
AnalysisLevelBarSkeleton,
|
||||||
|
AnalysisResultLevel,
|
||||||
|
} from './analysis-level-bar';
|
||||||
|
|
||||||
|
export type AnalysisResultForDisplay = Pick<
|
||||||
|
UserAnalysisElement,
|
||||||
|
| 'norm_status'
|
||||||
|
| 'response_value'
|
||||||
|
| 'unit'
|
||||||
|
| 'norm_lower_included'
|
||||||
|
| 'norm_upper_included'
|
||||||
|
| 'norm_lower'
|
||||||
|
| 'norm_upper'
|
||||||
|
| 'response_time'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export enum AnalysisStatus {
|
||||||
|
NORMAL = 0,
|
||||||
|
MEDIUM = 1,
|
||||||
|
HIGH = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Analysis = ({
|
||||||
|
analysisElement,
|
||||||
|
results,
|
||||||
|
startIcon,
|
||||||
|
endIcon,
|
||||||
|
isCancelled,
|
||||||
|
}: {
|
||||||
|
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
|
||||||
|
results?: AnalysisResultForDisplay;
|
||||||
|
isCancelled?: boolean;
|
||||||
|
startIcon?: ReactElement | null;
|
||||||
|
endIcon?: ReactNode | null;
|
||||||
|
}) => {
|
||||||
|
const name = analysisElement.analysis_name_lab || '';
|
||||||
|
const status = results?.norm_status || AnalysisStatus.NORMAL;
|
||||||
|
const value = results?.response_value || 0;
|
||||||
|
const unit = results?.unit || '';
|
||||||
|
const normLowerIncluded = results?.norm_lower_included || false;
|
||||||
|
const normUpperIncluded = results?.norm_upper_included || false;
|
||||||
|
const normLower = results?.norm_lower || 0;
|
||||||
|
const normUpper = results?.norm_upper || 0;
|
||||||
|
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const analysisResultLevel = useMemo(() => {
|
||||||
|
if (!results) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnderNorm = value < normLower;
|
||||||
|
if (isUnderNorm) {
|
||||||
|
switch (status) {
|
||||||
|
case AnalysisStatus.MEDIUM:
|
||||||
|
return AnalysisResultLevel.LOW;
|
||||||
|
default:
|
||||||
|
return AnalysisResultLevel.VERY_LOW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case AnalysisStatus.MEDIUM:
|
||||||
|
return AnalysisResultLevel.HIGH;
|
||||||
|
case AnalysisStatus.HIGH:
|
||||||
|
return AnalysisResultLevel.VERY_HIGH;
|
||||||
|
default:
|
||||||
|
return AnalysisResultLevel.NORMAL;
|
||||||
|
}
|
||||||
|
}, [results, value, normLower]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-border rounded-lg border px-5">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
{startIcon || <div className="w-4" />}
|
||||||
|
{name}
|
||||||
|
{results?.response_time && (
|
||||||
|
<div
|
||||||
|
className="group/tooltip relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowTooltip(!showTooltip);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
|
>
|
||||||
|
<Info className="hover" />{' '}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
|
||||||
|
{ block: showTooltip },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="analysis-results:analysisDate" />
|
||||||
|
{': '}
|
||||||
|
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{results ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 sm:ml-auto">
|
||||||
|
<div className="font-semibold">{value}</div>
|
||||||
|
<div className="text-muted-foreground text-sm">{unit}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
|
||||||
|
{normLower} - {normUpper}
|
||||||
|
<div>
|
||||||
|
<Trans i18nKey="analysis-results:results.range.normal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnalysisLevelBar
|
||||||
|
results={results}
|
||||||
|
normLowerIncluded={normLowerIncluded}
|
||||||
|
normUpperIncluded={normUpperIncluded}
|
||||||
|
level={analysisResultLevel!}
|
||||||
|
/>
|
||||||
|
{endIcon || <div className="mx-2 w-4" />}
|
||||||
|
</>
|
||||||
|
) : (isCancelled ? null : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 sm:ml-auto">
|
||||||
|
<div className="font-semibold">
|
||||||
|
<Trans i18nKey="analysis-results:waitingForResults" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-8 w-[60px]"></div>
|
||||||
|
<AnalysisLevelBarSkeleton />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analysis;
|
||||||
41
app/home/(user)/(dashboard)/booking/[handle]/page.tsx
Normal file
41
app/home/(user)/(dashboard)/booking/[handle]/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
|
||||||
|
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
|
||||||
|
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('booking:title');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
||||||
|
const handle = await params.handle;
|
||||||
|
const { category } = await loadCategory({ handle });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumbs
|
||||||
|
values={{
|
||||||
|
[handle]: category?.name || handle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'booking:title'} />}
|
||||||
|
description={<Trans i18nKey={'booking:description'} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody></PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(BookingHandlePage);
|
||||||
54
app/home/(user)/(dashboard)/booking/page.tsx
Normal file
54
app/home/(user)/(dashboard)/booking/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
||||||
|
import OrderCards from '../../_components/order-cards';
|
||||||
|
import ServiceCategories from '../../_components/service-categories';
|
||||||
|
import { loadTtoServices } from '../../_lib/server/load-tto-services';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('booking:title');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function BookingPage() {
|
||||||
|
const { heroCategories, ttoCategories } = use(loadTtoServices());
|
||||||
|
|
||||||
|
if (!heroCategories.length && !ttoCategories.length) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumbs />
|
||||||
|
<h3 className="mt-8">
|
||||||
|
<Trans i18nKey="booking:noCategories" />
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumbs />
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'booking:title'} />}
|
||||||
|
description={<Trans i18nKey={'booking:description'} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody className="space-y-2">
|
||||||
|
<OrderCards heroCategories={heroCategories} />
|
||||||
|
<ServiceCategories categories={ttoCategories} />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(BookingPage);
|
||||||
153
app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts
Normal file
153
app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { z } from "zod";
|
||||||
|
import { MontonioOrderToken } from "@/app/home/(user)/_components/cart/types";
|
||||||
|
import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user-account";
|
||||||
|
import { listProductTypes } from "@lib/data/products";
|
||||||
|
import { placeOrder, retrieveCart } from "@lib/data/cart";
|
||||||
|
import { createI18nServerInstance } from "~/lib/i18n/i18n.server";
|
||||||
|
import { createOrder } from '~/lib/services/order.service';
|
||||||
|
import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service';
|
||||||
|
import { createNotificationsApi } from '@kit/notifications/api';
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
import { AccountWithParams } from '@kit/accounts/api';
|
||||||
|
|
||||||
|
const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages';
|
||||||
|
const MONTONIO_PAID_STATUS = 'PAID';
|
||||||
|
|
||||||
|
const env = () => z
|
||||||
|
.object({
|
||||||
|
emailSender: z
|
||||||
|
.string({
|
||||||
|
error: 'EMAIL_SENDER is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
siteUrl: z
|
||||||
|
.string({
|
||||||
|
error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||||
|
})
|
||||||
|
.min(1),
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
emailSender: process.env.EMAIL_SENDER,
|
||||||
|
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEmail = async ({
|
||||||
|
account,
|
||||||
|
email,
|
||||||
|
analysisPackageName,
|
||||||
|
personName,
|
||||||
|
partnerLocationName,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
account: AccountWithParams,
|
||||||
|
email: string,
|
||||||
|
analysisPackageName: string,
|
||||||
|
personName: string,
|
||||||
|
partnerLocationName: string,
|
||||||
|
language: string,
|
||||||
|
}) => {
|
||||||
|
const client = getSupabaseServerAdminClient();
|
||||||
|
try {
|
||||||
|
const { renderSynlabAnalysisPackageEmail } = await import('@kit/email-templates');
|
||||||
|
const { getMailer } = await import('@kit/mailers');
|
||||||
|
|
||||||
|
const mailer = await getMailer();
|
||||||
|
|
||||||
|
const { html, subject } = await renderSynlabAnalysisPackageEmail({
|
||||||
|
analysisPackageName,
|
||||||
|
personName,
|
||||||
|
partnerLocationName,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer
|
||||||
|
.sendEmail({
|
||||||
|
from: env().emailSender,
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
throw new Error(`Failed to send email, message=${error}`);
|
||||||
|
});
|
||||||
|
await createNotificationsApi(client)
|
||||||
|
.createNotification({
|
||||||
|
account_id: account.id,
|
||||||
|
body: html,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to send email, message=${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processMontonioCallback(orderToken: string) {
|
||||||
|
const { language } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
const secretKey = process.env.MONTONIO_SECRET_KEY as string;
|
||||||
|
|
||||||
|
const decoded = jwt.verify(orderToken, secretKey, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as MontonioOrderToken;
|
||||||
|
|
||||||
|
if (decoded.paymentStatus !== MONTONIO_PAID_STATUS) {
|
||||||
|
throw new Error("Payment not successful");
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Account not found in context");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [, , cartId] = decoded.merchantReferenceDisplay.split(':');
|
||||||
|
if (!cartId) {
|
||||||
|
throw new Error("Cart ID not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cart = await retrieveCart(cartId);
|
||||||
|
if (!cart) {
|
||||||
|
throw new Error("Cart not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false });
|
||||||
|
const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder });
|
||||||
|
const orderId = await createOrder({ medusaOrder, orderedAnalysisElements });
|
||||||
|
|
||||||
|
const { productTypes } = await listProductTypes();
|
||||||
|
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE);
|
||||||
|
const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id);
|
||||||
|
|
||||||
|
const orderResult = {
|
||||||
|
medusaOrderId: medusaOrder.id,
|
||||||
|
email: medusaOrder.email,
|
||||||
|
partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '',
|
||||||
|
analysisPackageName: analysisPackageOrderItem?.title ?? '',
|
||||||
|
orderedAnalysisElements,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult;
|
||||||
|
const personName = account.name;
|
||||||
|
|
||||||
|
if (email && analysisPackageName) {
|
||||||
|
try {
|
||||||
|
await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send email", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// @TODO send email for separate analyses
|
||||||
|
console.error("Missing email or analysisPackageName", orderResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
||||||
|
|
||||||
|
return { success: true, orderId };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to place order", error);
|
||||||
|
throw new Error(`Failed to place order, message=${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { processMontonioCallback } from './actions';
|
||||||
|
|
||||||
|
export default function MontonioCallbackClient({ orderToken, error }: {
|
||||||
|
orderToken?: string;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [hasProcessed, setHasProcessed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
router.push('/home/cart/montonio-callback/error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderToken || hasProcessed || isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processOrder = async () => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setHasProcessed(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { orderId } = await processMontonioCallback(orderToken);
|
||||||
|
router.push(`/home/order/${orderId}/confirmed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to place order", error);
|
||||||
|
router.push('/home/cart/montonio-callback/error');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processOrder();
|
||||||
|
}, [orderToken, error, router, hasProcessed, isProcessing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex mt-10 justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
|
||||||
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { Alert, AlertDescription } from '@kit/ui/shadcn/alert';
|
||||||
|
import { AlertTitle } from '@kit/ui/shadcn/alert';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('cart:montonioCallback.title'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MontonioCheckoutCallbackErrorPage() {
|
||||||
|
return (
|
||||||
|
<div className={'flex h-full flex-1 flex-col'}>
|
||||||
|
<PageHeader title={<Trans i18nKey="cart:montonioCallback.title" />} />
|
||||||
|
<PageBody>
|
||||||
|
<div className={'flex flex-col space-y-4'}>
|
||||||
|
<Alert variant={'destructive'}>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'checkout.error.title'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey={'checkout.error.description'} />
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className={'flex'}>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={'/home'}>
|
||||||
|
<Trans i18nKey={'checkout.goToDashboard'} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/home/(user)/(dashboard)/cart/montonio-callback/page.tsx
Normal file
15
app/home/(user)/(dashboard)/cart/montonio-callback/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import MontonioCallbackClient from './client-component';
|
||||||
|
|
||||||
|
export default async function MontonioCallbackPage({ searchParams }: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
'order-token'?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const orderToken = (await searchParams)['order-token'];
|
||||||
|
|
||||||
|
if (!orderToken) {
|
||||||
|
return <MontonioCallbackClient error="Order token is missing" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MontonioCallbackClient orderToken={orderToken} />;
|
||||||
|
}
|
||||||
52
app/home/(user)/(dashboard)/cart/page.tsx
Normal file
52
app/home/(user)/(dashboard)/cart/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
||||||
|
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { retrieveCart } from '@lib/data/cart';
|
||||||
|
import Cart from '../../_components/cart';
|
||||||
|
import { listProductTypes } from '@lib/data/products';
|
||||||
|
import CartTimer from '../../_components/cart/cart-timer';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('cart:title'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CartPage() {
|
||||||
|
const cart = await retrieveCart().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
return notFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { productTypes } = await listProductTypes();
|
||||||
|
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages');
|
||||||
|
const synlabAnalysisType = productTypes.find(({ metadata }) => metadata?.handle === 'synlab-analysis');
|
||||||
|
const synlabAnalyses = analysisPackagesType && synlabAnalysisType && cart?.items
|
||||||
|
? cart.items.filter((item) => {
|
||||||
|
const productTypeId = item.product?.type_id;
|
||||||
|
if (!productTypeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return [analysisPackagesType.id, synlabAnalysisType.id].includes(productTypeId);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const ttoServiceItems = cart?.items?.filter((item) => !synlabAnalyses.some((analysis) => analysis.id === item.id)) ?? [];
|
||||||
|
|
||||||
|
const otherItemsSorted = ttoServiceItems.sort((a, b) => (a.updated_at ?? "") > (b.updated_at ?? "") ? -1 : 1);
|
||||||
|
const item = otherItemsSorted[0];
|
||||||
|
const isTimerShown = ttoServiceItems.length > 0 && !!item && !!item.updated_at;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageBody>
|
||||||
|
<PageHeader title={<Trans i18nKey="cart:title" />}>
|
||||||
|
{isTimerShown && <CartTimer cartItem={item} />}
|
||||||
|
</PageHeader>
|
||||||
|
<Cart cart={cart} synlabAnalyses={synlabAnalyses} ttoServiceItems={ttoServiceItems} />
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { use } from 'react';
|
import { use } from 'react';
|
||||||
|
|
||||||
import { cookies } from 'next/headers';
|
import { retrieveCart } from '@lib/data/cart';
|
||||||
|
import { StoreCart } from '@medusajs/types';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
import { UserWorkspaceContextProvider } from '@kit/accounts/components';
|
||||||
|
import { AppLogo } from '@kit/shared/components/app-logo';
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
import { AppLogo } from '~/components/app-logo';
|
|
||||||
import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config';
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
// home imports
|
// home imports
|
||||||
@@ -19,52 +18,24 @@ import { HomeSidebar } from '../_components/home-sidebar';
|
|||||||
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
import { loadUserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
|
||||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||||
const state = use(getLayoutState());
|
|
||||||
|
|
||||||
if (state.style === 'sidebar') {
|
|
||||||
return <SidebarLayout>{children}</SidebarLayout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <HeaderLayout>{children}</HeaderLayout>;
|
return <HeaderLayout>{children}</HeaderLayout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(UserHomeLayout);
|
export default withI18n(UserHomeLayout);
|
||||||
|
|
||||||
function SidebarLayout({ children }: React.PropsWithChildren) {
|
|
||||||
const workspace = use(loadUserWorkspace());
|
|
||||||
const state = use(getLayoutState());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserWorkspaceContextProvider value={workspace}>
|
|
||||||
<SidebarProvider defaultOpen={state.open}>
|
|
||||||
<Page style={'sidebar'}>
|
|
||||||
<PageNavigation>
|
|
||||||
<HomeSidebar />
|
|
||||||
</PageNavigation>
|
|
||||||
|
|
||||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
|
||||||
<MobileNavigation workspace={workspace} />
|
|
||||||
</PageMobileNavigation>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</Page>
|
|
||||||
</SidebarProvider>
|
|
||||||
</UserWorkspaceContextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderLayout({ children }: React.PropsWithChildren) {
|
function HeaderLayout({ children }: React.PropsWithChildren) {
|
||||||
const workspace = use(loadUserWorkspace());
|
const workspace = use(loadUserWorkspace());
|
||||||
|
const cart = use(retrieveCart());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserWorkspaceContextProvider value={workspace}>
|
<UserWorkspaceContextProvider value={workspace}>
|
||||||
<Page style={'header'}>
|
<Page style={'header'}>
|
||||||
<PageNavigation>
|
<PageNavigation>
|
||||||
<HomeMenuNavigation workspace={workspace} />
|
<HomeMenuNavigation workspace={workspace} cart={cart} />
|
||||||
</PageNavigation>
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation className={'flex items-center justify-between'}>
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
||||||
<MobileNavigation workspace={workspace} />
|
<MobileNavigation workspace={workspace} cart={cart} />
|
||||||
</PageMobileNavigation>
|
</PageMobileNavigation>
|
||||||
|
|
||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
@@ -82,38 +53,16 @@ function HeaderLayout({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
function MobileNavigation({
|
function MobileNavigation({
|
||||||
workspace,
|
workspace,
|
||||||
|
cart,
|
||||||
}: {
|
}: {
|
||||||
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
workspace: Awaited<ReturnType<typeof loadUserWorkspace>>;
|
||||||
|
cart: StoreCart | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppLogo />
|
<AppLogo href={pathsConfig.app.home} />
|
||||||
|
|
||||||
<HomeMobileNavigation workspace={workspace} />
|
<HomeMobileNavigation workspace={workspace} cart={cart} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLayoutState() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']);
|
|
||||||
|
|
||||||
const layoutStyleCookie = cookieStore.get('layout-style');
|
|
||||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
|
||||||
|
|
||||||
const sidebarOpen = sidebarOpenCookie
|
|
||||||
? sidebarOpenCookie.value === 'false'
|
|
||||||
: !personalAccountNavigationConfig.sidebarCollapsed;
|
|
||||||
|
|
||||||
const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value);
|
|
||||||
|
|
||||||
const style = parsedStyle.success
|
|
||||||
? parsedStyle.data
|
|
||||||
: personalAccountNavigationConfig.style;
|
|
||||||
|
|
||||||
return {
|
|
||||||
open: sidebarOpen,
|
|
||||||
style,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
48
app/home/(user)/(dashboard)/order-analysis-package/page.tsx
Normal file
48
app/home/(user)/(dashboard)/order-analysis-package/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Scale } from 'lucide-react';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import SelectAnalysisPackages from '@kit/shared/components/select-analysis-packages';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import ComparePackagesModal from '../../_components/compare-packages-modal';
|
||||||
|
import { loadAnalysisPackages } from '../../_lib/server/load-analysis-packages';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('order-analysis-package:title');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function OrderAnalysisPackagePage() {
|
||||||
|
const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageBody>
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<h3>
|
||||||
|
<Trans i18nKey={'marketing:selectPackage'} />
|
||||||
|
</h3>
|
||||||
|
<ComparePackagesModal
|
||||||
|
analysisPackages={analysisPackages}
|
||||||
|
analysisPackageElements={analysisPackageElements}
|
||||||
|
triggerElement={
|
||||||
|
<Button variant="secondary" className="gap-2">
|
||||||
|
<Trans i18nKey={'marketing:comparePackages'} />
|
||||||
|
<Scale className="size-4 stroke-[1.5px]" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SelectAnalysisPackages analysisPackages={analysisPackages} countryCode={countryCode} />
|
||||||
|
</PageBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(OrderAnalysisPackagePage);
|
||||||
46
app/home/(user)/(dashboard)/order-analysis/page.tsx
Normal file
46
app/home/(user)/(dashboard)/order-analysis/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
||||||
|
import { loadAnalyses } from '../../_lib/server/load-analyses';
|
||||||
|
import OrderAnalysesCards from '../../_components/order-analyses-cards';
|
||||||
|
import { createPageViewLog, PageViewAction } from '~/lib/services/audit/pageView.service';
|
||||||
|
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('order-analysis:title'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function OrderAnalysisPage() {
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { analyses, countryCode } = await loadAnalyses();
|
||||||
|
|
||||||
|
await createPageViewLog({
|
||||||
|
accountId: account.id,
|
||||||
|
action: PageViewAction.VIEW_ORDER_ANALYSIS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'order-analysis:title'} />}
|
||||||
|
description={<Trans i18nKey={'order-analysis:description'} />}
|
||||||
|
/>
|
||||||
|
<PageBody>
|
||||||
|
<OrderAnalysesCards analyses={analyses} countryCode={countryCode} />
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(OrderAnalysisPage);
|
||||||
29
app/home/(user)/(dashboard)/order-health-analysis/page.tsx
Normal file
29
app/home/(user)/(dashboard)/order-health-analysis/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const { t } = await createI18nServerInstance();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('order-health-analysis:title'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function OrderHealthAnalysisPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'order-health-analysis:title'} />}
|
||||||
|
description={<Trans i18nKey={'order-health-analysis:description'} />}
|
||||||
|
/>
|
||||||
|
<PageBody>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(OrderHealthAnalysisPage);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user