diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 829ba54..39a5fb3 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -71,19 +71,19 @@ export default async function syncConnectedOnline() { return { id: service.ID, clinic_id: service.ClinicID, - code: service.Code, - description: service.Description || null, - display: service.Display, - duration: service.Duration, - has_free_codes: !!service.HasFreeCodes, + sync_id: service.SyncID, name: service.Name, + description: service.Description || null, + price: service.Price, + requires_payment: !!service.RequiresPayment, + duration: service.Duration, neto_duration: service.NetoDuration, + display: service.Display, + price_periods: service.PricePeriods || null, online_hide_duration: service.OnlineHideDuration, online_hide_price: service.OnlineHidePrice, - price: service.Price, - price_periods: service.PricePeriods || null, - requires_payment: !!service.RequiresPayment, - sync_id: service.SyncID, + code: service.Code, + has_free_codes: !!service.HasFreeCodes, }; }); diff --git a/app/home/(user)/(dashboard)/booking/[handle]/page.tsx b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx new file mode 100644 index 0000000..ebce187 --- /dev/null +++ b/app/home/(user)/(dashboard)/booking/[handle]/page.tsx @@ -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 ( + <> + + } + description={} + /> + + + + ); +} + +export default withI18n(BookingHandlePage); diff --git a/app/home/(user)/(dashboard)/booking/page.tsx b/app/home/(user)/(dashboard)/booking/page.tsx index def99c9..680edfb 100644 --- a/app/home/(user)/(dashboard)/booking/page.tsx +++ b/app/home/(user)/(dashboard)/booking/page.tsx @@ -1,12 +1,16 @@ +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(); @@ -18,15 +22,30 @@ export const generateMetadata = async () => { }; function BookingPage() { + const { heroCategories, ttoCategories } = use(loadTtoServices()); + + if (!heroCategories.length && !ttoCategories.length) { + return ( + <> + +

+ +

+ + ); + } + return ( <> + } description={} /> - - + + + ); diff --git a/app/home/(user)/_components/order-cards.tsx b/app/home/(user)/_components/order-cards.tsx index bf95ace..0c936ce 100644 --- a/app/home/(user)/_components/order-cards.tsx +++ b/app/home/(user)/_components/order-cards.tsx @@ -1,74 +1,68 @@ -"use client"; +'use client'; -import { ChevronRight, HeartPulse } from 'lucide-react'; import Link from 'next/link'; +import { cn } from '@/lib/utils'; +import { pathsConfig } from '@/packages/shared/src/config'; +import { ComponentInstanceIcon } from '@radix-ui/react-icons'; +import { ChevronRight, HeartPulse } from 'lucide-react'; + import { Button } from '@kit/ui/button'; import { Card, - CardHeader, CardDescription, - CardProps, CardFooter, + CardHeader, + CardProps, } from '@kit/ui/card'; -import { Trans } from '@kit/ui/trans'; -import { cn } from '@/lib/utils'; -const dummyCards = [ - { - title: 'booking:analysisPackages.title', - description: 'booking:analysisPackages.description', - descriptionColor: 'text-primary', - icon: ( - - - - ), - cardVariant: 'gradient-success' as CardProps['variant'], - iconBg: 'bg-warning', - }, -]; +import { ServiceCategory } from './service-categories'; -export default function OrderCards() { +export default function OrderCards({ + heroCategories, +}: { + heroCategories: ServiceCategory[]; +}) { return ( -
- {dummyCards.map(({ - title, - description, - icon, - cardVariant, - descriptionColor, - iconBg, - }) => ( +
+ {heroCategories.map(({ name, description, color, handle }) => ( - +
- {icon} + +
+
+ + +
- -
- -
-
- -
- - - + +
{name}
+ {description}
))} diff --git a/app/home/(user)/_components/service-categories.tsx b/app/home/(user)/_components/service-categories.tsx new file mode 100644 index 0000000..9ef3e25 --- /dev/null +++ b/app/home/(user)/_components/service-categories.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; + +import { redirect } from 'next/navigation'; + +import { createPath, pathsConfig } from '@/packages/shared/src/config'; +import { ComponentInstanceIcon } from '@radix-ui/react-icons'; + +import { cn } from '@kit/ui/shadcn'; +import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card'; + +export interface ServiceCategory { + name: string; + handle: string; + color: string; + description: string; +} + +const ServiceCategories = ({ + categories, +}: { + categories: ServiceCategory[]; +}) => { + return ( +
+ {categories.map((category, index) => ( + { + redirect( + pathsConfig.app.bookingHandle.replace( + '[handle]', + category.handle, + ), + ); + }} + > +
+ +
+
+
{category.name}
+ + {category.description} + +
+
+ ))} +
+ ); +}; + +export default ServiceCategories; diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts index 424ff25..12eebbf 100644 --- a/app/home/(user)/_lib/server/load-analyses.ts +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -1,9 +1,11 @@ import { cache } from 'react'; -import { listProductTypes } from "@lib/data/products"; -import { listRegions } from '@lib/data/regions'; import { getProductCategories } from '@lib/data/categories'; +import { listProductTypes } from '@lib/data/products'; +import { listRegions } from '@lib/data/regions'; + import { OrderAnalysisCard } from '../../_components/order-analyses-cards'; +import { ServiceCategory } from '../../_components/service-categories'; async function countryCodesLoader() { const countryCodes = await listRegions().then((regions) => @@ -14,7 +16,9 @@ async function countryCodesLoader() { export const loadCountryCodes = cache(countryCodesLoader); async function productCategoriesLoader() { - const productCategories = await getProductCategories({ fields: "*products, *products.variants" }); + const productCategories = await getProductCategories({ + fields: '*products, *products.variants, is_active', + }); return productCategories.product_categories ?? []; } export const loadProductCategories = cache(productCategoriesLoader); @@ -29,25 +33,34 @@ async function analysesLoader() { const [countryCodes, productCategories] = await Promise.all([ loadCountryCodes(), loadProductCategories(), - ]); + ]); const countryCode = countryCodes[0]!; - const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); + const category = productCategories.find( + ({ metadata }) => metadata?.page === 'order-analysis', + ); + const serviceCategories = productCategories.filter( + ({ parent_category }) => parent_category?.handle === 'tto-categories', + ); return { - analyses: category?.products?.map(({ title, description, subtitle, variants, status, metadata }) => { - const variant = variants![0]!; - return { - title, - description, - subtitle, - variant: { - id: variant.id, + analyses: + category?.products?.map( + ({ title, description, subtitle, variants, status, metadata }) => { + const variant = variants![0]!; + return { + title, + description, + subtitle, + variant: { + id: variant.id, + }, + isAvailable: + status === 'published' && !!metadata?.analysisIdOriginal, + }; }, - isAvailable: status === 'published' && !!metadata?.analysisIdOriginal, - }; - }) ?? [], + ) ?? [], countryCode, - } + }; } export const loadAnalyses = cache(analysesLoader); diff --git a/app/home/(user)/_lib/server/load-category.ts b/app/home/(user)/_lib/server/load-category.ts new file mode 100644 index 0000000..2c0479c --- /dev/null +++ b/app/home/(user)/_lib/server/load-category.ts @@ -0,0 +1,31 @@ +import { cache } from 'react'; + +import { getProductCategories } from '@lib/data'; + +import { ServiceCategory } from '../../_components/service-categories'; + +async function categoryLoader({ + handle, +}: { + handle: string; +}): Promise<{ category: ServiceCategory | null }> { + const response = await getProductCategories({ + handle, + fields: '*products, is_active, metadata', + }); + + const category = response.product_categories[0]; + + return { + category: { + color: + typeof category?.metadata?.color === 'string' + ? category?.metadata?.color + : 'primary', + description: category?.description || '', + handle: category?.handle || '', + name: category?.name || '', + }, + }; +} +export const loadCategory = cache(categoryLoader); diff --git a/app/home/(user)/_lib/server/load-tto-services.ts b/app/home/(user)/_lib/server/load-tto-services.ts new file mode 100644 index 0000000..3bbc4e5 --- /dev/null +++ b/app/home/(user)/_lib/server/load-tto-services.ts @@ -0,0 +1,49 @@ +import { cache } from 'react'; + +import { getProductCategories } from '@lib/data'; + +import { ServiceCategory } from '../../_components/service-categories'; + +async function ttoServicesLoader() { + const response = await getProductCategories({ + fields: '*products, is_active, metadata', + }); + + const heroCategories = response.product_categories?.filter( + ({ parent_category, is_active, metadata }) => + parent_category?.handle === 'tto-categories' && + is_active && + metadata?.isHero, + ); + + const ttoCategories = response.product_categories?.filter( + ({ parent_category, is_active, metadata }) => + parent_category?.handle === 'tto-categories' && + is_active && + !metadata?.isHero, + ); + + return { + heroCategories: + heroCategories.map( + ({ name, handle, metadata, description }) => ({ + name, + handle, + color: + typeof metadata?.color === 'string' ? metadata.color : 'primary', + description, + }), + ) ?? [], + ttoCategories: + ttoCategories.map( + ({ name, handle, metadata, description }) => ({ + name, + handle, + color: + typeof metadata?.color === 'string' ? metadata.color : 'primary', + description, + }), + ) ?? [], + }; +} +export const loadTtoServices = cache(ttoServicesLoader); diff --git a/lib/services/mailer.service.ts b/lib/services/mailer.service.ts index 07ccc37..c902bad 100644 --- a/lib/services/mailer.service.ts +++ b/lib/services/mailer.service.ts @@ -6,6 +6,7 @@ import { emailSchema } from '@/lib/validations/email.schema'; import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { getMailer } from '@kit/mailers'; import { enhanceAction } from '@kit/next/actions'; +import { getLogger } from '@kit/shared/logger'; export const sendDoctorSummaryCompletedEmail = async ( language: string, @@ -49,12 +50,24 @@ export const sendCompanyOfferEmail = async ( export const sendEmail = enhanceAction( async ({ subject, html, to }) => { const mailer = await getMailer(); - await mailer.sendEmail({ + const log = await getLogger(); + + if (!process.env.EMAIL_USER) { + log.error('Sending email failed, as no sender found in env.') + throw new Error('No email user configured'); + } + + const result = await mailer.sendEmail({ + from: process.env.EMAIL_USER, to, subject, html, }); + log.info( + `Sent email with subject "${subject}", result: ${JSON.stringify(result)}`, + ); + return {}; }, { diff --git a/lib/validations/email.schema.ts b/lib/validations/email.schema.ts index 58cc00d..b06524d 100644 --- a/lib/validations/email.schema.ts +++ b/lib/validations/email.schema.ts @@ -3,5 +3,5 @@ import { z } from 'zod'; export const emailSchema = z.object({ to: z.string().email(), subject: z.string().min(1).max(200), - html: z.string().min(1).max(5000), + html: z.string().min(1), }); diff --git a/packages/email-templates/src/components/email-button.tsx b/packages/email-templates/src/components/email-button.tsx new file mode 100644 index 0000000..0a0f53d --- /dev/null +++ b/packages/email-templates/src/components/email-button.tsx @@ -0,0 +1,16 @@ +import { Button } from '@react-email/components'; + +export function EmailButton( + props: React.PropsWithChildren<{ + href: string; + }>, +) { + return ( + + ); +} diff --git a/packages/email-templates/src/emails/doctor-summary-received.email.tsx b/packages/email-templates/src/emails/doctor-summary-received.email.tsx index 6ca01cd..c9e4fae 100644 --- a/packages/email-templates/src/emails/doctor-summary-received.email.tsx +++ b/packages/email-templates/src/emails/doctor-summary-received.email.tsx @@ -1,9 +1,7 @@ import { Body, - Button, Head, Html, - Link, Preview, Tailwind, Text, @@ -13,6 +11,7 @@ import { import { BodyStyle } from '../components/body-style'; import CommonFooter from '../components/common-footer'; import { EmailContent } from '../components/content'; +import { EmailButton } from '../components/email-button'; import { EmailHeader } from '../components/header'; import { EmailHeading } from '../components/heading'; import { EmailWrapper } from '../components/wrapper'; @@ -72,11 +71,12 @@ export async function renderDoctorSummaryReceivedEmail({ {t(`${namespace}:summaryReceivedForOrder`, { orderNr })} - - - + {t(`${namespace}:linkText`, { orderNr })} + {t(`${namespace}:ifButtonDisabled`)}{' '} {`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`} diff --git a/packages/features/medusa-storefront/src/lib/data/categories.ts b/packages/features/medusa-storefront/src/lib/data/categories.ts index 7b3987d..b4db69d 100644 --- a/packages/features/medusa-storefront/src/lib/data/categories.ts +++ b/packages/features/medusa-storefront/src/lib/data/categories.ts @@ -1,13 +1,13 @@ -import { sdk } from "@lib/config" -import { HttpTypes } from "@medusajs/types" -import { getCacheOptions } from "./cookies" +import { sdk } from "@lib/config"; +import { HttpTypes } from "@medusajs/types"; +import { getCacheOptions } from "./cookies"; export const listCategories = async (query?: Record) => { const next = { ...(await getCacheOptions("categories")), - } + }; - const limit = query?.limit || 100 + const limit = query?.limit || 100; return sdk.client .fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>( @@ -23,8 +23,8 @@ export const listCategories = async (query?: Record) => { cache: "force-cache", } ) - .then(({ product_categories }) => product_categories) -} + .then(({ product_categories }) => product_categories); +}; export const getCategoryByHandle = async (categoryHandle: string[]) => { const { product_categories } = await getProductCategories({ @@ -32,7 +32,7 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => { limit: 1, }); return product_categories[0]; -} +}; export const getProductCategories = async ({ handle, @@ -45,19 +45,18 @@ export const getProductCategories = async ({ } = {}) => { const next = { ...(await getCacheOptions("categories")), - } + }; - return sdk.client - .fetch( - `/store/product-categories`, - { - query: { - fields, - handle, - limit, - }, - next, - //cache: "force-cache", - } - ); -} + return sdk.client.fetch( + `/store/product-categories`, + { + query: { + fields, + handle, + limit, + }, + next, + //cache: "force-cache", + } + ); +}; diff --git a/packages/shared/src/config/paths.config.ts b/packages/shared/src/config/paths.config.ts index d21c445..4e400e4 100644 --- a/packages/shared/src/config/paths.config.ts +++ b/packages/shared/src/config/paths.config.ts @@ -16,6 +16,7 @@ const PathsSchema = z.object({ home: z.string().min(1), selectPackage: z.string().min(1), booking: z.string().min(1), + bookingHandle: z.string().min(1), myOrders: z.string().min(1), analysisResults: z.string().min(1), orderAnalysisPackage: z.string().min(1), @@ -64,6 +65,7 @@ const pathsConfig = PathsSchema.parse({ joinTeam: '/join', selectPackage: '/select-package', booking: '/home/booking', + bookingHandle: '/home/booking/[handle]', orderAnalysisPackage: '/home/order-analysis-package', myOrders: '/home/order', analysisResults: '/home/analysis-results', diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 28d8341..33eee12 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -1091,7 +1091,7 @@ export type Database = { price: number price_periods: string | null requires_payment: boolean - sync_id: number + sync_id: string | null updated_at: string | null } Insert: { @@ -1110,7 +1110,7 @@ export type Database = { price: number price_periods?: string | null requires_payment: boolean - sync_id: number + sync_id?: string | null updated_at?: string | null } Update: { @@ -1129,7 +1129,7 @@ export type Database = { price?: number price_periods?: string | null requires_payment?: boolean - sync_id?: number + sync_id?: string | null updated_at?: string | null } Relationships: [ @@ -1150,7 +1150,7 @@ export type Database = { doctor_user_id: string | null id: number status: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at: string + updated_at: string | null updated_by: string | null user_id: string value: string | null @@ -1162,7 +1162,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id: string value?: string | null @@ -1174,7 +1174,7 @@ export type Database = { doctor_user_id?: string | null id?: number status?: Database["medreport"]["Enums"]["analysis_feedback_status"] - updated_at?: string + updated_at?: string | null updated_by?: string | null user_id?: string value?: string | null @@ -1881,9 +1881,7 @@ export type Database = { Returns: Json } create_team_account: { - Args: - | { account_name: string } - | { account_name: string; new_personal_code: string } + Args: { account_name: string; new_personal_code: string } Returns: { application_role: Database["medreport"]["Enums"]["application_role"] city: string | null diff --git a/public/locales/et/booking.json b/public/locales/et/booking.json index 17554de..3410d59 100644 --- a/public/locales/et/booking.json +++ b/public/locales/et/booking.json @@ -1,8 +1,9 @@ { - "title": "Vali teenus", - "description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.", - "analysisPackages": { - "title": "Analüüside paketid", - "description": "Tutvu personaalsete analüüsi pakettidega ja telli" - } -} \ No newline at end of file + "title": "Vali teenus", + "description": "Vali sobiv teenus või pakett vastavalt oma tervisemurele või -eesmärgile.", + "analysisPackages": { + "title": "Analüüside paketid", + "description": "Tutvu personaalsete analüüsi pakettidega ja telli" + }, + "noCategories": "Teenuste loetelu ei leitud, proovi hiljem uuesti" +} diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 8239aaf..70e6ec6 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -80,7 +80,8 @@ "dashboard": "Ülevaade", "settings": "Settings", "profile": "Profile", - "application": "Application" + "application": "Application", + "pickTime": "Vali aeg" }, "roles": { "owner": { diff --git a/supabase/migrations/20250827134000_bookings.sql b/supabase/migrations/20250827134000_bookings.sql new file mode 100644 index 0000000..9d77627 --- /dev/null +++ b/supabase/migrations/20250827134000_bookings.sql @@ -0,0 +1,2 @@ +ALTER TABLE medreport.connected_online_services +ALTER COLUMN sync_id TYPE text USING sync_id::text; \ No newline at end of file