p 1 and 2 compeleted
This commit is contained in:
1 parent
6adb541967
commit
3573709d99
28 files changed
+2533
-240
No files matched your search
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user