Merge branch 'main' into MED-57
This commit is contained in:
@@ -59,7 +59,7 @@ export class MontonioOrderHandlerService {
|
||||
if (error instanceof AxiosError) {
|
||||
console.error(error.response?.data);
|
||||
}
|
||||
console.error("Failed to create payment link, params=${JSON.stringify(params)}", error);
|
||||
console.error(`Failed to create payment link, params=${JSON.stringify(params)}`, error);
|
||||
throw new Error("Failed to create payment link");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx"
|
||||
"./services/*": "./src/lib/server/services/*.ts",
|
||||
"./actions/*": "./src/lib/server/actions/*.ts",
|
||||
"./schema/*": "./src/lib/server/schema/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -40,4 +43,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { isDoctor } from '../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} />;
|
||||
};
|
||||
}
|
||||
21
packages/features/doctor/src/lib/helpers.ts
Normal file
21
packages/features/doctor/src/lib/helpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { formatDate, getPersonParameters } from '@kit/shared/utils';
|
||||
|
||||
export function getResultSetName(
|
||||
title: string,
|
||||
isPackage: boolean,
|
||||
nrOfElements: number,
|
||||
) {
|
||||
return !isPackage && nrOfElements > 1 ? 'doctor:analyses' : title;
|
||||
}
|
||||
|
||||
export function getDOBWithAgeStringFromPersonalCode(
|
||||
personalCode: string | null,
|
||||
) {
|
||||
if (!personalCode) return 'common:unknown';
|
||||
|
||||
const person = getPersonParameters(personalCode);
|
||||
|
||||
if (!person) return 'common:unknown';
|
||||
|
||||
return `${formatDate(person.dob.toString())} (${person.age})`;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
import {
|
||||
DoctorAnalysisFeedbackTable,
|
||||
DoctorJobSelect,
|
||||
DoctorJobUnselect,
|
||||
doctorAnalysisFeedbackSchema,
|
||||
doctorJobSelectSchema,
|
||||
doctorJobUnselectSchema,
|
||||
} from '../schema/doctor-analysis.schema';
|
||||
import {
|
||||
selectJob,
|
||||
submitFeedback,
|
||||
unselectJob,
|
||||
} from '../services/doctor-analysis.service';
|
||||
import { doctorAction } from '../utils/doctor-action';
|
||||
|
||||
/**
|
||||
* @name selectJobAction
|
||||
* @description Creates a feedback item that ties doctor to the job and enables giving feedback.
|
||||
*/
|
||||
export const selectJobAction = doctorAction(
|
||||
enhanceAction(
|
||||
async ({ analysisOrderId, userId }: DoctorJobSelect) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ analysisOrderId }, `Selecting new job`);
|
||||
|
||||
await selectJob(analysisOrderId, userId);
|
||||
|
||||
logger.info({ analysisOrderId }, `Successfully selected`);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: doctorJobSelectSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name unselectJobAction
|
||||
* @description Removes the current user from a feedback item.
|
||||
*/
|
||||
export const unselectJobAction = doctorAction(
|
||||
enhanceAction(
|
||||
async ({ analysisOrderId }: DoctorJobUnselect) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ analysisOrderId }, `Removing doctor from job`);
|
||||
|
||||
await unselectJob(analysisOrderId);
|
||||
|
||||
logger.info(
|
||||
{ analysisOrderId },
|
||||
`Successfully removed current doctor from job`,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: doctorJobUnselectSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name giveFeedbackAction
|
||||
* @description Creates or updates doctor analysis feedback.
|
||||
*/
|
||||
export const giveFeedbackAction = doctorAction(
|
||||
enhanceAction(
|
||||
async ({
|
||||
feedbackValue,
|
||||
userId,
|
||||
analysisOrderId,
|
||||
status,
|
||||
}: {
|
||||
feedbackValue: string;
|
||||
userId: DoctorAnalysisFeedbackTable['user_id'];
|
||||
analysisOrderId: DoctorAnalysisFeedbackTable['analysis_order_id'];
|
||||
status: DoctorAnalysisFeedbackTable['status'];
|
||||
}) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ analysisOrderId },
|
||||
`Submitting feedback for analysis order...`,
|
||||
);
|
||||
|
||||
const result = await submitFeedback(
|
||||
analysisOrderId,
|
||||
userId,
|
||||
feedbackValue,
|
||||
status,
|
||||
);
|
||||
logger.info({ analysisOrderId }, `Successfully submitted feedback`);
|
||||
|
||||
return result;
|
||||
},
|
||||
{
|
||||
schema: doctorAnalysisFeedbackSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
'use server';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
import {
|
||||
getOpenResponses,
|
||||
getOtherResponses,
|
||||
getUserDoneResponses,
|
||||
getUserInProgressResponses,
|
||||
} from '@kit/doctor/services/doctor-analysis.service';
|
||||
import { doctorAction } from '../utils/doctor-action';
|
||||
|
||||
export const getUserDoneResponsesAction = doctorAction(
|
||||
async ({ page, pageSize }: { page: number; pageSize: number }) => {
|
||||
const logger = await getLogger();
|
||||
try {
|
||||
const data = await getUserDoneResponses({ page, pageSize });
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching data for user completed jobs`, error);
|
||||
return { success: false, error: 'Failed to fetch data from the server.' };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getUserInProgressResponsesAction = doctorAction(
|
||||
async ({ page, pageSize }: { page: number; pageSize: number }) => {
|
||||
const logger = await getLogger();
|
||||
try {
|
||||
const data = await getUserInProgressResponses({ page, pageSize });
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching data for user's in progress jobs`, error);
|
||||
return { success: false, error: 'Failed to fetch data from the server.' };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getOtherResponsesAction = doctorAction(
|
||||
async ({ page, pageSize }: { page: number; pageSize: number }) => {
|
||||
const logger = await getLogger();
|
||||
try {
|
||||
const data = await getOtherResponses({ page, pageSize });
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error fetching data for other analysis response jobs`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch data from the server.',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getOpenResponsesAction = doctorAction(
|
||||
async ({ page, pageSize }: { page: number; pageSize: number }) => {
|
||||
const logger = await getLogger();
|
||||
try {
|
||||
const data = await getOpenResponses({ page, pageSize });
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching open analysis response jobs`, error);
|
||||
return { success: false, error: 'Failed to fetch data from the server.' };
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const AnalysisOrderIdSchema = z.object({
|
||||
id: z.number(),
|
||||
medusa_order_id: z.string(),
|
||||
analysis_element_ids: z.array(z.number()).nullable(),
|
||||
});
|
||||
export type AnalysisOrderId = z.infer<typeof AnalysisOrderIdSchema>;
|
||||
|
||||
export const DoctorFeedbackSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
analysis_order_id: z.number(),
|
||||
doctor_user_id: z.string().nullable(),
|
||||
user_id: z.string(),
|
||||
value: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
created_by: z.string(),
|
||||
updated_by: z.string().nullable(),
|
||||
status: z.string(),
|
||||
})
|
||||
.optional()
|
||||
.nullable();
|
||||
export type DoctorFeedback = z.infer<typeof DoctorFeedbackSchema>;
|
||||
|
||||
export const OrderSchema = z.object({
|
||||
title: z.string(),
|
||||
isPackage: z.boolean(),
|
||||
analysisOrderId: z.number(),
|
||||
});
|
||||
export type Order = z.infer<typeof OrderSchema>;
|
||||
|
||||
export const PatientSchema = z.object({
|
||||
userId: z.string(),
|
||||
accountId: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string().nullable(),
|
||||
personalCode: z.string().nullable(),
|
||||
phone: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
height: z.number().optional().nullable(),
|
||||
weight: z.number().optional().nullable(),
|
||||
});
|
||||
export type Patient = z.infer<typeof PatientSchema>;
|
||||
|
||||
export const AnalysisResponsesSchema = z.object({
|
||||
user_id: z.string(),
|
||||
analysis_order_id: AnalysisOrderIdSchema,
|
||||
});
|
||||
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>;
|
||||
|
||||
export const AnalysisResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
analysis_response_id: z.number(),
|
||||
analysis_element_original_id: z.string(),
|
||||
unit: z.string().nullable(),
|
||||
response_value: z.number(),
|
||||
response_time: z.string(),
|
||||
norm_upper: z.number().nullable(),
|
||||
norm_upper_included: z.boolean().nullable(),
|
||||
norm_lower: z.number().nullable(),
|
||||
norm_lower_included: z.boolean().nullable(),
|
||||
norm_status: z.number().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
analysis_name: z.string().nullable(),
|
||||
analysis_responses: AnalysisResponsesSchema,
|
||||
});
|
||||
export type AnalysisResponse = z.infer<typeof AnalysisResponseSchema>;
|
||||
|
||||
export const AnalysisResultDetailsSchema = z.object({
|
||||
analysisResponse: z.array(AnalysisResponseSchema),
|
||||
order: OrderSchema,
|
||||
doctorFeedback: DoctorFeedbackSchema,
|
||||
patient: PatientSchema,
|
||||
});
|
||||
export type AnalysisResultDetails = z.infer<typeof AnalysisResultDetailsSchema>;
|
||||
@@ -0,0 +1,135 @@
|
||||
import z from 'zod/v3';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export const doctorJobSelectSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
analysisOrderId: z.number(),
|
||||
});
|
||||
export type DoctorJobSelect = z.infer<typeof doctorJobSelectSchema>;
|
||||
|
||||
export const doctorJobUnselectSchema = z.object({
|
||||
analysisOrderId: z.number(),
|
||||
});
|
||||
export type DoctorJobUnselect = z.infer<typeof doctorJobUnselectSchema>;
|
||||
|
||||
export const FeedbackStatus = z.enum(['STARTED', 'DRAFT', 'COMPLETED']);
|
||||
export const doctorAnalysisFeedbackFormSchema = z.object({
|
||||
feedbackValue: z.string().min(15),
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
export type DoctorAnalysisFeedbackForm = z.infer<
|
||||
typeof doctorAnalysisFeedbackFormSchema
|
||||
>;
|
||||
export const doctorAnalysisFeedbackSchema = z.object({
|
||||
feedbackValue: z.string().min(15),
|
||||
userId: z.string().uuid(),
|
||||
analysisOrderId: z.number(),
|
||||
status: FeedbackStatus,
|
||||
});
|
||||
export type DoctorAnalysisFeedback = z.infer<
|
||||
typeof doctorAnalysisFeedbackSchema
|
||||
>;
|
||||
|
||||
export type DoctorAnalysisFeedbackTable =
|
||||
Database['medreport']['Tables']['doctor_analysis_feedback']['Row'];
|
||||
|
||||
export const AnalysisOrderIdSchema = z.object({
|
||||
id: z.number(),
|
||||
medusa_order_id: z.string(),
|
||||
analysis_element_ids: z.array(z.number()).nullable(),
|
||||
});
|
||||
export type AnalysisOrderId = z.infer<typeof AnalysisOrderIdSchema>;
|
||||
|
||||
export const ElementSchema = z.object({
|
||||
id: z.number(),
|
||||
analysis_response_id: z.number(),
|
||||
analysis_element_original_id: z.string(),
|
||||
unit: z.string().nullable(),
|
||||
response_value: z.number(),
|
||||
response_time: z.string(),
|
||||
norm_upper: z.number().nullable(),
|
||||
norm_upper_included: z.boolean().nullable(),
|
||||
norm_lower: z.number().nullable(),
|
||||
norm_lower_included: z.boolean().nullable(),
|
||||
norm_status: z.number().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
analysis_name: z.string().nullable(),
|
||||
});
|
||||
export type Element = z.infer<typeof ElementSchema>;
|
||||
|
||||
export const FeedbackSchema = z.object({
|
||||
analysis_order_id: z.number(),
|
||||
user_id: z.string(),
|
||||
doctor_user_id: z.string().nullable(),
|
||||
status: z.string(),
|
||||
});
|
||||
export type Feedback = z.infer<typeof FeedbackSchema>;
|
||||
|
||||
export const OrderSchema = z.object({
|
||||
title: z.string().optional().nullable(),
|
||||
isPackage: z.boolean(),
|
||||
analysisOrderId: z.number(),
|
||||
});
|
||||
export type Order = z.infer<typeof OrderSchema>;
|
||||
|
||||
export const AccountSchema = z.object({
|
||||
name: z.string(),
|
||||
last_name: z.string().nullable(),
|
||||
id: z.string(),
|
||||
primary_owner_user_id: z.string(),
|
||||
});
|
||||
export type Account = z.infer<typeof AccountSchema>;
|
||||
|
||||
export const ResponseTableSchema = z.object({
|
||||
id: z.number(),
|
||||
analysis_order_id: AnalysisOrderIdSchema,
|
||||
order_number: z.string(),
|
||||
order_status: z.string(),
|
||||
user_id: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().nullable(),
|
||||
elements: z.array(ElementSchema),
|
||||
firstSampleGivenAt: z.string().nullable(),
|
||||
patient: AccountSchema,
|
||||
order: OrderSchema,
|
||||
doctor: AccountSchema.optional().nullable(),
|
||||
feedback: FeedbackSchema.optional(),
|
||||
});
|
||||
export type ResponseTable = z.infer<typeof ResponseTableSchema>;
|
||||
|
||||
export type AnalysisResponseBase = {
|
||||
analysis_order_id: number & {
|
||||
id: number;
|
||||
medusa_order_id: string;
|
||||
analysis_element_ids: number[] | null;
|
||||
};
|
||||
created_at: string;
|
||||
id: number;
|
||||
order_number: string;
|
||||
order_status: Database['medreport']['Tables']['analysis_orders']['Row']['status'];
|
||||
updated_at: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerActionResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import 'server-only';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
||||
import {
|
||||
AnalysisResponseBase,
|
||||
DoctorAnalysisFeedbackTable,
|
||||
PaginatedData,
|
||||
PaginationParams,
|
||||
ResponseTable,
|
||||
} from '../schema/doctor-analysis.schema';
|
||||
|
||||
async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
if (!analysisResponses?.length) return [];
|
||||
|
||||
const analysisResponseIds = analysisResponses.map((r) => r.id);
|
||||
const medusaOrderIds = analysisResponses.map(
|
||||
(r) => r.analysis_order_id.medusa_order_id,
|
||||
);
|
||||
const userIds = analysisResponses.map((response) => response.user_id);
|
||||
|
||||
const [
|
||||
{ data: doctorFeedbackItems },
|
||||
{ data: medusaOrderItems },
|
||||
{ data: analysisResponseElements },
|
||||
{ data: accounts },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('analysis_order_id, user_id, doctor_user_id, status')
|
||||
.in(
|
||||
'analysis_order_id',
|
||||
analysisResponses.map((r) => r.analysis_order_id.id),
|
||||
),
|
||||
supabase
|
||||
.schema('public')
|
||||
.from('order_item')
|
||||
.select('order_id, item_id(product_title, product_type)')
|
||||
.in('order_id', medusaOrderIds),
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_response_elements')
|
||||
.select('*')
|
||||
.in('analysis_response_id', analysisResponseIds),
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select('name, last_name, id, primary_owner_user_id')
|
||||
.in('primary_owner_user_id', userIds),
|
||||
]);
|
||||
|
||||
const doctorUserIds =
|
||||
doctorFeedbackItems
|
||||
?.map((item) => item.doctor_user_id)
|
||||
.filter((value) => value !== null) || [];
|
||||
|
||||
const { data: doctorAccounts } = doctorUserIds.length
|
||||
? await supabase
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select('name, last_name, id, primary_owner_user_id')
|
||||
.in('primary_owner_user_id', doctorUserIds)
|
||||
: { data: [] };
|
||||
|
||||
const allAccounts = [...(accounts ?? []), ...(doctorAccounts ?? [])];
|
||||
|
||||
return analysisResponses.map((analysisResponse) => {
|
||||
const responseElements =
|
||||
analysisResponseElements?.filter(
|
||||
(element) => element.analysis_response_id === analysisResponse.id,
|
||||
) || [];
|
||||
|
||||
const firstSampleGivenAt = responseElements.length
|
||||
? responseElements.reduce((earliest, current) =>
|
||||
new Date(current.response_time) < new Date(earliest.response_time)
|
||||
? current
|
||||
: earliest,
|
||||
)?.response_time
|
||||
: null;
|
||||
|
||||
const medusaOrder = medusaOrderItems?.find(
|
||||
({ order_id }) =>
|
||||
order_id === analysisResponse.analysis_order_id.medusa_order_id,
|
||||
);
|
||||
|
||||
const patientAccount = allAccounts?.find(
|
||||
({ primary_owner_user_id }) =>
|
||||
analysisResponse.user_id === primary_owner_user_id,
|
||||
);
|
||||
|
||||
const feedback = doctorFeedbackItems?.find(
|
||||
({ analysis_order_id }) =>
|
||||
analysis_order_id === analysisResponse.analysis_order_id.id,
|
||||
);
|
||||
|
||||
const doctorAccount = allAccounts?.find(
|
||||
({ primary_owner_user_id }) =>
|
||||
feedback?.doctor_user_id === primary_owner_user_id,
|
||||
);
|
||||
|
||||
const order = {
|
||||
title: medusaOrder?.item_id.product_title,
|
||||
isPackage:
|
||||
medusaOrder?.item_id.product_type?.toLowerCase() === 'analysis package',
|
||||
analysisOrderId: analysisResponse.analysis_order_id.id,
|
||||
status: analysisResponse.order_status,
|
||||
};
|
||||
|
||||
if (!patientAccount || !analysisResponse) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
return {
|
||||
...analysisResponse,
|
||||
elements: responseElements,
|
||||
firstSampleGivenAt,
|
||||
patient: patientAccount,
|
||||
doctor: doctorAccount,
|
||||
order,
|
||||
feedback,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserInProgressResponses({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data: inProgressFeedback } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('analysis_order_id')
|
||||
.eq('doctor_user_id', user.id)
|
||||
.neq('status', 'COMPLETED');
|
||||
|
||||
if (!inProgressFeedback?.length) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
const analysisOrderIds = inProgressFeedback.map((f) => f.analysis_order_id);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const {
|
||||
data: analysisResponses,
|
||||
error,
|
||||
count,
|
||||
} = await supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_responses')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
analysis_order_id(id, medusa_order_id, analysis_element_ids)
|
||||
`,
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.in('analysis_order_id', analysisOrderIds)
|
||||
.range(offset, offset + pageSize - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
const enrichedData = await enrichAnalysisData(analysisResponses);
|
||||
const totalCount = count || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
pagination: { currentPage: page, totalPages, totalCount, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserDoneResponses({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data: completedFeedback } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('analysis_order_id')
|
||||
.eq('doctor_user_id', user.id)
|
||||
.eq('status', 'COMPLETED');
|
||||
|
||||
if (!completedFeedback?.length) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
const analysisOrderIds = completedFeedback.map((f) => f.analysis_order_id);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const {
|
||||
data: analysisResponses,
|
||||
error,
|
||||
count,
|
||||
} = await supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_responses')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
analysis_order_id(id, medusa_order_id, status, analysis_element_ids)
|
||||
`,
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.in('analysis_order_id', analysisOrderIds)
|
||||
.range(offset, offset + pageSize - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const enrichedData = await enrichAnalysisData(analysisResponses);
|
||||
const totalCount = count || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
pagination: { currentPage: page, totalPages, totalCount, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOpenResponses({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
const { data: assignedOrderIds } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('analysis_order_id')
|
||||
.not('doctor_user_id', 'is', null);
|
||||
|
||||
const assignedIds = assignedOrderIds?.map((f) => f.analysis_order_id) || [];
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let query = supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_responses')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
analysis_order_id(id, medusa_order_id, analysis_element_ids)
|
||||
`,
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (assignedIds.length > 0) {
|
||||
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
|
||||
}
|
||||
|
||||
const {
|
||||
data: analysisResponses,
|
||||
error,
|
||||
count,
|
||||
} = await query.range(offset, offset + pageSize - 1);
|
||||
if (error) throw error;
|
||||
|
||||
const enrichedData = await enrichAnalysisData(analysisResponses);
|
||||
const totalCount = count || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
pagination: { currentPage: page, totalPages, totalCount, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOtherResponses({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
}: PaginationParams): Promise<PaginatedData<ResponseTable>> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data: otherFeedback } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('analysis_order_id')
|
||||
.neq('doctor_user_id', user.id);
|
||||
|
||||
if (!otherFeedback?.length) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: page, totalPages: 0, totalCount: 0, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
const analysisOrderIds = otherFeedback.map((f) => f.analysis_order_id);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const {
|
||||
data: analysisResponses,
|
||||
error,
|
||||
count,
|
||||
} = await supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_responses')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
analysis_order_id(id, medusa_order_id, analysis_element_ids)
|
||||
`,
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.in('analysis_order_id', analysisOrderIds)
|
||||
.range(offset, offset + pageSize - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const enrichedData = await enrichAnalysisData(analysisResponses);
|
||||
const totalCount = count || 0;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
pagination: { currentPage: page, totalPages, totalCount, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAnalysisResultsForDoctor(
|
||||
id: number,
|
||||
): Promise<AnalysisResultDetails> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
const { data: analysisResponse, error } = await supabase
|
||||
.schema('medreport')
|
||||
.from(`analysis_response_elements`)
|
||||
.select(
|
||||
`*,
|
||||
analysis_responses(user_id, analysis_order_id(id,medusa_order_id, analysis_element_ids))`,
|
||||
)
|
||||
.eq('analysis_response_id', id);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Something went wrong.');
|
||||
}
|
||||
|
||||
const firstAnalysisResponse = analysisResponse?.[0];
|
||||
const userId = firstAnalysisResponse?.analysis_responses.user_id;
|
||||
const medusaOrderId =
|
||||
firstAnalysisResponse?.analysis_responses?.analysis_order_id
|
||||
?.medusa_order_id;
|
||||
|
||||
if (!analysisResponse?.length || !userId || !medusaOrderId) {
|
||||
throw new Error('Failed to retrieve full analysis data.');
|
||||
}
|
||||
|
||||
const [
|
||||
{ data: medusaOrderItems, error: medusaOrderError },
|
||||
{ data: accountWithParams, error: accountError },
|
||||
{ data: doctorFeedback, error: feedbackError },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.schema('public')
|
||||
.from('order_item')
|
||||
.select(`order_id, item_id(product_title, product_type)`)
|
||||
.eq('order_id', medusaOrderId),
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.select(
|
||||
`primary_owner_user_id, id, name, last_name, personal_code, phone, email,
|
||||
account_params(height,weight)`,
|
||||
)
|
||||
.eq('is_personal_account', true)
|
||||
.eq('primary_owner_user_id', userId)
|
||||
.limit(1),
|
||||
await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select(`*`)
|
||||
.eq(
|
||||
'analysis_order_id',
|
||||
firstAnalysisResponse.analysis_responses.analysis_order_id.id,
|
||||
)
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
if (medusaOrderError || accountError || feedbackError) {
|
||||
throw new Error('Something went wrong.');
|
||||
}
|
||||
|
||||
if (!accountWithParams?.[0]) {
|
||||
throw new Error('Account not found.');
|
||||
}
|
||||
|
||||
const {
|
||||
primary_owner_user_id,
|
||||
id: accountId,
|
||||
name,
|
||||
email,
|
||||
last_name,
|
||||
personal_code,
|
||||
phone,
|
||||
account_params,
|
||||
} = accountWithParams[0];
|
||||
|
||||
return {
|
||||
analysisResponse,
|
||||
order: {
|
||||
title: medusaOrderItems?.[0]?.item_id.product_title ?? '-',
|
||||
isPackage:
|
||||
medusaOrderItems?.[0]?.item_id.product_type?.toLowerCase() ===
|
||||
'analysis package',
|
||||
analysisOrderId:
|
||||
firstAnalysisResponse.analysis_responses.analysis_order_id.id,
|
||||
},
|
||||
doctorFeedback: doctorFeedback?.[0],
|
||||
patient: {
|
||||
userId: primary_owner_user_id,
|
||||
accountId,
|
||||
firstName: name,
|
||||
lastName: last_name,
|
||||
personalCode: personal_code,
|
||||
phone,
|
||||
email,
|
||||
height: account_params?.[0]?.height,
|
||||
weight: account_params?.[0]?.weight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectJob(analysisOrderId: number, userId: string) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data: existingFeedback, error: existingFeedbackError } =
|
||||
await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('doctor_user_id')
|
||||
.eq('analysis_order_id', analysisOrderId)
|
||||
.limit(1);
|
||||
|
||||
const jobAssignedToUserId = existingFeedback?.[0]?.doctor_user_id;
|
||||
|
||||
if (!!jobAssignedToUserId && jobAssignedToUserId !== user.id) {
|
||||
throw new Error('Job already assigned to another doctor.');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.upsert(
|
||||
{
|
||||
doctor_user_id: user.id,
|
||||
user_id: userId,
|
||||
analysis_order_id: analysisOrderId,
|
||||
},
|
||||
{ onConflict: 'analysis_order_id' },
|
||||
);
|
||||
|
||||
if (error || existingFeedbackError) {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function unselectJob(analysisOrderId: number) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data: currentDoctorFeedback, error: currentDoctorFeedbackError } =
|
||||
await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select('id,doctor_user_id')
|
||||
.eq('analysis_order_id', analysisOrderId)
|
||||
.eq('doctor_user_id', user.id)
|
||||
.limit(1);
|
||||
|
||||
if (!currentDoctorFeedback) {
|
||||
throw new Error(
|
||||
'Current user not assigned to give feedback to this set of analyses.',
|
||||
);
|
||||
}
|
||||
|
||||
if (currentDoctorFeedbackError) {
|
||||
throw new Error('Error retrieving job data');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.update({
|
||||
doctor_user_id: null,
|
||||
})
|
||||
.eq('analysis_order_id', analysisOrderId)
|
||||
.eq('doctor_user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function submitFeedback(
|
||||
analysisOrderId: DoctorAnalysisFeedbackTable['analysis_order_id'],
|
||||
userId: DoctorAnalysisFeedbackTable['user_id'],
|
||||
value: DoctorAnalysisFeedbackTable['value'],
|
||||
status: DoctorAnalysisFeedbackTable['status'],
|
||||
) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user?.id) {
|
||||
throw new Error('No user logged in.');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.upsert(
|
||||
{
|
||||
doctor_user_id: user?.id,
|
||||
user_id: userId,
|
||||
analysis_order_id: analysisOrderId,
|
||||
value,
|
||||
status,
|
||||
},
|
||||
{ onConflict: 'analysis_order_id' },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isDoctor } from './is-doctor';
|
||||
|
||||
/**
|
||||
* @name doctorAction
|
||||
* @description Wrap a server action to ensure the user is a doctor.
|
||||
* @param fn
|
||||
*/
|
||||
export function doctorAction<Args, Response>(
|
||||
fn: (params: Args) => Promise<Response>,
|
||||
) {
|
||||
return async (params: Args): Promise<Response> => {
|
||||
const isUserDoctor = await isDoctor(getSupabaseServerClient());
|
||||
|
||||
if (!isUserDoctor) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return await fn(params);
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,23 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./services/*": "./src/lib/server/services/*.ts",
|
||||
"./actions/*": "./src/lib/server/actions/*.ts"
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"src",
|
||||
"app"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@components/*": [
|
||||
"./src/lib/*"
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Image from 'next/image';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { MedReportLogo } from '@/components/med-report-logo';
|
||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"./utils": "./src/utils.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./events": "./src/events/index.tsx",
|
||||
"./registry": "./src/registry/index.ts"
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./registry": "./src/registry/index.ts",
|
||||
"./config": "./src/config/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
@@ -32,4 +34,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
packages/shared/src/components/analytics-provider.tsx
Normal file
104
packages/shared/src/components/analytics-provider.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { analytics } from '@kit/analytics';
|
||||
import {
|
||||
AppEvent,
|
||||
AppEventType,
|
||||
ConsumerProvidedEventTypes,
|
||||
useAppEvents,
|
||||
} from '@kit/shared/events';
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
|
||||
type AnalyticsMapping<
|
||||
T extends ConsumerProvidedEventTypes = NonNullable<unknown>,
|
||||
> = {
|
||||
[K in AppEventType<T>]?: (event: AppEvent<T, K>) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to subscribe to app events and map them to analytics actions
|
||||
* @param mapping
|
||||
*/
|
||||
function useAnalyticsMapping<T extends ConsumerProvidedEventTypes>(
|
||||
mapping: AnalyticsMapping<T>,
|
||||
) {
|
||||
const appEvents = useAppEvents<T>();
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = Object.entries(mapping).map(
|
||||
([eventType, handler]) => {
|
||||
appEvents.on(eventType as AppEventType<T>, handler);
|
||||
|
||||
return () => appEvents.off(eventType as AppEventType<T>, handler);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [appEvents, mapping]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a mapping of app events to analytics actions
|
||||
* Add new mappings here to track new events in the analytics service from app events
|
||||
*/
|
||||
const analyticsMapping: AnalyticsMapping = {
|
||||
'user.signedIn': (event) => {
|
||||
const { userId, ...traits } = event.payload;
|
||||
|
||||
if (userId) {
|
||||
return analytics.identify(userId, traits);
|
||||
}
|
||||
},
|
||||
'user.signedUp': (event) => {
|
||||
return analytics.trackEvent(event.type, event.payload);
|
||||
},
|
||||
'checkout.started': (event) => {
|
||||
return analytics.trackEvent(event.type, event.payload);
|
||||
},
|
||||
'user.updated': (event) => {
|
||||
return analytics.trackEvent(event.type, event.payload);
|
||||
},
|
||||
};
|
||||
|
||||
function AnalyticsProviderBrowser(props: React.PropsWithChildren) {
|
||||
// Subscribe to app events and map them to analytics actions
|
||||
useAnalyticsMapping(analyticsMapping);
|
||||
|
||||
// Report page views to the analytics service
|
||||
useReportPageView(useCallback((url) => analytics.trackPageView(url), []));
|
||||
|
||||
// Render children
|
||||
return props.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for the analytics service
|
||||
*/
|
||||
export function AnalyticsProvider(props: React.PropsWithChildren) {
|
||||
if (!isBrowser()) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return <AnalyticsProviderBrowser>{props.children}</AnalyticsProviderBrowser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to report page views to the analytics service
|
||||
* @param reportAnalyticsFn
|
||||
*/
|
||||
function useReportPageView(reportAnalyticsFn: (url: string) => unknown) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const url = [pathname, searchParams.toString()].filter(Boolean).join('?');
|
||||
|
||||
reportAnalyticsFn(url);
|
||||
}, [pathname, reportAnalyticsFn, searchParams]);
|
||||
}
|
||||
36
packages/shared/src/components/app-logo.tsx
Normal file
36
packages/shared/src/components/app-logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
||||
|
||||
function LogoImage({
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
className?: string;
|
||||
width?: number;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return <MedReportLogo compact={compact} className={className} />;
|
||||
}
|
||||
|
||||
export function AppLogo({
|
||||
href,
|
||||
label,
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
href?: string | null;
|
||||
className?: string;
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (href === null) {
|
||||
return <LogoImage className={className} compact={compact} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
|
||||
<LogoImage className={className} compact={compact} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
80
packages/shared/src/components/auth-provider.tsx
Normal file
80
packages/shared/src/components/auth-provider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
|
||||
import { useMonitoring } from '@kit/monitoring/hooks';
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { useAuthChangeListener } from '@kit/supabase/hooks/use-auth-change-listener';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
|
||||
|
||||
export function AuthProvider(props: React.PropsWithChildren) {
|
||||
const dispatchEvent = useDispatchAppEventFromAuthEvent();
|
||||
|
||||
const onEvent = useCallback(
|
||||
(event: AuthChangeEvent, session: Session | null) => {
|
||||
dispatchEvent(event, session?.user.id, {
|
||||
email: session?.user.email ?? '',
|
||||
});
|
||||
},
|
||||
[dispatchEvent],
|
||||
);
|
||||
|
||||
useAuthChangeListener({
|
||||
appHomePath: pathsConfig.app.home,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
return props.children;
|
||||
}
|
||||
|
||||
function useDispatchAppEventFromAuthEvent() {
|
||||
const { emit } = useAppEvents();
|
||||
const monitoring = useMonitoring();
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
type: AuthChangeEvent,
|
||||
userId: string | undefined,
|
||||
traits: Record<string, string> = {},
|
||||
) => {
|
||||
switch (type) {
|
||||
case 'INITIAL_SESSION':
|
||||
if (userId) {
|
||||
emit({
|
||||
type: 'user.signedIn',
|
||||
payload: { userId, ...traits },
|
||||
});
|
||||
|
||||
monitoring.identifyUser({ id: userId, ...traits });
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'SIGNED_IN':
|
||||
if (userId) {
|
||||
emit({
|
||||
type: 'user.signedIn',
|
||||
payload: { userId, ...traits },
|
||||
});
|
||||
|
||||
monitoring.identifyUser({ id: userId, ...traits });
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'USER_UPDATED':
|
||||
emit({
|
||||
type: 'user.updated',
|
||||
payload: { userId: userId!, ...traits },
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[emit, monitoring],
|
||||
);
|
||||
}
|
||||
31
packages/shared/src/components/back-button.tsx
Normal file
31
packages/shared/src/components/back-button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { ArrowLeft } from '@/public/assets/arrow-left';
|
||||
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function BackButton({ onBack }: { onBack?: () => void }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<form
|
||||
action={() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="absolute top-4 left-4 flex cursor-pointer flex-row items-center gap-3">
|
||||
<div className="flex items-center justify-center rounded-sm border p-3">
|
||||
<ArrowLeft />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
<Trans i18nKey="common:goBack" />
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
52
packages/shared/src/components/confirmation-modal.tsx
Normal file
52
packages/shared/src/components/confirmation-modal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function ConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
titleKey,
|
||||
descriptionKey,
|
||||
cancelKey = 'common:cancel',
|
||||
confirmKey = 'common:confirm',
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
cancelKey?: string;
|
||||
confirmKey?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={titleKey} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={descriptionKey} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Trans i18nKey={cancelKey} />
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>
|
||||
<Trans i18nKey={confirmKey} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
69
packages/shared/src/components/header-auth.tsx
Normal file
69
packages/shared/src/components/header-auth.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { signOutAction } from '@/lib/actions/sign-out';
|
||||
import { hasEnvVars } from '@/utils/supabase/check-env-vars';
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export default async function AuthButton() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!hasEnvVars) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<Badge
|
||||
variant={'default'}
|
||||
className="pointer-events-none font-normal"
|
||||
>
|
||||
Please update .env.local file with anon key and url
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={'outline'}
|
||||
disabled
|
||||
className="pointer-events-none cursor-none opacity-75"
|
||||
>
|
||||
<Link href="/sign-in">Sign in</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={'default'}
|
||||
disabled
|
||||
className="pointer-events-none cursor-none opacity-75"
|
||||
>
|
||||
<Link href="example/sign-up">Sign up</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return user ? (
|
||||
<div className="flex items-center gap-4">
|
||||
Hey, {user.email}!
|
||||
<form action={signOutAction}>
|
||||
<Button type="submit" variant={'outline'}>
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button asChild size="sm" variant={'outline'}>
|
||||
<Link href="/sign-in">Sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
packages/shared/src/components/med-report-logo.tsx
Normal file
20
packages/shared/src/components/med-report-logo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { MedReportSmallLogo } from '../../../../public/assets/med-report-small-logo';
|
||||
|
||||
export const MedReportLogo = ({
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) => (
|
||||
<div className={cn('flex justify-center gap-2', className)}>
|
||||
<MedReportSmallLogo />
|
||||
{!compact && (
|
||||
<span className="text-foreground text-lg font-semibold tracking-tighter">
|
||||
MedReport
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
31
packages/shared/src/components/package-header.tsx
Normal file
31
packages/shared/src/components/package-header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export const PackageHeader = ({
|
||||
title,
|
||||
tagColor,
|
||||
analysesNr,
|
||||
language,
|
||||
price,
|
||||
}: {
|
||||
title: string;
|
||||
tagColor: string;
|
||||
analysesNr: string;
|
||||
language: string;
|
||||
price: string | number;
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm sm:text-lg sm:font-medium">{title}</p>
|
||||
<h2 className="text-xl sm:text-4xl">
|
||||
{formatCurrency({
|
||||
currencyCode: 'eur',
|
||||
locale: language,
|
||||
value: price,
|
||||
})}
|
||||
</h2>
|
||||
<Badge className={cn('text-xs', tagColor)}>{analysesNr}</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { ApplicationRole } from '@kit/accounts/types/accounts';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
import { pathsConfig, featureFlagsConfig } from '@kit/shared/config';
|
||||
|
||||
|
||||
const paths = {
|
||||
home: pathsConfig.app.home,
|
||||
admin: pathsConfig.app.admin,
|
||||
doctor: pathsConfig.app.doctor,
|
||||
personalAccountSettings: pathsConfig.app.personalAccountSettings,
|
||||
};
|
||||
|
||||
const features = {
|
||||
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
|
||||
};
|
||||
|
||||
export function ProfileAccountDropdownContainer(props: {
|
||||
user?: User;
|
||||
showProfileName?: boolean;
|
||||
|
||||
account?: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
application_role: ApplicationRole | null;
|
||||
};
|
||||
accounts: {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image?: string | null;
|
||||
application_role: ApplicationRole | null;
|
||||
}[];
|
||||
}) {
|
||||
const signOut = useSignOut();
|
||||
const user = useUser(props.user);
|
||||
const userData = user.data;
|
||||
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
className={'w-full'}
|
||||
paths={paths}
|
||||
features={features}
|
||||
user={userData}
|
||||
account={props.account}
|
||||
accounts={props.accounts}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
showProfileName={props.showProfileName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
packages/shared/src/components/react-query-provider.tsx
Normal file
26
packages/shared/src/components/react-query-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
export function ReactQueryProvider(props: React.PropsWithChildren) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
87
packages/shared/src/components/root-providers.tsx
Normal file
87
packages/shared/src/components/root-providers.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
|
||||
import { CaptchaProvider } from '@kit/auth/captcha/client';
|
||||
import { I18nProvider } from '@kit/i18n/provider';
|
||||
import { MonitoringProvider } from '@kit/monitoring/components';
|
||||
import { AnalyticsProvider } from '@kit/shared/components/analytics-provider';
|
||||
import { AuthProvider } from '@kit/shared/components/auth-provider';
|
||||
import { appConfig, authConfig, featureFlagsConfig } from '@kit/shared/config';
|
||||
import { AppEventsProvider } from '@kit/shared/events';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { VersionUpdater } from '@kit/ui/version-updater';
|
||||
|
||||
import { i18nResolver } from '../../../../lib/i18n/i18n.resolver';
|
||||
import { getI18nSettings } from '../../../../lib/i18n/i18n.settings';
|
||||
|
||||
import { ReactQueryProvider } from './react-query-provider';
|
||||
|
||||
const captchaSiteKey = authConfig.captchaTokenSiteKey;
|
||||
|
||||
const CaptchaTokenSetter = dynamic(async () => {
|
||||
if (!captchaSiteKey) {
|
||||
return Promise.resolve(() => null);
|
||||
}
|
||||
|
||||
const { CaptchaTokenSetter } = await import('@kit/auth/captcha/client');
|
||||
|
||||
return {
|
||||
default: CaptchaTokenSetter,
|
||||
};
|
||||
});
|
||||
|
||||
type RootProvidersProps = React.PropsWithChildren<{
|
||||
// The language to use for the app (optional)
|
||||
lang?: string;
|
||||
// The theme (light or dark or system) (optional)
|
||||
theme?: string;
|
||||
// The CSP nonce to pass to scripts (optional)
|
||||
nonce?: string;
|
||||
}>;
|
||||
|
||||
export function RootProviders({
|
||||
lang,
|
||||
theme = appConfig.theme,
|
||||
nonce,
|
||||
children,
|
||||
}: RootProvidersProps) {
|
||||
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
|
||||
|
||||
return (
|
||||
<MonitoringProvider>
|
||||
<AppEventsProvider>
|
||||
<AnalyticsProvider>
|
||||
<ReactQueryProvider>
|
||||
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
|
||||
<CaptchaProvider>
|
||||
<CaptchaTokenSetter siteKey={captchaSiteKey} />
|
||||
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme={theme}
|
||||
enableColorScheme={false}
|
||||
nonce={nonce}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</CaptchaProvider>
|
||||
|
||||
<If condition={featureFlagsConfig.enableVersionUpdater}>
|
||||
<VersionUpdater />
|
||||
</If>
|
||||
</I18nProvider>
|
||||
</ReactQueryProvider>
|
||||
</AnalyticsProvider>
|
||||
</AppEventsProvider>
|
||||
</MonitoringProvider>
|
||||
);
|
||||
}
|
||||
114
packages/shared/src/components/select-analysis-package.tsx
Normal file
114
packages/shared/src/components/select-analysis-package.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { StoreProduct, StoreProductVariant } from '@medusajs/types';
|
||||
import { Button } from '@medusajs/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from '@kit/ui/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
|
||||
import { getAnalysisElementMedusaProductIds } from '../../../../utils/medusa-product';
|
||||
import { PackageHeader } from './package-header';
|
||||
import { ButtonTooltip } from './ui/button-tooltip';
|
||||
|
||||
export interface IAnalysisPackage {
|
||||
titleKey: string;
|
||||
price: number;
|
||||
tagColor: string;
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
export default function SelectAnalysisPackage({
|
||||
analysisPackage,
|
||||
countryCode,
|
||||
}: {
|
||||
analysisPackage: StoreProduct;
|
||||
countryCode: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useTranslation();
|
||||
|
||||
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
||||
const handleSelect = async (selectedVariant: StoreProductVariant) => {
|
||||
if (!selectedVariant?.id) return null;
|
||||
|
||||
setIsAddingToCart(true);
|
||||
await handleAddToCart({
|
||||
selectedVariant,
|
||||
countryCode,
|
||||
});
|
||||
setIsAddingToCart(false);
|
||||
router.push('/home/cart');
|
||||
};
|
||||
|
||||
const titleKey = analysisPackage.title;
|
||||
const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([
|
||||
analysisPackage,
|
||||
]);
|
||||
const nrOfAnalyses = analysisElementMedusaProductIds.length;
|
||||
const description = analysisPackage.description ?? '';
|
||||
const subtitle = analysisPackage.subtitle ?? '';
|
||||
const variant = analysisPackage.variants?.[0];
|
||||
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const price = variant.calculated_price?.calculated_amount ?? 0;
|
||||
|
||||
return (
|
||||
<Card key={titleKey}>
|
||||
<CardHeader className="relative">
|
||||
{description && (
|
||||
<ButtonTooltip
|
||||
content={description}
|
||||
className="absolute top-5 right-5 z-10"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src="/assets/card-image.png"
|
||||
alt="background"
|
||||
width={326}
|
||||
height={195}
|
||||
className="max-h-48 w-full opacity-10"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-center">
|
||||
<PackageHeader
|
||||
title={t(titleKey)}
|
||||
tagColor="bg-cyan"
|
||||
analysesNr={t('product:nrOfAnalyses', { nr: nrOfAnalyses })}
|
||||
language={language}
|
||||
price={price}
|
||||
/>
|
||||
<CardDescription>{subtitle}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full text-[10px] sm:text-sm"
|
||||
onClick={() => handleSelect(variant)}
|
||||
isLoading={isAddingToCart}
|
||||
>
|
||||
{!isAddingToCart && (
|
||||
<Trans i18nKey="order-analysis-package:selectThisPackage" />
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
packages/shared/src/components/select-analysis-packages.tsx
Normal file
19
packages/shared/src/components/select-analysis-packages.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
|
||||
import SelectAnalysisPackage from './select-analysis-package';
|
||||
|
||||
export default function SelectAnalysisPackages({ analysisPackages, countryCode }: { analysisPackages: StoreProduct[], countryCode: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{analysisPackages.length > 0 ? analysisPackages.map(
|
||||
(product) => (
|
||||
<SelectAnalysisPackage key={product.title} analysisPackage={product} countryCode={countryCode} />
|
||||
)) : (
|
||||
<h4>
|
||||
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
packages/shared/src/components/table-skeleton.tsx
Normal file
41
packages/shared/src/components/table-skeleton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
export default function TableSkeleton({
|
||||
rows = 1,
|
||||
cols = 7,
|
||||
}: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<Table className="w-full border-separate animate-pulse rounded-lg border">
|
||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||
<TableRow>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<TableHead key={i} className="p-2">
|
||||
<div className="h-4 w-22 rounded bg-gray-200"></div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<TableRow key={r} className="border-t border-gray-200">
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<TableCell key={c}>
|
||||
<div className="h-4 w-22 rounded bg-gray-200"></div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
31
packages/shared/src/components/ui/button-tooltip.tsx
Normal file
31
packages/shared/src/components/ui/button-tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
|
||||
export function ButtonTooltip({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button className={className} variant="outline" size="icon">
|
||||
<Info className="size-4 cursor-pointer" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='sm:max-w-[30vw] sm:leading-4'>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
30
packages/shared/src/components/ui/info-tooltip.tsx
Normal file
30
packages/shared/src/components/ui/info-tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { JSX } from 'react';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
|
||||
export function InfoTooltip({
|
||||
content,
|
||||
icon,
|
||||
}: {
|
||||
content?: string;
|
||||
icon?: JSX.Element;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{icon || <Info className="size-4 cursor-pointer" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
33
packages/shared/src/components/ui/search.tsx
Normal file
33
packages/shared/src/components/ui/search.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { JSX, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
export type SearchProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
startElement?: string | JSX.Element;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Search = React.forwardRef<HTMLInputElement, SearchProps>(
|
||||
({ className, startElement, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-input ring-offset-background focus-within:ring-ring flex h-10 items-center rounded-md border bg-white pl-3 text-sm focus-within:ring-1 focus-within:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!!startElement && startElement}
|
||||
<input
|
||||
{...props}
|
||||
type="search"
|
||||
ref={ref}
|
||||
className="placeholder:text-muted-foreground w-full p-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Search.displayName = 'Search';
|
||||
|
||||
export { Search };
|
||||
23
packages/shared/src/components/ui/submit-button.tsx
Normal file
23
packages/shared/src/components/ui/submit-button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@kit/ui/button";
|
||||
import { type ComponentProps } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
type Props = ComponentProps<typeof Button> & {
|
||||
pendingText?: string;
|
||||
};
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
pendingText = "Submitting...",
|
||||
...props
|
||||
}: Props) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" aria-disabled={pending} {...props}>
|
||||
{pending ? pendingText : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
78
packages/shared/src/config/app.config.ts
Normal file
78
packages/shared/src/config/app.config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const production = process.env.NODE_ENV === 'production';
|
||||
|
||||
const AppConfigSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string({
|
||||
description: `This is the name of your SaaS. Ex. "Makerkit"`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`,
|
||||
})
|
||||
.min(1),
|
||||
title: z
|
||||
.string({
|
||||
description: `This is the default title tag of your SaaS.`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`,
|
||||
})
|
||||
.min(1),
|
||||
description: z.string({
|
||||
description: `This is the default description of your SaaS.`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`,
|
||||
}),
|
||||
url: z
|
||||
.string({
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`,
|
||||
})
|
||||
.url({
|
||||
message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
|
||||
}),
|
||||
locale: z
|
||||
.string({
|
||||
description: `This is the default locale of your SaaS.`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`,
|
||||
})
|
||||
.default('en'),
|
||||
theme: z.enum(['light', 'dark', 'system']),
|
||||
production: z.boolean(),
|
||||
themeColor: z.string(),
|
||||
themeColorDark: z.string(),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const isCI = process.env.NEXT_PUBLIC_CI;
|
||||
|
||||
if (isCI ?? !schema.production) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !schema.url.startsWith('http:');
|
||||
},
|
||||
{
|
||||
message: `Please provide a valid HTTPS URL. Set the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`,
|
||||
path: ['url'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.themeColor !== schema.themeColorDark;
|
||||
},
|
||||
{
|
||||
message: `Please provide different theme colors for light and dark themes.`,
|
||||
path: ['themeColor'],
|
||||
},
|
||||
);
|
||||
|
||||
const appConfig = AppConfigSchema.parse({
|
||||
name: process.env.NEXT_PUBLIC_PRODUCT_NAME,
|
||||
title: process.env.NEXT_PUBLIC_SITE_TITLE,
|
||||
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION,
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
locale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE,
|
||||
theme: process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
|
||||
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR,
|
||||
themeColorDark: process.env.NEXT_PUBLIC_THEME_COLOR_DARK,
|
||||
production,
|
||||
});
|
||||
|
||||
export default appConfig;
|
||||
73
packages/shared/src/config/auth.config.ts
Normal file
73
packages/shared/src/config/auth.config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const providers: z.ZodType<Provider> = getProviders();
|
||||
|
||||
const AuthConfigSchema = z.object({
|
||||
captchaTokenSiteKey: z
|
||||
.string({
|
||||
description: 'The reCAPTCHA site key.',
|
||||
})
|
||||
.optional(),
|
||||
displayTermsCheckbox: z
|
||||
.boolean({
|
||||
description: 'Whether to display the terms checkbox during sign-up.',
|
||||
})
|
||||
.optional(),
|
||||
providers: z.object({
|
||||
password: z.boolean({
|
||||
description: 'Enable password authentication.',
|
||||
}),
|
||||
magicLink: z.boolean({
|
||||
description: 'Enable magic link authentication.',
|
||||
}),
|
||||
oAuth: providers.array(),
|
||||
}),
|
||||
});
|
||||
|
||||
const authConfig = AuthConfigSchema.parse({
|
||||
// NB: This is a public key, so it's safe to expose.
|
||||
// Copy the value from the Supabase Dashboard.
|
||||
captchaTokenSiteKey: process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY,
|
||||
|
||||
// whether to display the terms checkbox during sign-up
|
||||
displayTermsCheckbox:
|
||||
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true',
|
||||
|
||||
// NB: Enable the providers below in the Supabase Console
|
||||
// in your production project
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
export default authConfig;
|
||||
|
||||
function getProviders() {
|
||||
return z.enum([
|
||||
'apple',
|
||||
'azure',
|
||||
'bitbucket',
|
||||
'discord',
|
||||
'facebook',
|
||||
'figma',
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'kakao',
|
||||
'keycloak',
|
||||
'linkedin',
|
||||
'linkedin_oidc',
|
||||
'notion',
|
||||
'slack',
|
||||
'spotify',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'workos',
|
||||
'zoom',
|
||||
'fly',
|
||||
]);
|
||||
}
|
||||
8
packages/shared/src/config/billing.config.ts
Normal file
8
packages/shared/src/config/billing.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Replace this file with your own billing configuration file.
|
||||
Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products.
|
||||
This file will never be overwritten by git updates
|
||||
*/
|
||||
import sampleSchema from './billing.sample.config';
|
||||
|
||||
export default sampleSchema;
|
||||
148
packages/shared/src/config/billing.sample.config.ts
Normal file
148
packages/shared/src/config/billing.sample.config.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace
|
||||
* the configuration with your own billing provider and products.
|
||||
*/
|
||||
import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
|
||||
|
||||
// The billing provider to use. This should be set in the environment variables
|
||||
// and should match the provider in the database. We also add it here so we can validate
|
||||
// your configuration against the selected provider at build time.
|
||||
const provider = BillingProviderSchema.parse(
|
||||
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
|
||||
);
|
||||
|
||||
export default createBillingSchema({
|
||||
// also update config.billing_provider in the DB to match the selected
|
||||
provider,
|
||||
// products configuration
|
||||
products: [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
description: 'The perfect plan to get started',
|
||||
currency: 'USD',
|
||||
badge: `Value`,
|
||||
plans: [
|
||||
{
|
||||
name: 'Starter Monthly',
|
||||
id: 'starter-monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||
name: 'Starter',
|
||||
cost: 9.99,
|
||||
type: 'flat' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Starter Yearly',
|
||||
id: 'starter-yearly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'starter-yearly',
|
||||
name: 'Base',
|
||||
cost: 99.99,
|
||||
type: 'flat' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
badge: `Popular`,
|
||||
highlighted: true,
|
||||
description: 'The perfect plan for professionals',
|
||||
currency: 'USD',
|
||||
plans: [
|
||||
{
|
||||
name: 'Pro Monthly',
|
||||
id: 'pro-monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_1PGOAVI1i3VnbZTqc69xaypm',
|
||||
name: 'Base',
|
||||
cost: 19.99,
|
||||
type: 'flat',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Pro Yearly',
|
||||
id: 'pro-yearly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_pro_yearly',
|
||||
name: 'Base',
|
||||
cost: 199.99,
|
||||
type: 'flat',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: [
|
||||
'Feature 1',
|
||||
'Feature 2',
|
||||
'Feature 3',
|
||||
'Feature 4',
|
||||
'Feature 5',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'The perfect plan for enterprises',
|
||||
currency: 'USD',
|
||||
plans: [
|
||||
{
|
||||
name: 'Enterprise Monthly',
|
||||
id: 'enterprise-monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_enterprise-monthly',
|
||||
name: 'Base',
|
||||
cost: 29.99,
|
||||
type: 'flat',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Enterprise Yearly',
|
||||
id: 'enterprise-yearly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'year',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_enterprise_yearly',
|
||||
name: 'Base',
|
||||
cost: 299.9,
|
||||
type: 'flat',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
features: [
|
||||
'Feature 1',
|
||||
'Feature 2',
|
||||
'Feature 3',
|
||||
'Feature 4',
|
||||
'Feature 5',
|
||||
'Feature 6',
|
||||
'Feature 7',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
112
packages/shared/src/config/feature-flags.config.ts
Normal file
112
packages/shared/src/config/feature-flags.config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
type LanguagePriority = 'user' | 'application';
|
||||
|
||||
const FeatureFlagsSchema = z.object({
|
||||
enableThemeToggle: z.boolean({
|
||||
description: 'Enable theme toggle in the user interface.',
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE',
|
||||
}),
|
||||
enableAccountDeletion: z.boolean({
|
||||
description: 'Enable personal account deletion.',
|
||||
required_error:
|
||||
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION',
|
||||
}),
|
||||
enableTeamDeletion: z.boolean({
|
||||
description: 'Enable team deletion.',
|
||||
required_error:
|
||||
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION',
|
||||
}),
|
||||
enableTeamAccounts: z.boolean({
|
||||
description: 'Enable team accounts.',
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS',
|
||||
}),
|
||||
enableTeamCreation: z.boolean({
|
||||
description: 'Enable team creation.',
|
||||
required_error:
|
||||
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION',
|
||||
}),
|
||||
enablePersonalAccountBilling: z.boolean({
|
||||
description: 'Enable personal account billing.',
|
||||
required_error:
|
||||
'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING',
|
||||
}),
|
||||
enableTeamAccountBilling: z.boolean({
|
||||
description: 'Enable team account billing.',
|
||||
required_error:
|
||||
'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING',
|
||||
}),
|
||||
languagePriority: z
|
||||
.enum(['user', 'application'], {
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY',
|
||||
description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`,
|
||||
})
|
||||
.default('application'),
|
||||
enableNotifications: z.boolean({
|
||||
description: 'Enable notifications functionality',
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS',
|
||||
}),
|
||||
realtimeNotifications: z.boolean({
|
||||
description: 'Enable realtime for the notifications functionality',
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS',
|
||||
}),
|
||||
enableVersionUpdater: z.boolean({
|
||||
description: 'Enable version updater',
|
||||
required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER',
|
||||
}),
|
||||
});
|
||||
|
||||
const featureFlagsConfig = FeatureFlagsSchema.parse({
|
||||
enableThemeToggle: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_THEME_TOGGLE,
|
||||
true,
|
||||
),
|
||||
enableAccountDeletion: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION,
|
||||
false,
|
||||
),
|
||||
enableTeamDeletion: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION,
|
||||
false,
|
||||
),
|
||||
enableTeamAccounts: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS,
|
||||
true,
|
||||
),
|
||||
enableTeamCreation: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION,
|
||||
true,
|
||||
),
|
||||
enablePersonalAccountBilling: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING,
|
||||
false,
|
||||
),
|
||||
enableTeamAccountBilling: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING,
|
||||
false,
|
||||
),
|
||||
languagePriority: process.env
|
||||
.NEXT_PUBLIC_LANGUAGE_PRIORITY as LanguagePriority,
|
||||
enableNotifications: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS,
|
||||
true,
|
||||
),
|
||||
realtimeNotifications: getBoolean(
|
||||
process.env.NEXT_PUBLIC_REALTIME_NOTIFICATIONS,
|
||||
false,
|
||||
),
|
||||
enableVersionUpdater: getBoolean(
|
||||
process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER,
|
||||
false,
|
||||
),
|
||||
} satisfies z.infer<typeof FeatureFlagsSchema>);
|
||||
|
||||
export default featureFlagsConfig;
|
||||
|
||||
function getBoolean(value: unknown, defaultValue: boolean) {
|
||||
if (typeof value === 'string') {
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
21
packages/shared/src/config/index.ts
Normal file
21
packages/shared/src/config/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import appConfig from './app.config';
|
||||
import authConfig from './auth.config';
|
||||
import billingConfig from './billing.config';
|
||||
import featureFlagsConfig from './feature-flags.config';
|
||||
import pathsConfig from './paths.config';
|
||||
import { personalAccountNavigationConfig } from './personal-account-navigation.config';
|
||||
import {
|
||||
createPath,
|
||||
getTeamAccountSidebarConfig,
|
||||
} from './team-account-navigation.config';
|
||||
|
||||
export {
|
||||
appConfig,
|
||||
authConfig,
|
||||
billingConfig,
|
||||
createPath,
|
||||
featureFlagsConfig,
|
||||
getTeamAccountSidebarConfig,
|
||||
pathsConfig,
|
||||
personalAccountNavigationConfig,
|
||||
};
|
||||
81
packages/shared/src/config/paths.config.ts
Normal file
81
packages/shared/src/config/paths.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const PathsSchema = z.object({
|
||||
auth: z.object({
|
||||
signIn: z.string().min(1),
|
||||
signUp: z.string().min(1),
|
||||
verifyMfa: z.string().min(1),
|
||||
callback: z.string().min(1),
|
||||
passwordReset: z.string().min(1),
|
||||
passwordUpdate: z.string().min(1),
|
||||
updateAccount: z.string().min(1),
|
||||
updateAccountSuccess: z.string().min(1),
|
||||
membershipConfirmation: z.string().min(1),
|
||||
}),
|
||||
app: z.object({
|
||||
home: z.string().min(1),
|
||||
selectPackage: z.string().min(1),
|
||||
booking: z.string().min(1),
|
||||
myOrders: z.string().min(1),
|
||||
analysisResults: z.string().min(1),
|
||||
orderAnalysisPackage: z.string().min(1),
|
||||
orderAnalysis: z.string().min(1),
|
||||
orderHealthAnalysis: z.string().min(1),
|
||||
personalAccountSettings: z.string().min(1),
|
||||
personalAccountBilling: z.string().min(1),
|
||||
personalAccountBillingReturn: z.string().min(1),
|
||||
accountHome: z.string().min(1),
|
||||
accountSettings: z.string().min(1),
|
||||
accountBilling: z.string().min(1),
|
||||
accountMembers: z.string().min(1),
|
||||
accountBillingReturn: z.string().min(1),
|
||||
joinTeam: z.string().min(1),
|
||||
admin: z.string().min(1),
|
||||
doctor: z.string().min(1),
|
||||
myJobs: z.string().min(1),
|
||||
completedJobs: z.string().min(1),
|
||||
openJobs: z.string().min(1),
|
||||
analysisDetails: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
const pathsConfig = PathsSchema.parse({
|
||||
auth: {
|
||||
signIn: '/auth/sign-in',
|
||||
signUp: '/auth/sign-up',
|
||||
verifyMfa: '/auth/verify',
|
||||
callback: '/auth/callback',
|
||||
passwordReset: '/auth/password-reset',
|
||||
passwordUpdate: '/update-password',
|
||||
updateAccount: '/auth/update-account',
|
||||
updateAccountSuccess: '/auth/update-account/success',
|
||||
membershipConfirmation: '/auth/membership-confirmation',
|
||||
},
|
||||
app: {
|
||||
home: '/home',
|
||||
personalAccountSettings: '/home/settings',
|
||||
personalAccountBilling: '/home/billing',
|
||||
personalAccountBillingReturn: '/home/billing/return',
|
||||
accountHome: '/home/[account]',
|
||||
accountSettings: `/home/[account]/settings`,
|
||||
accountBilling: `/home/[account]/billing`,
|
||||
accountMembers: `/home/[account]/members`,
|
||||
accountBillingReturn: `/home/[account]/billing/return`,
|
||||
joinTeam: '/join',
|
||||
selectPackage: '/select-package',
|
||||
booking: '/home/booking',
|
||||
orderAnalysisPackage: '/home/order-analysis-package',
|
||||
myOrders: '/home/order',
|
||||
analysisResults: '/home/analysis-results',
|
||||
orderAnalysis: '/home/order-analysis',
|
||||
orderHealthAnalysis: '/home/order-health-analysis',
|
||||
doctor: '/doctor',
|
||||
admin: '/admin',
|
||||
myJobs: '/doctor/my-jobs',
|
||||
completedJobs: '/doctor/completed-jobs',
|
||||
openJobs: '/doctor/open-jobs',
|
||||
analysisDetails: 'doctor/analysis',
|
||||
},
|
||||
} satisfies z.infer<typeof PathsSchema>);
|
||||
|
||||
export default pathsConfig;
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
FileLineChart,
|
||||
HeartPulse,
|
||||
LineChart,
|
||||
MousePointerClick,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
TestTube2,
|
||||
} from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
|
||||
const iconClasses = 'w-4 stroke-[1.5px]';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: 'common:routes.overview',
|
||||
path: pathsConfig.app.home,
|
||||
Icon: <LineChart className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.booking',
|
||||
path: pathsConfig.app.booking,
|
||||
Icon: <MousePointerClick className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.myOrders',
|
||||
path: pathsConfig.app.myOrders,
|
||||
Icon: <ShoppingCart className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.analysisResults',
|
||||
path: pathsConfig.app.analysisResults,
|
||||
Icon: <TestTube2 className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.orderAnalysisPackage',
|
||||
path: pathsConfig.app.orderAnalysisPackage,
|
||||
Icon: <HeartPulse className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.orderAnalysis',
|
||||
path: pathsConfig.app.orderAnalysis,
|
||||
Icon: <FileLineChart className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.orderHealthAnalysis',
|
||||
path: pathsConfig.app.orderHealthAnalysis,
|
||||
Icon: <Stethoscope className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies z.infer<typeof NavigationConfigSchema>['routes'];
|
||||
|
||||
export const personalAccountNavigationConfig = NavigationConfigSchema.parse({
|
||||
routes,
|
||||
style: 'custom',
|
||||
sidebarCollapsed: false,
|
||||
sidebarCollapsedStyle: 'icon',
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
|
||||
|
||||
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
||||
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
||||
|
||||
const iconClasses = 'w-4';
|
||||
|
||||
const getRoutes = (account: string) => [
|
||||
{
|
||||
label: 'common:routes.application',
|
||||
children: [
|
||||
{
|
||||
label: 'common:routes.dashboard',
|
||||
path: pathsConfig.app.accountHome.replace('[account]', account),
|
||||
Icon: <LayoutDashboard className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'common:routes.settings',
|
||||
collapsible: false,
|
||||
children: [
|
||||
{
|
||||
label: 'common:routes.settings',
|
||||
path: createPath(pathsConfig.app.accountSettings, account),
|
||||
Icon: <Settings className={iconClasses} />,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.members',
|
||||
path: createPath(pathsConfig.app.accountMembers, account),
|
||||
Icon: <Users className={iconClasses} />,
|
||||
},
|
||||
featureFlagsConfig.enableTeamAccountBilling
|
||||
? {
|
||||
label: 'common:routes.billing',
|
||||
path: createPath(pathsConfig.app.accountBilling, account),
|
||||
Icon: <CreditCard className={iconClasses} />,
|
||||
}
|
||||
: undefined,
|
||||
].filter(Boolean),
|
||||
},
|
||||
];
|
||||
|
||||
export function getTeamAccountSidebarConfig(account: string) {
|
||||
return NavigationConfigSchema.parse({
|
||||
routes: getRoutes(account),
|
||||
style: process.env.NEXT_PUBLIC_TEAM_NAVIGATION_STYLE,
|
||||
sidebarCollapsed: process.env.NEXT_PUBLIC_TEAM_SIDEBAR_COLLAPSED,
|
||||
sidebarCollapsedStyle: process.env.NEXT_PUBLIC_SIDEBAR_COLLAPSED_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPath(path: string, account: string) {
|
||||
return path.replace('[account]', account);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { format } from 'date-fns';
|
||||
import Isikukood, { Gender } from 'isikukood';
|
||||
|
||||
/**
|
||||
* Check if the code is running in a browser environment.
|
||||
*/
|
||||
@@ -21,3 +24,36 @@ export function formatCurrency(params: {
|
||||
currency: params.currencyCode,
|
||||
}).format(Number(params.value));
|
||||
}
|
||||
|
||||
export function formatDateAndTime(date?: string) {
|
||||
if (!date) return '-';
|
||||
|
||||
return format(date, 'dd.MM.yyyy HH:mm');
|
||||
}
|
||||
|
||||
export function formatDate(date?: string) {
|
||||
if (!date) return '-';
|
||||
|
||||
return format(date, 'dd.MM.yyyy');
|
||||
}
|
||||
|
||||
export function getFullName(
|
||||
firstName?: string | null,
|
||||
lastName?: string | null,
|
||||
) {
|
||||
return [firstName ?? '', lastName ?? ''].join(' ');
|
||||
}
|
||||
|
||||
export const getPersonParameters = (personalCode: string) => {
|
||||
try {
|
||||
const person = new Isikukood(personalCode);
|
||||
return {
|
||||
gender: person.getGender(),
|
||||
dob: person.getBirthday(),
|
||||
age: person.getAge(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1012,6 +1012,53 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
doctor_analysis_feedback: {
|
||||
Row: {
|
||||
analysis_order_id: number
|
||||
created_at: string
|
||||
created_by: string
|
||||
doctor_user_id: string | null
|
||||
id: number
|
||||
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at: string
|
||||
updated_by: string | null
|
||||
user_id: string
|
||||
value: string | null
|
||||
}
|
||||
Insert: {
|
||||
analysis_order_id: number
|
||||
created_at?: string
|
||||
created_by?: string
|
||||
doctor_user_id?: string | null
|
||||
id?: number
|
||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
user_id: string
|
||||
value?: string | null
|
||||
}
|
||||
Update: {
|
||||
analysis_order_id?: number
|
||||
created_at?: string
|
||||
created_by?: string
|
||||
doctor_user_id?: string | null
|
||||
id?: number
|
||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at?: string
|
||||
updated_by?: string | null
|
||||
user_id?: string
|
||||
value?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "doctor_analysis_feedback_analysis_order_id_fkey"
|
||||
columns: ["analysis_order_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "analysis_orders"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
invitations: {
|
||||
Row: {
|
||||
account_id: string
|
||||
@@ -1864,22 +1911,6 @@ export type Database = {
|
||||
}
|
||||
Returns: undefined
|
||||
}
|
||||
update_analysis_order_status: {
|
||||
Args: {
|
||||
order_id: number
|
||||
medusa_order_id_param: string
|
||||
status_param: Database["medreport"]["Enums"]["analysis_order_status"]
|
||||
}
|
||||
Returns: {
|
||||
analysis_element_ids: number[] | null
|
||||
analysis_ids: number[] | null
|
||||
created_at: string
|
||||
id: number
|
||||
medusa_order_id: string
|
||||
status: Database["medreport"]["Enums"]["analysis_order_status"]
|
||||
user_id: string
|
||||
}
|
||||
}
|
||||
upsert_order: {
|
||||
Args: {
|
||||
target_account_id: string
|
||||
@@ -1950,6 +1981,7 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
|
||||
analysis_order_status:
|
||||
| "QUEUED"
|
||||
| "ON_HOLD"
|
||||
@@ -7843,6 +7875,7 @@ export const Constants = {
|
||||
},
|
||||
medreport: {
|
||||
Enums: {
|
||||
analysis_feedback_status: ["STARTED", "DRAFT", "COMPLETED"],
|
||||
analysis_order_status: [
|
||||
"QUEUED",
|
||||
"ON_HOLD",
|
||||
|
||||
Reference in New Issue
Block a user