p 1 and 2 compeleted

This commit is contained in:
dimitar committed 2026-03-11 01:43:19 +01:00
1 parent 6adb541967
commit 3573709d99
28 files changed
+2533 -240

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
import { Resend } from "resend";
import { render } from "@react-email/render";
import log from "./logger";
/**
* Email service using Resend
*
* Environment variables required:
* - RESEND_API_KEY: API key from Resend dashboard
* - EMAIL_FROM: Sender email address (e.g., "FitAI <noreply@fitai.com>")
* - EMAIL_REPLY_TO: Reply-to email address (optional)
*/
const resend = new Resend(process.env.RESEND_API_KEY);
const DEFAULT_FROM = process.env.EMAIL_FROM || "FitAI <noreply@fitai.com>";
const DEFAULT_REPLY_TO = process.env.EMAIL_REPLY_TO;
export interface SendEmailOptions {
to: string | string[];
subject: string;
react: React.ReactElement;
from?: string;
replyTo?: string;
}
/**
* Send an email using Resend
*
* @param options - Email options including recipient, subject, and React component
* @returns Response from Resend API
*
* @example
* ```tsx
* import { WelcomeEmail } from '@/emails/welcome-email';
*
* await sendEmail({
* to: 'user@example.com',
* subject: 'Welcome to FitAI',
* react: <WelcomeEmail name="John" />
* });
* ```
*/
export async function sendEmail({
to,
subject,
react,
from = DEFAULT_FROM,
replyTo = DEFAULT_REPLY_TO,
}: SendEmailOptions) {
try {
// Check if email is configured
if (!process.env.RESEND_API_KEY) {
log.warn("RESEND_API_KEY not configured. Email not sent.", {
to,
subject,
});
return { success: false, error: "Email not configured" };
}
const { data, error } = await resend.emails.send({
from,
to,
subject,
react,
...(replyTo && { replyTo }),
});
if (error) {
log.error("Failed to send email", error, { to, subject });
return { success: false, error: error.message };
}
log.info("Email sent successfully", { to, subject, emailId: data?.id });
return { success: true, data };
} catch (error) {
log.error("Email service error", error, { to, subject });
return { success: false, error: "Email service error" };
}
}
/**
* Send a welcome email to a new user
*/
export async function sendWelcomeEmail(email: string, name: string) {
const { WelcomeEmail } = await import("@/emails/welcome-email");
return sendEmail({
to: email,
subject: "Welcome to FitAI!",
react: WelcomeEmail({ name }),
});
}
/**
* Send an invitation email to a new user
*/
export async function sendInvitationEmail(
email: string,
role: string,
inviteUrl: string,
) {
const { UserInvitationEmail } = await import(
"@/emails/user-invitation-email"
);
return sendEmail({
to: email,
subject: "You've been invited to join FitAI",
react: UserInvitationEmail({ email, role, inviteUrl }),
});
}
/**
* Send a trainer assignment notification email
*/
export async function sendTrainerAssignmentEmail(
clientEmail: string,
clientName: string,
trainerName: string,
) {
const { TrainerAssignmentEmail } = await import(
"@/emails/trainer-assignment-email"
);
return sendEmail({
to: clientEmail,
subject: "You've been assigned a trainer",
react: TrainerAssignmentEmail({ clientName, trainerName }),
});
}
/**
* Send a recommendation notification email to a client
*/
export async function sendRecommendationEmail(
clientEmail: string,
clientName: string,
recommendationText: string,
) {
const { RecommendationEmail } = await import("@/emails/recommendation-email");
return sendEmail({
to: clientEmail,
subject: "New fitness recommendation from your trainer",
react: RecommendationEmail({ clientName, recommendationText }),
});
}
+77
View File
@@ -0,0 +1,77 @@
import { toast as sonnerToast } from "sonner";
/**
* Toast notification utility wrapper around Sonner
*
* Provides a consistent API for showing toast notifications throughout the app
*/
export const toast = {
/**
* Show a success toast notification
* @param message - The message to display
* @param description - Optional description text
*/
success: (message: string, description?: string) => {
return sonnerToast.success(message, { description });
},
/**
* Show an error toast notification
* @param message - The message to display
* @param description - Optional description text
*/
error: (message: string, description?: string) => {
return sonnerToast.error(message, { description });
},
/**
* Show an info toast notification
* @param message - The message to display
* @param description - Optional description text
*/
info: (message: string, description?: string) => {
return sonnerToast.info(message, { description });
},
/**
* Show a warning toast notification
* @param message - The message to display
* @param description - Optional description text
*/
warning: (message: string, description?: string) => {
return sonnerToast.warning(message, { description });
},
/**
* Show a loading toast notification
* @param message - The message to display
* @returns Toast ID that can be used to update or dismiss the toast
*/
loading: (message: string) => {
return sonnerToast.loading(message);
},
/**
* Show a promise toast that updates based on promise state
* @param promise - The promise to track
* @param messages - Messages for loading, success, and error states
*/
promise: <T>(
promise: Promise<T>,
messages: {
loading: string;
success: string | ((data: T) => string);
error: string | ((error: Error) => string);
},
) => {
return sonnerToast.promise(promise, messages);
},
/**
* Dismiss a specific toast by ID or all toasts
* @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts
*/
dismiss: (toastId?: string | number) => {
return sonnerToast.dismiss(toastId);
},
};
@@ -0,0 +1,111 @@
import { z } from "zod";
/**
* Validation schemas for user creation flow
*/
// Step 1: Role Selection
export const roleSelectionSchema = z.object({
role: z.enum(["admin", "trainer", "client"], {
message: "Please select a role",
}),
});
export type RoleSelectionData = z.infer<typeof roleSelectionSchema>;
// Step 2: Basic Information
export const basicInfoSchema = z.object({
firstName: z
.string()
.min(2, "First name must be at least 2 characters")
.max(50, "First name must be less than 50 characters"),
lastName: z
.string()
.min(2, "Last name must be at least 2 characters")
.max(50, "Last name must be less than 50 characters"),
phone: z
.string()
.regex(/^\+?[1-9]\d{1,14}$/, "Please enter a valid phone number")
.optional()
.or(z.literal("")),
email: z.string().email("Please enter a valid email address"),
});
export type BasicInfoData = z.infer<typeof basicInfoSchema>;
// Step 3: Client-Specific Information (only for clients)
export const clientInfoSchema = z.object({
membershipType: z.enum(["basic", "premium", "vip"], {
message: "Please select a membership type",
}),
membershipStatus: z.enum(["active", "inactive", "suspended"]),
sendWelcomeEmail: z.boolean(),
});
export type ClientInfoData = z.infer<typeof clientInfoSchema>;
// Step 4: Invitation Options (for admin/trainer with email)
export const invitationOptionsSchema = z.object({
sendInvitation: z.boolean(),
gymId: z.string().optional(),
});
export type InvitationOptionsData = z.infer<typeof invitationOptionsSchema>;
// Complete user creation payload
export const createUserSchema = z.object({
role: z.enum(["admin", "trainer", "client"]),
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
phone: z.string().optional(),
email: z.string().email(),
// Client-specific fields
membershipType: z.enum(["basic", "premium", "vip"]).optional(),
membershipStatus: z.enum(["active", "inactive", "suspended"]).optional(),
// Invitation options
sendInvitation: z.boolean(),
sendWelcomeEmail: z.boolean(),
gymId: z.string().optional(),
});
export type CreateUserData = z.infer<typeof createUserSchema>;
/**
* Helper function to validate and merge form data from all steps
*/
export function mergeUserCreationData(
roleData: RoleSelectionData,
basicInfo: BasicInfoData,
clientInfo?: ClientInfoData,
invitationOptions?: InvitationOptionsData,
): CreateUserData {
const merged: CreateUserData = {
role: roleData.role,
firstName: basicInfo.firstName,
lastName: basicInfo.lastName,
phone: basicInfo.phone || undefined,
email: basicInfo.email,
sendInvitation: false,
sendWelcomeEmail: false,
};
// Add client-specific data
if (roleData.role === "client" && clientInfo) {
merged.membershipType = clientInfo.membershipType;
merged.membershipStatus = clientInfo.membershipStatus;
merged.sendWelcomeEmail = clientInfo.sendWelcomeEmail;
}
// Add invitation options for admin/trainer
if (
(roleData.role === "admin" || roleData.role === "trainer") &&
invitationOptions
) {
merged.sendInvitation = invitationOptions.sendInvitation;
merged.gymId = invitationOptions.gymId;
}
return createUserSchema.parse(merged);
}