diff --git a/.gitignore b/.gitignore index e92851d..e4c9f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,9 @@ PRODUCTION_DEPLOYMENT.md PRODUCTION_INSTALLATION.md TESTING.md PACKAGE_UPGRADE_PLAN.md +PUSH_NOTIFICATIONS_IMPLEMENTATION.md +PUSH_NOTIFICATIONS_PERSISTENCE_SUMMARY.md +pwa_web_push_implementation_plan.md **/docs/*.md !README.md diff --git a/maternal-app/maternal-app-backend/src/common/services/email.service.ts b/maternal-app/maternal-app-backend/src/common/services/email.service.ts index c569b46..3f135aa 100644 --- a/maternal-app/maternal-app-backend/src/common/services/email.service.ts +++ b/maternal-app/maternal-app-backend/src/common/services/email.service.ts @@ -316,6 +316,130 @@ export class EmailService { `.trim(); } + /** + * Send family invite email with role-based invite code + */ + async sendFamilyInviteEmail( + to: string, + inviterName: string, + familyName: string, + role: 'parent' | 'caregiver' | 'viewer', + inviteCode: string, + expiresAt: Date, + ): Promise { + const subject = `${inviterName} invited you to join their family on Maternal App`; + const html = this.getFamilyInviteEmailTemplate( + inviterName, + familyName, + role, + inviteCode, + expiresAt, + ); + + await this.sendEmail({ to, subject, html }); + } + + /** + * Family invite email template + */ + private getFamilyInviteEmailTemplate( + inviterName: string, + familyName: string, + role: 'parent' | 'caregiver' | 'viewer', + inviteCode: string, + expiresAt: Date, + ): string { + const roleDescriptions = { + parent: + 'Full access - manage children, log activities, view reports, and invite others', + caregiver: + 'Can edit children, log activities, and view reports (cannot add children or invite others)', + viewer: 'View-only access - can view reports but cannot log activities', + }; + + const roleEmojis = { + parent: '👨‍👩‍👧', + caregiver: '🤝', + viewer: '👁️', + }; + + const expiryDate = new Date(expiresAt).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + return ` + + + + + + Family Invitation + + + +
+
+

${roleEmojis[role]} You've Been Invited!

+
+
+

${inviterName} has invited you to join ${familyName} on Maternal App.

+ +
+

Your Invite Code:

+
${inviteCode}
+
${role.charAt(0).toUpperCase() + role.slice(1)} Role
+
+ +
+

Your Permissions:

+

${roleDescriptions[role]}

+
+ +

To accept this invitation:

+
    +
  1. Sign up or log in to Maternal App
  2. +
  3. Go to the "Join Family" section
  4. +
  5. Enter the invite code above
  6. +
+ +
+ Open Maternal App +
+ +

⏰ This invitation expires on ${expiryDate}

+ +

If you didn't expect this invitation, you can safely ignore this email.

+
+ +
+ + + `.trim(); + } + /** * Strip HTML tags for plain text version */ diff --git a/maternal-app/maternal-app-backend/src/database/entities/family.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/family.entity.ts index 062ceae..797c253 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/family.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/family.entity.ts @@ -23,6 +23,43 @@ export class Family { @Column({ name: 'share_code', length: 10, unique: true }) shareCode: string; + @Column({ + name: 'share_code_expires_at', + type: 'timestamp without time zone', + nullable: true, + }) + shareCodeExpiresAt?: Date | null; + + @Column({ name: 'parent_invite_code', length: 10, nullable: true }) + parentInviteCode?: string; + + @Column({ + name: 'parent_invite_expires_at', + type: 'timestamp without time zone', + nullable: true, + }) + parentInviteExpiresAt?: Date | null; + + @Column({ name: 'caregiver_invite_code', length: 10, nullable: true }) + caregiverInviteCode?: string; + + @Column({ + name: 'caregiver_invite_expires_at', + type: 'timestamp without time zone', + nullable: true, + }) + caregiverInviteExpiresAt?: Date | null; + + @Column({ name: 'viewer_invite_code', length: 10, nullable: true }) + viewerInviteCode?: string; + + @Column({ + name: 'viewer_invite_expires_at', + type: 'timestamp without time zone', + nullable: true, + }) + viewerInviteExpiresAt?: Date | null; + @Column({ name: 'created_by', length: 20 }) createdBy: string; diff --git a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts index cf7a7af..2779e86 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts @@ -118,6 +118,25 @@ export class User { timeFormat?: '12h' | '24h'; }; + // Onboarding tracking fields + @Column({ name: 'onboarding_completed', default: false }) + onboardingCompleted: boolean; + + @Column({ name: 'onboarding_step', default: 0 }) + onboardingStep: number; + + @Column({ name: 'onboarding_data', type: 'jsonb', nullable: true }) + onboardingData?: { + selectedLanguage?: string; + selectedMeasurement?: 'metric' | 'imperial'; + familyChoice?: 'create' | 'join'; + childData?: { + name?: string; + birthDate?: string; + gender?: string; + }; + }; + // Admin/Role fields @Column({ name: 'global_role', length: 20, default: 'parent' }) globalRole: 'parent' | 'guest' | 'admin'; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/1736000000000-UpdateFamilyRoleConstraint.ts b/maternal-app/maternal-app-backend/src/database/migrations/1736000000000-UpdateFamilyRoleConstraint.ts new file mode 100644 index 0000000..0d3d333 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/1736000000000-UpdateFamilyRoleConstraint.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateFamilyRoleConstraint1736000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Drop old constraint if it exists + await queryRunner.query(` + ALTER TABLE family_members + DROP CONSTRAINT IF EXISTS valid_family_role; + `); + + // Add new constraint with all 3 roles + await queryRunner.query(` + ALTER TABLE family_members + ADD CONSTRAINT valid_family_role + CHECK (role IN ('parent', 'caregiver', 'viewer')); + `); + + // Update existing 'guest' roles to 'viewer' (if any exist) + await queryRunner.query(` + UPDATE family_members + SET role = 'viewer' + WHERE role = 'guest'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Revert viewer back to guest + await queryRunner.query(` + UPDATE family_members + SET role = 'guest' + WHERE role = 'viewer'; + `); + + // Drop new constraint + await queryRunner.query(` + ALTER TABLE family_members + DROP CONSTRAINT IF EXISTS valid_family_role; + `); + + // Restore old constraint + await queryRunner.query(` + ALTER TABLE family_members + ADD CONSTRAINT valid_family_role + CHECK (role IN ('parent', 'guest')); + `); + } +} diff --git a/maternal-app/maternal-app-backend/src/database/migrations/1736000000001-AddShareCodeExpiryToFamily.ts b/maternal-app/maternal-app-backend/src/database/migrations/1736000000001-AddShareCodeExpiryToFamily.ts new file mode 100644 index 0000000..44521b4 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/1736000000001-AddShareCodeExpiryToFamily.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddShareCodeExpiryToFamily1736000000001 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Add share_code_expires_at column to families table + await queryRunner.query(` + ALTER TABLE families + ADD COLUMN share_code_expires_at TIMESTAMP; + `); + + // Create index on share_code for faster lookups when joining by code + await queryRunner.query(` + CREATE INDEX idx_families_share_code + ON families(share_code) + WHERE share_code IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop index + await queryRunner.query(` + DROP INDEX IF EXISTS idx_families_share_code; + `); + + // Drop column + await queryRunner.query(` + ALTER TABLE families + DROP COLUMN share_code_expires_at; + `); + } +} diff --git a/maternal-app/maternal-app-backend/src/database/migrations/1736000000002-AddRoleBasedInviteCodes.ts b/maternal-app/maternal-app-backend/src/database/migrations/1736000000002-AddRoleBasedInviteCodes.ts new file mode 100644 index 0000000..2d562ef --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/1736000000002-AddRoleBasedInviteCodes.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoleBasedInviteCodes1736000000002 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // Add role-specific invite codes to families table + await queryRunner.query(` + ALTER TABLE families + ADD COLUMN parent_invite_code VARCHAR(10), + ADD COLUMN parent_invite_expires_at TIMESTAMP, + ADD COLUMN caregiver_invite_code VARCHAR(10), + ADD COLUMN caregiver_invite_expires_at TIMESTAMP, + ADD COLUMN viewer_invite_code VARCHAR(10), + ADD COLUMN viewer_invite_expires_at TIMESTAMP; + `); + + // Create indexes for role-based invite codes + await queryRunner.query(` + CREATE INDEX idx_families_parent_invite_code + ON families(parent_invite_code) + WHERE parent_invite_code IS NOT NULL; + `); + + await queryRunner.query(` + CREATE INDEX idx_families_caregiver_invite_code + ON families(caregiver_invite_code) + WHERE caregiver_invite_code IS NOT NULL; + `); + + await queryRunner.query(` + CREATE INDEX idx_families_viewer_invite_code + ON families(viewer_invite_code) + WHERE viewer_invite_code IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop indexes + await queryRunner.query(` + DROP INDEX IF EXISTS idx_families_viewer_invite_code; + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS idx_families_caregiver_invite_code; + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS idx_families_parent_invite_code; + `); + + // Drop columns + await queryRunner.query(` + ALTER TABLE families + DROP COLUMN viewer_invite_expires_at, + DROP COLUMN viewer_invite_code, + DROP COLUMN caregiver_invite_expires_at, + DROP COLUMN caregiver_invite_code, + DROP COLUMN parent_invite_expires_at, + DROP COLUMN parent_invite_code; + `); + } +} diff --git a/maternal-app/maternal-app-backend/src/graphql/types/family.type.ts b/maternal-app/maternal-app-backend/src/graphql/types/family.type.ts index 2f49a0f..2fbe60a 100644 --- a/maternal-app/maternal-app-backend/src/graphql/types/family.type.ts +++ b/maternal-app/maternal-app-backend/src/graphql/types/family.type.ts @@ -4,6 +4,7 @@ import { UserType } from './user.type'; export enum FamilyRole { PARENT = 'parent', CAREGIVER = 'caregiver', + VIEWER = 'viewer', } registerEnumType(FamilyRole, { diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts index b27d70c..f0a1292 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.service.ts @@ -171,29 +171,9 @@ export class AuthService { ); } - // Create default family for user - const family = this.familyRepository.create({ - name: `${registerDto.name}'s Family`, - createdBy: savedUser.id, - subscriptionTier: 'free', - }); - - const savedFamily = await this.familyRepository.save(family); - - // Add user as parent in the family - const familyMember = this.familyMemberRepository.create({ - userId: savedUser.id, - familyId: savedFamily.id, - role: FamilyRole.PARENT, - permissions: { - canAddChildren: true, - canEditChildren: true, - canLogActivities: true, - canViewReports: true, - }, - }); - - await this.familyMemberRepository.save(familyMember); + // REMOVED: Don't create default family during registration + // Family will be created during onboarding when user chooses "Create New Family" + // or user will join existing family if they choose "Join Family" // Log COPPA consent if user is under 18 if (age < 18 && registerDto.coppaConsentGiven) { diff --git a/maternal-app/maternal-app-backend/src/modules/families/dto/join-family.dto.ts b/maternal-app/maternal-app-backend/src/modules/families/dto/join-family.dto.ts index f8e34aa..5415482 100644 --- a/maternal-app/maternal-app-backend/src/modules/families/dto/join-family.dto.ts +++ b/maternal-app/maternal-app-backend/src/modules/families/dto/join-family.dto.ts @@ -2,6 +2,6 @@ import { IsString, Length } from 'class-validator'; export class JoinFamilyDto { @IsString() - @Length(6, 6) + @Length(6, 10) shareCode: string; } diff --git a/maternal-app/maternal-app-backend/src/modules/families/families.controller.ts b/maternal-app/maternal-app-backend/src/modules/families/families.controller.ts index a8ecc0e..72d4166 100644 --- a/maternal-app/maternal-app-backend/src/modules/families/families.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/families/families.controller.ts @@ -136,4 +136,132 @@ export class FamiliesController { message: 'Member removed successfully', }; } + + @Post(':id/share-code') + async generateShareCode(@Req() req: any, @Param('id') familyId: string) { + const result = await this.familiesService.generateShareCode( + familyId, + req.user.userId, + ); + + return { + success: true, + data: result, + }; + } + + /** + * Generate role-based invite code + * POST /api/v1/families/:id/invite-codes + * Body: { role: 'parent' | 'caregiver' | 'viewer', expiryDays?: number } + */ + @Post(':id/invite-codes') + async generateRoleInviteCode( + @Req() req: any, + @Param('id') familyId: string, + @Body() body: { role: FamilyRole; expiryDays?: number }, + ) { + const result = await this.familiesService.generateRoleInviteCode( + familyId, + req.user.userId, + body.role, + body.expiryDays, + ); + + return { + success: true, + data: result, + }; + } + + /** + * Get all active role-based invite codes for a family + * GET /api/v1/families/:id/invite-codes + */ + @Get(':id/invite-codes') + async getRoleInviteCodes(@Req() req: any, @Param('id') familyId: string) { + const codes = await this.familiesService.getRoleInviteCodes( + familyId, + req.user.userId, + ); + + return { + success: true, + data: codes, + }; + } + + /** + * Join family using role-based invite code + * POST /api/v1/families/join-with-role + * Body: { inviteCode: string } + */ + @Post('join-with-role') + async joinFamilyWithRoleCode( + @Req() req: any, + @Body() body: { inviteCode: string }, + ) { + const member = await this.familiesService.joinFamilyWithRoleCode( + req.user.userId, + body.inviteCode, + ); + + return { + success: true, + data: { + member, + message: 'Successfully joined family with assigned role', + }, + }; + } + + /** + * Revoke a role-based invite code + * DELETE /api/v1/families/:id/invite-codes/:role + */ + @Delete(':id/invite-codes/:role') + @HttpCode(HttpStatus.OK) + async revokeRoleInviteCode( + @Req() req: any, + @Param('id') familyId: string, + @Param('role') role: FamilyRole, + ) { + await this.familiesService.revokeRoleInviteCode( + familyId, + req.user.userId, + role, + ); + + return { + success: true, + message: `${role} invite code revoked successfully`, + }; + } + + /** + * Send email invite with role-based invite code + * POST /api/v1/families/:id/email-invite + * Body: { email: string, role: 'parent' | 'caregiver' | 'viewer' } + */ + @Post(':id/email-invite') + async sendEmailInvite( + @Req() req: any, + @Param('id') familyId: string, + @Body() body: { email: string; role: FamilyRole }, + ) { + const result = await this.familiesService.sendEmailInvite( + familyId, + req.user.userId, + body.email, + body.role, + ); + + return { + success: true, + data: result, + message: result.emailSent + ? `Invite email sent successfully to ${body.email}` + : `Invite code generated but email could not be sent`, + }; + } } diff --git a/maternal-app/maternal-app-backend/src/modules/families/families.service.ts b/maternal-app/maternal-app-backend/src/modules/families/families.service.ts index 0ff5a10..da398dd 100644 --- a/maternal-app/maternal-app-backend/src/modules/families/families.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/families/families.service.ts @@ -15,6 +15,7 @@ import { import { User } from '../../database/entities/user.entity'; import { InviteFamilyMemberDto } from './dto/invite-family-member.dto'; import { JoinFamilyDto } from './dto/join-family.dto'; +import { EmailService } from '../../common/services/email.service'; @Injectable() export class FamiliesService { @@ -25,6 +26,7 @@ export class FamiliesService { private familyMemberRepository: Repository, @InjectRepository(User) private userRepository: Repository, + private emailService: EmailService, ) {} async inviteMember( @@ -101,6 +103,14 @@ export class FamiliesService { throw new NotFoundException('Invalid share code'); } + // Check if code is expired + if ( + family.shareCodeExpiresAt && + family.shareCodeExpiresAt < new Date() + ) { + throw new BadRequestException('This family code has expired'); + } + // Check if user is already a member const existingMember = await this.familyMemberRepository.findOne({ where: { userId, familyId: family.id }, @@ -276,4 +286,412 @@ export class FamiliesService { await this.familyMemberRepository.remove(targetMember); } + + async generateShareCode( + familyId: string, + userId: string, + ): Promise<{ code: string; expiresAt: Date }> { + // Verify user is family admin/parent + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership || membership.role !== FamilyRole.PARENT) { + throw new ForbiddenException( + 'Only family admins can generate share codes', + ); + } + + // Get family + const family = await this.familyRepository.findOne({ + where: { id: familyId }, + }); + + if (!family) { + throw new NotFoundException('Family not found'); + } + + // Generate random code (8 characters) + const code = this.generateRandomCode(8); + + // Set expiration (7 days from now) + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + // Update family record + family.shareCode = code; + family.shareCodeExpiresAt = expiresAt; + + await this.familyRepository.save(family); + + return { code, expiresAt }; + } + + private generateRandomCode(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < length; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + } + + /** + * Generate role-based invite code + * Returns a unique invite code for a specific role with expiration + */ + async generateRoleInviteCode( + familyId: string, + userId: string, + role: FamilyRole, + expiryDays: number = 7, + ): Promise<{ code: string; expiresAt: Date; role: FamilyRole }> { + // Verify user is family admin/parent + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership || membership.role !== FamilyRole.PARENT) { + throw new ForbiddenException( + 'Only family admins can generate invite codes', + ); + } + + // Get family + const family = await this.familyRepository.findOne({ + where: { id: familyId }, + }); + + if (!family) { + throw new NotFoundException('Family not found'); + } + + // Generate random code (8 characters) + const code = this.generateRandomCode(8); + + // Set expiration + const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000); + + // Update family record based on role + switch (role) { + case FamilyRole.PARENT: + family.parentInviteCode = code; + family.parentInviteExpiresAt = expiresAt; + break; + case FamilyRole.CAREGIVER: + family.caregiverInviteCode = code; + family.caregiverInviteExpiresAt = expiresAt; + break; + case FamilyRole.VIEWER: + family.viewerInviteCode = code; + family.viewerInviteExpiresAt = expiresAt; + break; + default: + throw new BadRequestException('Invalid role'); + } + + await this.familyRepository.save(family); + + return { code, expiresAt, role }; + } + + /** + * Get all active role-based invite codes for a family + */ + async getRoleInviteCodes( + familyId: string, + userId: string, + ): Promise<{ + parent?: { code: string; expiresAt: Date }; + caregiver?: { code: string; expiresAt: Date }; + viewer?: { code: string; expiresAt: Date }; + }> { + // Verify user is family admin/parent + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership || membership.role !== FamilyRole.PARENT) { + throw new ForbiddenException( + 'Only family admins can view invite codes', + ); + } + + // Get family + const family = await this.familyRepository.findOne({ + where: { id: familyId }, + select: [ + 'id', + 'parentInviteCode', + 'parentInviteExpiresAt', + 'caregiverInviteCode', + 'caregiverInviteExpiresAt', + 'viewerInviteCode', + 'viewerInviteExpiresAt', + ], + }); + + if (!family) { + throw new NotFoundException('Family not found'); + } + + const now = new Date(); + const result: { + parent?: { code: string; expiresAt: Date }; + caregiver?: { code: string; expiresAt: Date }; + viewer?: { code: string; expiresAt: Date }; + } = {}; + + // Only return codes that are not expired + if ( + family.parentInviteCode && + family.parentInviteExpiresAt && + family.parentInviteExpiresAt > now + ) { + result.parent = { + code: family.parentInviteCode, + expiresAt: family.parentInviteExpiresAt, + }; + } + + if ( + family.caregiverInviteCode && + family.caregiverInviteExpiresAt && + family.caregiverInviteExpiresAt > now + ) { + result.caregiver = { + code: family.caregiverInviteCode, + expiresAt: family.caregiverInviteExpiresAt, + }; + } + + if ( + family.viewerInviteCode && + family.viewerInviteExpiresAt && + family.viewerInviteExpiresAt > now + ) { + result.viewer = { + code: family.viewerInviteCode, + expiresAt: family.viewerInviteExpiresAt, + }; + } + + return result; + } + + /** + * Join family using role-based invite code + * Determines role based on which code was used + */ + async joinFamilyWithRoleCode( + userId: string, + inviteCode: string, + ): Promise { + // Find family by any of the role-based invite codes + const family = await this.familyRepository + .createQueryBuilder('family') + .where('family.parentInviteCode = :code', { code: inviteCode }) + .orWhere('family.caregiverInviteCode = :code', { code: inviteCode }) + .orWhere('family.viewerInviteCode = :code', { code: inviteCode }) + .getOne(); + + if (!family) { + throw new NotFoundException('Invalid invite code'); + } + + // Determine role based on which code matched + let targetRole: FamilyRole; + let codeExpiresAt: Date | null = null; + + if (family.parentInviteCode === inviteCode) { + targetRole = FamilyRole.PARENT; + codeExpiresAt = family.parentInviteExpiresAt || null; + } else if (family.caregiverInviteCode === inviteCode) { + targetRole = FamilyRole.CAREGIVER; + codeExpiresAt = family.caregiverInviteExpiresAt || null; + } else if (family.viewerInviteCode === inviteCode) { + targetRole = FamilyRole.VIEWER; + codeExpiresAt = family.viewerInviteExpiresAt || null; + } else { + throw new NotFoundException('Invalid invite code'); + } + + // Check if code is expired + if (codeExpiresAt && codeExpiresAt < new Date()) { + throw new BadRequestException('This invite code has expired'); + } + + // Check if user is already a member + const existingMember = await this.familyMemberRepository.findOne({ + where: { userId, familyId: family.id }, + }); + + if (existingMember) { + throw new ConflictException('You are already a member of this family'); + } + + // Check family size limit + const memberCount = await this.familyMemberRepository.count({ + where: { familyId: family.id }, + }); + + if (memberCount >= 10) { + throw new BadRequestException( + 'Family has reached maximum member limit (10)', + ); + } + + // Set permissions based on role + let permissions: any; + switch (targetRole) { + case FamilyRole.PARENT: + permissions = { + canAddChildren: true, + canEditChildren: true, + canLogActivities: true, + canViewReports: true, + canInviteMembers: true, + }; + break; + case FamilyRole.CAREGIVER: + permissions = { + canAddChildren: false, + canEditChildren: true, + canLogActivities: true, + canViewReports: true, + canInviteMembers: false, + }; + break; + case FamilyRole.VIEWER: + permissions = { + canAddChildren: false, + canEditChildren: false, + canLogActivities: false, + canViewReports: true, + canInviteMembers: false, + }; + break; + } + + // Add user as member with determined role + const newMember = this.familyMemberRepository.create({ + userId, + familyId: family.id, + role: targetRole, + permissions, + }); + + return await this.familyMemberRepository.save(newMember); + } + + /** + * Revoke a role-based invite code + */ + async revokeRoleInviteCode( + familyId: string, + userId: string, + role: FamilyRole, + ): Promise { + // Verify user is family admin/parent + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership || membership.role !== FamilyRole.PARENT) { + throw new ForbiddenException( + 'Only family admins can revoke invite codes', + ); + } + + // Get family + const family = await this.familyRepository.findOne({ + where: { id: familyId }, + }); + + if (!family) { + throw new NotFoundException('Family not found'); + } + + // Clear code based on role + switch (role) { + case FamilyRole.PARENT: + family.parentInviteCode = null; + family.parentInviteExpiresAt = null; + break; + case FamilyRole.CAREGIVER: + family.caregiverInviteCode = null; + family.caregiverInviteExpiresAt = null; + break; + case FamilyRole.VIEWER: + family.viewerInviteCode = null; + family.viewerInviteExpiresAt = null; + break; + default: + throw new BadRequestException('Invalid role'); + } + + await this.familyRepository.save(family); + } + + /** + * Send email invite with role-based invite code + */ + async sendEmailInvite( + familyId: string, + userId: string, + recipientEmail: string, + role: FamilyRole, + ): Promise<{ code: string; expiresAt: Date; emailSent: boolean }> { + // Verify user is family admin/parent + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + relations: ['user'], + }); + + if (!membership || membership.role !== FamilyRole.PARENT) { + throw new ForbiddenException( + 'Only family admins can send email invites', + ); + } + + // Get family with full details + const family = await this.familyRepository.findOne({ + where: { id: familyId }, + }); + + if (!family) { + throw new NotFoundException('Family not found'); + } + + // Generate or get existing role-based invite code + const inviteData = await this.generateRoleInviteCode( + familyId, + userId, + role, + 7, // 7 days expiry + ); + + // Send email invite + let emailSent = false; + try { + await this.emailService.sendFamilyInviteEmail( + recipientEmail, + membership.user.name || 'A family member', + family.name || 'My Family', + role, + inviteData.code, + inviteData.expiresAt, + ); + emailSent = true; + } catch (error) { + // Log error but don't fail the request + // The invite code is still valid even if email fails + console.error('Failed to send invite email:', error); + } + + return { + code: inviteData.code, + expiresAt: inviteData.expiresAt, + emailSent, + }; + } } diff --git a/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.entity.ts b/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.entity.ts index 39df7c5..7c35007 100644 --- a/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.entity.ts +++ b/maternal-app/maternal-app-backend/src/modules/invite-codes/invite-codes.entity.ts @@ -1,5 +1,7 @@ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { User } from '../../database/entities/user.entity'; +import { Family } from '../../database/entities/family.entity'; +import { FamilyRole } from '../../database/entities/family-member.entity'; @Entity('invite_codes') export class InviteCode { @@ -16,8 +18,19 @@ export class InviteCode { @JoinColumn({ name: 'created_by' }) creator?: User; - @Column({ default: 0 }) - uses: number; + // Family-specific invite codes + @Column({ name: 'family_id', nullable: true, length: 20 }) + familyId: string | null; + + @ManyToOne(() => Family, { nullable: true }) + @JoinColumn({ name: 'family_id' }) + family?: Family; + + @Column({ type: 'varchar', length: 20, nullable: true }) + role: FamilyRole | null; + + @Column({ name: 'use_count', default: 0 }) + useCount: number; @Column({ name: 'max_uses', nullable: true }) maxUses: number | null; diff --git a/maternal-app/maternal-app-backend/src/modules/users/dto/update-onboarding-progress.dto.ts b/maternal-app/maternal-app-backend/src/modules/users/dto/update-onboarding-progress.dto.ts new file mode 100644 index 0000000..36e14ce --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/users/dto/update-onboarding-progress.dto.ts @@ -0,0 +1,21 @@ +import { IsNumber, IsObject, IsOptional, Min, Max } from 'class-validator'; + +export class UpdateOnboardingProgressDto { + @IsNumber() + @Min(0) + @Max(4) + step: number; + + @IsOptional() + @IsObject() + data?: { + selectedLanguage?: string; + selectedMeasurement?: 'metric' | 'imperial'; + familyChoice?: 'create' | 'join'; + childData?: { + name?: string; + birthDate?: string; + gender?: string; + }; + }; +} diff --git a/maternal-app/maternal-app-backend/src/modules/users/user-preferences.controller.ts b/maternal-app/maternal-app-backend/src/modules/users/user-preferences.controller.ts index 85bd066..71110eb 100644 --- a/maternal-app/maternal-app-backend/src/modules/users/user-preferences.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/users/user-preferences.controller.ts @@ -15,6 +15,7 @@ import { NotificationPreferences, } from './user-preferences.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UpdateOnboardingProgressDto } from './dto/update-onboarding-progress.dto'; @Controller('api/v1/preferences') @UseGuards(JwtAuthGuard) @@ -114,4 +115,72 @@ export class UserPreferencesController { const userId = req.user?.userId; return this.userPreferencesService.resetToDefaults(userId); } + + /** + * Get onboarding status + */ + @Get('onboarding') + @HttpCode(HttpStatus.OK) + async getOnboardingStatus(@Req() req: any): Promise<{ + success: boolean; + data: { + completed: boolean; + step: number; + data: any; + }; + }> { + const userId = req.user?.userId; + const status = await this.userPreferencesService.getOnboardingStatus( + userId, + ); + return { + success: true, + data: status, + }; + } + + /** + * Update onboarding progress + */ + @Put('onboarding/progress') + @HttpCode(HttpStatus.OK) + async updateOnboardingProgress( + @Req() req: any, + @Body() dto: UpdateOnboardingProgressDto, + ): Promise<{ + success: boolean; + data: { + user: any; + }; + }> { + const userId = req.user?.userId; + const user = await this.userPreferencesService.updateOnboardingProgress( + userId, + dto.step, + dto.data, + ); + return { + success: true, + data: { user }, + }; + } + + /** + * Mark onboarding as complete + */ + @Post('onboarding/complete') + @HttpCode(HttpStatus.OK) + async completeOnboarding(@Req() req: any): Promise<{ + success: boolean; + data: { + user: any; + }; + }> { + const userId = req.user?.userId; + const user = await this.userPreferencesService.completeOnboarding(userId); + return { + success: true, + data: { user }, + }; + } } diff --git a/maternal-app/maternal-app-backend/src/modules/users/user-preferences.service.ts b/maternal-app/maternal-app-backend/src/modules/users/user-preferences.service.ts index f3f77b3..febf70d 100644 --- a/maternal-app/maternal-app-backend/src/modules/users/user-preferences.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/users/user-preferences.service.ts @@ -237,4 +237,85 @@ export class UserPreferencesService { return defaultPreferences; } + + /** + * Update onboarding progress for a user + */ + async updateOnboardingProgress( + userId: string, + step: number, + data?: any, + ): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Merge new data with existing onboarding data + const updatedData = { + ...user.onboardingData, + ...data, + }; + + await this.userRepository.update(userId, { + onboardingStep: step, + onboardingData: updatedData as any, + }); + + this.logger.log( + `Updated onboarding progress for user ${userId} - step ${step}`, + ); + + return this.userRepository.findOne({ where: { id: userId } }); + } + + /** + * Mark onboarding as complete for a user + */ + async completeOnboarding(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + await this.userRepository.update(userId, { + onboardingCompleted: true, + onboardingStep: 4, // Final step + onboardingData: null, // Clear temporary data + }); + + this.logger.log(`Completed onboarding for user ${userId}`); + + return this.userRepository.findOne({ where: { id: userId } }); + } + + /** + * Get onboarding status for a user + */ + async getOnboardingStatus(userId: string): Promise<{ + completed: boolean; + step: number; + data: any; + }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: ['id', 'onboardingCompleted', 'onboardingStep', 'onboardingData'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return { + completed: user.onboardingCompleted || false, + step: user.onboardingStep || 0, + data: user.onboardingData || {}, + }; + } } diff --git a/maternal-app/maternal-app-backend/src/schema.gql b/maternal-app/maternal-app-backend/src/schema.gql index 6ba298c..4fb774e 100644 --- a/maternal-app/maternal-app-backend/src/schema.gql +++ b/maternal-app/maternal-app-backend/src/schema.gql @@ -96,6 +96,7 @@ type FamilyMember { enum FamilyRole { CAREGIVER PARENT + VIEWER } enum Gender { diff --git a/maternal-web/app/(auth)/onboarding/page.tsx b/maternal-web/app/(auth)/onboarding/page.tsx index eca0458..c3c8e43 100644 --- a/maternal-web/app/(auth)/onboarding/page.tsx +++ b/maternal-web/app/(auth)/onboarding/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Stepper, @@ -38,8 +38,10 @@ import { supportedLanguages } from '@/lib/i18n/config'; import { usersApi } from '@/lib/api/users'; import { useTheme } from '@mui/material/styles'; import { StepIconProps } from '@mui/material/StepIcon'; +import { FamilySetupStep, ChildData } from '@/components/onboarding/FamilySetupStep'; +import { familiesApi } from '@/lib/api/families'; -const steps = ['Welcome', 'Language', 'Measurements', 'Add Child', 'Complete']; +const steps = ['Welcome', 'Preferences', 'Family Setup', 'Complete']; // Custom connector for mobile-friendly stepper const CustomConnector = styled(StepConnector)(({ theme }) => ({ @@ -105,47 +107,66 @@ export default function OnboardingPage() { const { t } = useTranslation('onboarding'); const theme = useTheme(); + // Restore onboarding progress on mount + useEffect(() => { + const restoreProgress = async () => { + try { + if (user?.id) { + const status = await usersApi.getOnboardingStatus(); + + // If onboarding is completed, redirect to home + if (status.completed) { + router.push('/'); + return; + } + + // If user has saved progress, restore it + if (status.step > 0) { + setActiveStep(status.step); + + if (status.data?.selectedLanguage) { + setSelectedLanguage(status.data.selectedLanguage); + } + + if (status.data?.selectedMeasurement) { + setSelectedMeasurement(status.data.selectedMeasurement); + } + } + } + } catch (err) { + console.error('Failed to restore onboarding progress:', err); + // Continue with default state if restore fails + } + }; + + restoreProgress(); + }, [user?.id, router]); + const handleNext = async () => { setError(''); - - // Step 1: Save language preference + + // Step 1: Save preferences (language + measurement) if (activeStep === 1) { try { setLoading(true); await setLanguage(selectedLanguage); - // Save to backend - if (user?.id) { - await usersApi.updatePreferences({ - language: selectedLanguage, - }); - } - setActiveStep((prevActiveStep) => prevActiveStep + 1); - } catch (err: any) { - console.error('Failed to save language:', err); - // Language was saved locally even if backend failed, so continue - setActiveStep((prevActiveStep) => prevActiveStep + 1); - } finally { - setLoading(false); - } - return; - } - - // Step 2: Save measurement preference - if (activeStep === 2) { - try { - setLoading(true); setMeasurementSystem(selectedMeasurement); - // Save to backend + + // Save progress to backend if (user?.id) { - await usersApi.updatePreferences({ - measurementUnit: selectedMeasurement, + await usersApi.updateOnboardingProgress({ + step: activeStep + 1, + data: { + selectedLanguage, + selectedMeasurement, + }, }); await refreshUser(); } setActiveStep((prevActiveStep) => prevActiveStep + 1); } catch (err: any) { - console.error('Failed to save measurement:', err); - // Measurement was saved locally even if backend failed, so continue + console.error('Failed to save preferences:', err); + // Preferences were saved locally even if backend failed, so continue setActiveStep((prevActiveStep) => prevActiveStep + 1); } finally { setLoading(false); @@ -153,41 +174,27 @@ export default function OnboardingPage() { return; } - // Step 3: Validate and save child data (Add Child) - if (activeStep === 3) { - if (!childName.trim() || !childBirthDate) { - setError(t('child.name') + ' and ' + t('child.dateOfBirth') + ' are required'); - return; - } - - const familyId = user?.families?.[0]?.familyId; - if (!familyId) { - console.error('No family found. User object:', JSON.stringify(user, null, 2)); - setError('Unable to create child profile. Your account setup is incomplete. Please contact support or try logging out and back in.'); - return; - } - - try { - setLoading(true); - setError(''); - await childrenApi.createChild(familyId, { - name: childName.trim(), - birthDate: childBirthDate, - gender: childGender, - }); - setActiveStep((prevActiveStep) => prevActiveStep + 1); - } catch (err: any) { - console.error('Failed to create child:', err); - setError(err.response?.data?.message || 'Failed to save child. Please try again.'); - } finally { - setLoading(false); - } + // Step 2: Family Setup - This is now handled by FamilySetupStep component + if (activeStep === 2) { + // The component handles the logic internally via onCreateFamily or onJoinFamily return; } if (activeStep === steps.length - 1) { // Complete onboarding - router.push('/'); + try { + setLoading(true); + if (user?.id) { + await usersApi.completeOnboarding(); + } + router.push('/'); + } catch (err) { + console.error('Failed to mark onboarding complete:', err); + // Navigate anyway since onboarding flow is done + router.push('/'); + } finally { + setLoading(false); + } } else { setActiveStep((prevActiveStep) => prevActiveStep + 1); } @@ -201,6 +208,50 @@ export default function OnboardingPage() { router.push('/'); }; + const handleCreateFamily = async (childData: ChildData) => { + try { + setLoading(true); + setError(''); + + // Create a new family for the user + const createdFamily = await familiesApi.createFamily({ + name: `${user?.name || 'My'}'s Family`, + }); + + await refreshUser(); // Refresh to get new family membership + + // Add the first child to the new family + await childrenApi.createChild(createdFamily.id, { + name: childData.name.trim(), + birthDate: childData.birthDate, + gender: childData.gender, + }); + + setActiveStep((prev) => prev + 1); + } catch (err: any) { + console.error('Failed to create family:', err); + setError(err.response?.data?.message || 'Failed to create family. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleJoinFamily = async (shareCode: string) => { + try { + setLoading(true); + setError(''); + + await familiesApi.joinFamily({ shareCode }); + await refreshUser(); // Refresh to get new family membership + setActiveStep((prev) => prev + 1); + } catch (err: any) { + console.error('Failed to join family:', err); + setError(err.response?.data?.message || 'Failed to join family. Please check the code and try again.'); + } finally { + setLoading(false); + } + }; + return ( )} - {/* Step 1: Language Selection */} + {/* Step 1: Preferences (Language + Measurements) */} {activeStep === 1 && ( - - - - - - {t('language.title')} - - - {t('language.subtitle')} - - - - - {error && ( - - {error} - - )} - - - {supportedLanguages.map((lang) => ( - - - setSelectedLanguage(lang.code)} - sx={{ p: 2 }} - > - - - - {lang.nativeName} - - - {lang.name} - - - - - - - - ))} - - - - {t('language.description')} - - - )} - - {/* Step 2: Measurement System */} - {activeStep === 2 && ( - - - - - - {t('measurements.title')} - - - {t('measurements.subtitle')} - - - - - {error && ( - - {error} - - )} - - - - - setSelectedMeasurement('metric')} - sx={{ p: 3, height: '100%' }} - > - - - - {t('measurements.metric.title')} - - - {t('measurements.metric.description')} - - - - - - - - - setSelectedMeasurement('imperial')} - sx={{ p: 3, height: '100%' }} - > - - - - {t('measurements.imperial.title')} - - - {t('measurements.imperial.description')} - - - - - - - - - {t('measurements.description')} - - - )} - - {/* Step 3: Add Child */} - {activeStep === 3 && ( - {t('child.title')} + {t('preferences.title') || 'Preferences'} - {t('child.subtitle')} + {t('preferences.subtitle') || 'Set your language and measurement preferences'} {error && ( @@ -461,61 +361,59 @@ export default function OnboardingPage() { )} + {/* Language Dropdown */} setChildName(e.target.value)} - margin="normal" - required - disabled={loading} - InputProps={{ - sx: { borderRadius: 3 }, - }} - /> - - setChildBirthDate(e.target.value)} - margin="normal" - required - disabled={loading} - InputLabelProps={{ - shrink: true, - }} - InputProps={{ - sx: { borderRadius: 3 }, - }} - /> - - setChildGender(e.target.value as 'male' | 'female' | 'other')} + fullWidth + label={t('language.title') || 'Language'} + value={selectedLanguage} + onChange={(e) => setSelectedLanguage(e.target.value)} margin="normal" - disabled={loading} - InputProps={{ - sx: { borderRadius: 3 }, - }} + sx={{ mb: 2 }} > - {t('child.genders.male')} - {t('child.genders.female')} - {t('child.genders.preferNotToSay')} + {supportedLanguages.map((lang) => ( + + {lang.nativeName} ({lang.name}) + + ))} + + + {/* Measurement Dropdown */} + setSelectedMeasurement(e.target.value as 'metric' | 'imperial')} + margin="normal" + > + + {t('measurements.metric.title') || 'Metric'} (kg, cm, °C) + + + {t('measurements.imperial.title') || 'Imperial'} (lbs, inches, °F) + - {t('child.skipForNow')} + {t('preferences.description') || 'You can change these settings later in your profile.'} )} - {/* Step 4: Complete */} - {activeStep === 4 && ( + {/* Step 2: Family Setup */} + {activeStep === 2 && ( + + )} + + {/* Step 3: Complete */} + {activeStep === 3 && ( + + {/* Role-Based Invites - Only visible to parents */} + {isParent && family && ( + + setSnackbar({ open: true, message })} + onError={(message) => setError(message)} + /> + + )} )} diff --git a/maternal-web/components/family/RoleInvitesSection.tsx b/maternal-web/components/family/RoleInvitesSection.tsx new file mode 100644 index 0000000..11ddbdb --- /dev/null +++ b/maternal-web/components/family/RoleInvitesSection.tsx @@ -0,0 +1,367 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Box, + Paper, + Typography, + Button, + Chip, + IconButton, + Tooltip, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + CircularProgress, + Accordion, + AccordionSummary, + AccordionDetails, + Stack, +} from '@mui/material'; +import { + ContentCopy, + Refresh, + Delete, + Email, + ExpandMore, + Info, +} from '@mui/icons-material'; +import { familiesApi } from '@/lib/api/families'; + +interface RoleInvitesSectionProps { + familyId: string; + onSuccess?: (message: string) => void; + onError?: (message: string) => void; +} + +interface RoleCode { + code: string; + expiresAt: Date; +} + +interface RoleCodes { + parent?: RoleCode; + caregiver?: RoleCode; + viewer?: RoleCode; +} + +const roleInfo = { + parent: { + icon: '👨‍👩‍👧', + label: 'Parent', + description: 'Full access - manage children, log activities, view reports, and invite others', + color: 'primary' as const, + }, + caregiver: { + icon: '🤝', + label: 'Caregiver', + description: 'Can edit children, log activities, and view reports (cannot add children or invite others)', + color: 'secondary' as const, + }, + viewer: { + icon: '👁️', + label: 'Viewer', + description: 'View-only access - can view reports but cannot log activities', + color: 'info' as const, + }, +}; + +export function RoleInvitesSection({ familyId, onSuccess, onError }: RoleInvitesSectionProps) { + const [roleCodes, setRoleCodes] = useState({}); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [emailDialogOpen, setEmailDialogOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState<'parent' | 'caregiver' | 'viewer' | null>(null); + const [recipientEmail, setRecipientEmail] = useState(''); + const [emailSending, setEmailSending] = useState(false); + + useEffect(() => { + fetchRoleCodes(); + }, [familyId]); + + const fetchRoleCodes = async () => { + try { + setLoading(true); + const codes = await familiesApi.getRoleInviteCodes(familyId); + setRoleCodes(codes); + } catch (err: any) { + console.error('Failed to fetch role codes:', err); + onError?.('Failed to load invite codes'); + } finally { + setLoading(false); + } + }; + + const handleGenerateCode = async (role: 'parent' | 'caregiver' | 'viewer') => { + try { + setActionLoading(role); + const result = await familiesApi.generateRoleInviteCode(familyId, role, 7); + setRoleCodes(prev => ({ + ...prev, + [role]: { code: result.code, expiresAt: result.expiresAt }, + })); + onSuccess?.(`${roleInfo[role].label} invite code generated`); + } catch (err: any) { + console.error('Failed to generate code:', err); + onError?.('Failed to generate invite code'); + } finally { + setActionLoading(null); + } + }; + + const handleCopyCode = async (code: string, role: string) => { + try { + await navigator.clipboard.writeText(code); + onSuccess?.(`${roleInfo[role as keyof typeof roleInfo].label} code copied to clipboard`); + } catch (err) { + onError?.('Failed to copy code'); + } + }; + + const handleRevokeCode = async (role: 'parent' | 'caregiver' | 'viewer') => { + try { + setActionLoading(role); + await familiesApi.revokeRoleInviteCode(familyId, role); + setRoleCodes(prev => { + const updated = { ...prev }; + delete updated[role]; + return updated; + }); + onSuccess?.(`${roleInfo[role].label} invite code revoked`); + } catch (err: any) { + console.error('Failed to revoke code:', err); + onError?.('Failed to revoke invite code'); + } finally { + setActionLoading(null); + } + }; + + const handleOpenEmailDialog = (role: 'parent' | 'caregiver' | 'viewer') => { + setSelectedRole(role); + setRecipientEmail(''); + setEmailDialogOpen(true); + }; + + const handleSendEmailInvite = async () => { + if (!selectedRole || !recipientEmail) return; + + try { + setEmailSending(true); + const result = await familiesApi.sendEmailInvite(familyId, recipientEmail, selectedRole); + + // Update the codes + setRoleCodes(prev => ({ + ...prev, + [selectedRole]: { code: result.code, expiresAt: result.expiresAt }, + })); + + if (result.emailSent) { + onSuccess?.(`Invite email sent to ${recipientEmail}`); + } else { + onSuccess?.('Invite code generated, but email could not be sent'); + } + + setEmailDialogOpen(false); + setRecipientEmail(''); + setSelectedRole(null); + } catch (err: any) { + console.error('Failed to send email invite:', err); + onError?.(err.response?.data?.message || 'Failed to send email invite'); + } finally { + setEmailSending(false); + } + }; + + const formatExpiryDate = (expiresAt: Date) => { + const date = new Date(expiresAt); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) return 'Expired'; + if (diffDays === 0) return 'Expires today'; + if (diffDays === 1) return 'Expires tomorrow'; + return `Expires in ${diffDays} days`; + }; + + const renderRoleCodeCard = (role: 'parent' | 'caregiver' | 'viewer') => { + const info = roleInfo[role]; + const codeData = roleCodes[role]; + const isLoading = actionLoading === role; + + return ( + + } + sx={{ + borderRadius: 2, + '&.Mui-expanded': { borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }, + }} + > + + {info.icon} + + + {info.label} + + + {codeData ? formatExpiryDate(codeData.expiresAt) : 'No active code'} + + + {codeData && ( + + )} + + + + + + {info.description} + + + {codeData ? ( + + + + + + handleRevokeCode(role)} + disabled={isLoading} + > + + + + + ) : ( + + )} + + + + ); + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + <> + + + + Role-Based Invites + + + + + + + + + + Create role-specific invite codes that automatically assign permissions when used. + + + + {renderRoleCodeCard('parent')} + {renderRoleCodeCard('caregiver')} + {renderRoleCodeCard('viewer')} + + + + {/* Email Invite Dialog */} + setEmailDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + Send {selectedRole && roleInfo[selectedRole].icon} {selectedRole && roleInfo[selectedRole].label} Invite via Email + + + + Send an invitation email with the {selectedRole} invite code + + setRecipientEmail(e.target.value)} + placeholder="name@example.com" + autoFocus + sx={{ mt: 1 }} + /> + + + + + + + + ); +} diff --git a/maternal-web/components/onboarding/FamilySetupStep.tsx b/maternal-web/components/onboarding/FamilySetupStep.tsx new file mode 100644 index 0000000..68acc51 --- /dev/null +++ b/maternal-web/components/onboarding/FamilySetupStep.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useState } from 'react'; +import { + Box, + Card, + CardActionArea, + CardContent, + Typography, + TextField, + Button, + Alert, + MenuItem, +} from '@mui/material'; +import { FamilyRestroom, PersonAdd } from '@mui/icons-material'; + +export interface ChildData { + name: string; + birthDate: string; + gender: 'male' | 'female' | 'other'; +} + +interface FamilySetupStepProps { + onCreateFamily: (childData: ChildData) => Promise; + onJoinFamily: (shareCode: string) => Promise; + loading: boolean; + error: string; + t: (key: string) => string; +} + +export function FamilySetupStep({ + onCreateFamily, + onJoinFamily, + loading, + error, + t, +}: FamilySetupStepProps) { + const [choice, setChoice] = useState<'create' | 'join' | null>(null); + const [shareCode, setShareCode] = useState(''); + const [childName, setChildName] = useState(''); + const [childBirthDate, setChildBirthDate] = useState(''); + const [childGender, setChildGender] = useState<'male' | 'female' | 'other'>('other'); + + if (choice === 'create') { + return ( + + + {t('child.title')} + + + {t('child.subtitle')} + + + {error && ( + + {error} + + )} + + setChildName(e.target.value)} + margin="normal" + required + disabled={loading} + InputProps={{ + sx: { borderRadius: 3 }, + }} + /> + + setChildBirthDate(e.target.value)} + margin="normal" + required + disabled={loading} + InputLabelProps={{ + shrink: true, + }} + InputProps={{ + sx: { borderRadius: 3 }, + }} + /> + + setChildGender(e.target.value as 'male' | 'female' | 'other')} + margin="normal" + disabled={loading} + InputProps={{ + sx: { borderRadius: 3 }, + }} + > + {t('child.genders.male')} + {t('child.genders.female')} + {t('child.genders.preferNotToSay')} + + + + + + + + ); + } + + if (choice === 'join') { + return ( + + + Join Existing Family + + + Enter the family code to join an existing family + + + {error && ( + + {error} + + )} + + setShareCode(e.target.value.toUpperCase())} + margin="normal" + placeholder="ABC123DEF" + helperText="Ask your family admin for the code" + required + disabled={loading} + InputProps={{ + sx: { borderRadius: 3 }, + }} + /> + + + You'll be added as a viewer by default. The admin can change your role later. + + + + + + + + ); + } + + return ( + + + Choose Your Setup + + + Start tracking or join an existing family + + + {error && ( + + {error} + + )} + + + + setChoice('create')} sx={{ p: 2 }}> + + + + + + Create New Family + + + Start tracking for your child. Invite family members later. + + + + + + + + + setChoice('join')} sx={{ p: 2 }}> + + + + + + Join Existing Family + + + Enter family code to join. Access shared tracking. + + + + + + + + + ); +} diff --git a/maternal-web/lib/api/families.ts b/maternal-web/lib/api/families.ts index 7fe7a78..031774e 100644 --- a/maternal-web/lib/api/families.ts +++ b/maternal-web/lib/api/families.ts @@ -32,6 +32,12 @@ export interface JoinFamilyData { } export const familiesApi = { + // Create a new family + createFamily: async (data: { name: string }): Promise => { + const response = await apiClient.post('/api/v1/families', data); + return response.data.data.family; + }, + // Get a specific family getFamily: async (familyId: string): Promise => { const response = await apiClient.get(`/api/v1/families/${familyId}`); @@ -66,4 +72,57 @@ export const familiesApi = { removeMember: async (familyId: string, userId: string): Promise => { await apiClient.delete(`/api/v1/families/${familyId}/members/${userId}`); }, + + // Generate a new share code for the family + generateShareCode: async (familyId: string): Promise<{ code: string; expiresAt: Date }> => { + const response = await apiClient.post(`/api/v1/families/${familyId}/share-code`); + return response.data.data; + }, + + // Generate role-based invite code + generateRoleInviteCode: async ( + familyId: string, + role: 'parent' | 'caregiver' | 'viewer', + expiryDays?: number + ): Promise<{ code: string; expiresAt: Date; role: string }> => { + const response = await apiClient.post(`/api/v1/families/${familyId}/invite-codes`, { + role, + expiryDays, + }); + return response.data.data; + }, + + // Get all active role-based invite codes + getRoleInviteCodes: async (familyId: string): Promise<{ + parent?: { code: string; expiresAt: Date }; + caregiver?: { code: string; expiresAt: Date }; + viewer?: { code: string; expiresAt: Date }; + }> => { + const response = await apiClient.get(`/api/v1/families/${familyId}/invite-codes`); + return response.data.data; + }, + + // Join family with role-based invite code + joinFamilyWithRoleCode: async (inviteCode: string): Promise => { + const response = await apiClient.post('/api/v1/families/join-with-role', { inviteCode }); + return response.data.data.member; + }, + + // Revoke a role-based invite code + revokeRoleInviteCode: async (familyId: string, role: 'parent' | 'caregiver' | 'viewer'): Promise => { + await apiClient.delete(`/api/v1/families/${familyId}/invite-codes/${role}`); + }, + + // Send email invite with role-based code + sendEmailInvite: async ( + familyId: string, + email: string, + role: 'parent' | 'caregiver' | 'viewer' + ): Promise<{ code: string; expiresAt: Date; emailSent: boolean }> => { + const response = await apiClient.post(`/api/v1/families/${familyId}/email-invite`, { + email, + role, + }); + return response.data.data; + }, }; diff --git a/maternal-web/lib/api/users.ts b/maternal-web/lib/api/users.ts index a6f78bf..321cb18 100644 --- a/maternal-web/lib/api/users.ts +++ b/maternal-web/lib/api/users.ts @@ -23,6 +23,26 @@ export interface UserProfile { emailVerified: boolean; preferences?: UserPreferences; families?: string[]; + onboardingCompleted?: boolean; + onboardingStep?: number; + onboardingData?: OnboardingData; +} + +export interface OnboardingData { + selectedLanguage?: string; + selectedMeasurement?: 'metric' | 'imperial'; + familyChoice?: 'create' | 'join'; + childData?: { + name?: string; + birthDate?: string; + gender?: string; + }; +} + +export interface OnboardingStatus { + completed: boolean; + step: number; + data: OnboardingData; } export const usersApi = { @@ -31,4 +51,22 @@ export const usersApi = { const response = await apiClient.patch('/api/v1/auth/profile', data); return response.data.data; }, + + // Get onboarding status + getOnboardingStatus: async (): Promise => { + const response = await apiClient.get('/api/v1/preferences/onboarding'); + return response.data.data; + }, + + // Update onboarding progress + updateOnboardingProgress: async (data: { step: number; data?: OnboardingData }): Promise => { + const response = await apiClient.put('/api/v1/preferences/onboarding/progress', data); + return response.data.data.user; + }, + + // Mark onboarding as complete + completeOnboarding: async (): Promise => { + const response = await apiClient.post('/api/v1/preferences/onboarding/complete'); + return response.data.data.user; + }, }; diff --git a/maternal-web/public/sw.js b/maternal-web/public/sw.js index 97f9294..25f6bab 100644 --- a/maternal-web/public/sw.js +++ b/maternal-web/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,a={};const s=(s,c)=>(s=new URL(s+".js",c).href,a[s]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=a,document.head.appendChild(e)}else e=s,importScripts(s),a()}).then(()=>{let e=a[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(c,i)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(a[n])return;let t={};const d=e=>s(e,n),f={module:{uri:n},exports:t,require:d};a[n]=Promise.all(c.map(e=>f[e]||d(e))).then(e=>(i(...e),t))}}define(["./workbox-4d767a27"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"d6c86768dc57bf65a4d6eb1d3d1a1fdd"},{url:"/_next/static/39AHXX_NOouP2Oz0P0jez/_buildManifest.js",revision:"673df67655213af81147283455f8956d"},{url:"/_next/static/39AHXX_NOouP2Oz0P0jez/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1091.c762d795c6885f94.js",revision:"c762d795c6885f94"},{url:"/_next/static/chunks/1188.88c14dc0b9d46cf9.js",revision:"88c14dc0b9d46cf9"},{url:"/_next/static/chunks/1255-b2f7fd83e387a9e1.js",revision:"b2f7fd83e387a9e1"},{url:"/_next/static/chunks/1280-077bbec6d00a7de6.js",revision:"077bbec6d00a7de6"},{url:"/_next/static/chunks/1514-a6ed8a01b9885870.js",revision:"a6ed8a01b9885870"},{url:"/_next/static/chunks/1543-530e0f57f7af68aa.js",revision:"530e0f57f7af68aa"},{url:"/_next/static/chunks/164f4fb6.cb2a48d4da4418c4.js",revision:"cb2a48d4da4418c4"},{url:"/_next/static/chunks/189-453061dd646fdba4.js",revision:"453061dd646fdba4"},{url:"/_next/static/chunks/1930-cd8328eb1cfa4178.js",revision:"cd8328eb1cfa4178"},{url:"/_next/static/chunks/2262-26293d6453fcc927.js",revision:"26293d6453fcc927"},{url:"/_next/static/chunks/2506-35fdf1de6e0fd0ec.js",revision:"35fdf1de6e0fd0ec"},{url:"/_next/static/chunks/2619-04bc32f026a0d946.js",revision:"04bc32f026a0d946"},{url:"/_next/static/chunks/2931.14c1e0fb7788f4ba.js",revision:"14c1e0fb7788f4ba"},{url:"/_next/static/chunks/2f0b94e8.3186a98eb4c9012b.js",revision:"3186a98eb4c9012b"},{url:"/_next/static/chunks/3127-49a95e7cb556ace3.js",revision:"49a95e7cb556ace3"},{url:"/_next/static/chunks/3452-86647d15ff7842a5.js",revision:"86647d15ff7842a5"},{url:"/_next/static/chunks/3460-a2b6a712ec21acfb.js",revision:"a2b6a712ec21acfb"},{url:"/_next/static/chunks/3664-56dedfcaec4aaceb.js",revision:"56dedfcaec4aaceb"},{url:"/_next/static/chunks/3762-a8faf80b78d53eb7.js",revision:"a8faf80b78d53eb7"},{url:"/_next/static/chunks/4199.bc1715114dd19eda.js",revision:"bc1715114dd19eda"},{url:"/_next/static/chunks/4337-7b7eba57ddf5f8f1.js",revision:"7b7eba57ddf5f8f1"},{url:"/_next/static/chunks/4648-2997dba2ece0d119.js",revision:"2997dba2ece0d119"},{url:"/_next/static/chunks/4710-9f9aefe46e6a48d5.js",revision:"9f9aefe46e6a48d5"},{url:"/_next/static/chunks/4bd1b696-100b9d70ed4e49c1.js",revision:"100b9d70ed4e49c1"},{url:"/_next/static/chunks/5125-c990fc036d2a6ce4.js",revision:"c990fc036d2a6ce4"},{url:"/_next/static/chunks/5380-9004e1ac3565daca.js",revision:"9004e1ac3565daca"},{url:"/_next/static/chunks/5385-7ecda8e4ba984edc.js",revision:"7ecda8e4ba984edc"},{url:"/_next/static/chunks/5482-7535aa0aab02d518.js",revision:"7535aa0aab02d518"},{url:"/_next/static/chunks/551.26e2933365d2f96d.js",revision:"26e2933365d2f96d"},{url:"/_next/static/chunks/6088-c165c565edce02be.js",revision:"c165c565edce02be"},{url:"/_next/static/chunks/6181-66be9b76f10d48f6.js",revision:"66be9b76f10d48f6"},{url:"/_next/static/chunks/6183-84d624cdd79749ef.js",revision:"84d624cdd79749ef"},{url:"/_next/static/chunks/658-1d9d4c0c8b5fb129.js",revision:"1d9d4c0c8b5fb129"},{url:"/_next/static/chunks/670-a4ca0f366ee779f5.js",revision:"a4ca0f366ee779f5"},{url:"/_next/static/chunks/6873-ff265086321345c8.js",revision:"ff265086321345c8"},{url:"/_next/static/chunks/6886-40f1779ffff00d58.js",revision:"40f1779ffff00d58"},{url:"/_next/static/chunks/710-7e96cbf5d461482a.js",revision:"7e96cbf5d461482a"},{url:"/_next/static/chunks/711-5df8fde8fe94f7a8.js",revision:"5df8fde8fe94f7a8"},{url:"/_next/static/chunks/7359-1abfb9f346309354.js",revision:"1abfb9f346309354"},{url:"/_next/static/chunks/7741-0af8b5a61d8e63d3.js",revision:"0af8b5a61d8e63d3"},{url:"/_next/static/chunks/7855-72c79224370eff7b.js",revision:"72c79224370eff7b"},{url:"/_next/static/chunks/787-032067ae978e62a8.js",revision:"032067ae978e62a8"},{url:"/_next/static/chunks/7902-e1f71c3b4c62bff9.js",revision:"e1f71c3b4c62bff9"},{url:"/_next/static/chunks/8221-d51102291d5ddaf9.js",revision:"d51102291d5ddaf9"},{url:"/_next/static/chunks/8241-eaf1b9c6054e9ad8.js",revision:"eaf1b9c6054e9ad8"},{url:"/_next/static/chunks/8412-8ce7440f3599e2d9.js",revision:"8ce7440f3599e2d9"},{url:"/_next/static/chunks/8423-ac92fec5ac4dabe7.js",revision:"ac92fec5ac4dabe7"},{url:"/_next/static/chunks/8466-ffa71cea7998f777.js",revision:"ffa71cea7998f777"},{url:"/_next/static/chunks/8544.3d61228111902e97.js",revision:"3d61228111902e97"},{url:"/_next/static/chunks/8746-92ff3ad56eb06d6e.js",revision:"92ff3ad56eb06d6e"},{url:"/_next/static/chunks/8900-ff82add2eebd43fa.js",revision:"ff82add2eebd43fa"},{url:"/_next/static/chunks/9205-f540995b767df00b.js",revision:"f540995b767df00b"},{url:"/_next/static/chunks/9333-edf14831f0a39549.js",revision:"edf14831f0a39549"},{url:"/_next/static/chunks/9392-2887c5e5703ed90a.js",revision:"2887c5e5703ed90a"},{url:"/_next/static/chunks/9397-40b8ac68e22a4d87.js",revision:"40b8ac68e22a4d87"},{url:"/_next/static/chunks/9515-53e74005e71810bd.js",revision:"53e74005e71810bd"},{url:"/_next/static/chunks/9517-17518b5fffe76114.js",revision:"17518b5fffe76114"},{url:"/_next/static/chunks/9580-031d243edbbe82e5.js",revision:"031d243edbbe82e5"},{url:"/_next/static/chunks/9738-d4ae78df35beeba7.js",revision:"d4ae78df35beeba7"},{url:"/_next/static/chunks/ad2866b8.e13a3cf75ccf0eb8.js",revision:"e13a3cf75ccf0eb8"},{url:"/_next/static/chunks/app/(auth)/forgot-password/page-7311b657b7446bf6.js",revision:"7311b657b7446bf6"},{url:"/_next/static/chunks/app/(auth)/login/page-bb5d0eaa38179e93.js",revision:"bb5d0eaa38179e93"},{url:"/_next/static/chunks/app/(auth)/onboarding/page-28f345357577a0af.js",revision:"28f345357577a0af"},{url:"/_next/static/chunks/app/(auth)/register/page-d0632ddd8172dde1.js",revision:"d0632ddd8172dde1"},{url:"/_next/static/chunks/app/(auth)/reset-password/page-2f5aaa4f5e10070d.js",revision:"2f5aaa4f5e10070d"},{url:"/_next/static/chunks/app/_not-found/page-95f11f5fe94340f1.js",revision:"95f11f5fe94340f1"},{url:"/_next/static/chunks/app/ai-assistant/page-3edb2cda7412d8b4.js",revision:"3edb2cda7412d8b4"},{url:"/_next/static/chunks/app/analytics/advanced/page-8dce8adb1ed3736a.js",revision:"8dce8adb1ed3736a"},{url:"/_next/static/chunks/app/analytics/page-938a3b366d2969b4.js",revision:"938a3b366d2969b4"},{url:"/_next/static/chunks/app/api/ai/chat/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/login/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/password-reset/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/register/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/health/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/tracking/feeding/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/voice/transcribe/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/children/page-40f1bfa952ee593c.js",revision:"40f1bfa952ee593c"},{url:"/_next/static/chunks/app/family/page-a6c42634bbec4ac7.js",revision:"a6c42634bbec4ac7"},{url:"/_next/static/chunks/app/history/page-36e2f94462dd67ae.js",revision:"36e2f94462dd67ae"},{url:"/_next/static/chunks/app/insights/page-296df3d508143098.js",revision:"296df3d508143098"},{url:"/_next/static/chunks/app/layout-dc028048fba2f9f1.js",revision:"dc028048fba2f9f1"},{url:"/_next/static/chunks/app/legal/cookies/page-c39a3fa6e27a8806.js",revision:"c39a3fa6e27a8806"},{url:"/_next/static/chunks/app/legal/eula/page-8015f749ab4dd660.js",revision:"8015f749ab4dd660"},{url:"/_next/static/chunks/app/legal/page-3de074f0b9741bc6.js",revision:"3de074f0b9741bc6"},{url:"/_next/static/chunks/app/legal/privacy/page-3cb58024b6fd8e21.js",revision:"3cb58024b6fd8e21"},{url:"/_next/static/chunks/app/legal/terms/page-b5a1c96cae251767.js",revision:"b5a1c96cae251767"},{url:"/_next/static/chunks/app/logout/page-48007f334d2917ca.js",revision:"48007f334d2917ca"},{url:"/_next/static/chunks/app/offline/page-28c005360c2b2736.js",revision:"28c005360c2b2736"},{url:"/_next/static/chunks/app/page-c5729e7d614eb749.js",revision:"c5729e7d614eb749"},{url:"/_next/static/chunks/app/settings/page-0341b76587e9db2f.js",revision:"0341b76587e9db2f"},{url:"/_next/static/chunks/app/track/activity/page-3767427eaacf5fff.js",revision:"3767427eaacf5fff"},{url:"/_next/static/chunks/app/track/diaper/page-c62c6bfb393c13a1.js",revision:"c62c6bfb393c13a1"},{url:"/_next/static/chunks/app/track/feeding/page-5acbeddc0db06597.js",revision:"5acbeddc0db06597"},{url:"/_next/static/chunks/app/track/growth/page-aac0bf91cb288a19.js",revision:"aac0bf91cb288a19"},{url:"/_next/static/chunks/app/track/medicine/page-dcad90e6224b0800.js",revision:"dcad90e6224b0800"},{url:"/_next/static/chunks/app/track/page-dd5ade1eb19ad389.js",revision:"dd5ade1eb19ad389"},{url:"/_next/static/chunks/app/track/sleep/page-b586a2d14249bb9a.js",revision:"b586a2d14249bb9a"},{url:"/_next/static/chunks/bc98253f.2a96f718cf128d0e.js",revision:"2a96f718cf128d0e"},{url:"/_next/static/chunks/framework-bd61ec64032c2de7.js",revision:"bd61ec64032c2de7"},{url:"/_next/static/chunks/main-520e5ec2d671abe7.js",revision:"520e5ec2d671abe7"},{url:"/_next/static/chunks/main-app-02fc3649960ba6c7.js",revision:"02fc3649960ba6c7"},{url:"/_next/static/chunks/pages/_app-4b3fb5e477a0267f.js",revision:"4b3fb5e477a0267f"},{url:"/_next/static/chunks/pages/_error-c970d8b55ace1b48.js",revision:"c970d8b55ace1b48"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-ffb6d2ad3fedf086.js",revision:"ffb6d2ad3fedf086"},{url:"/_next/static/css/e883b9d6cff8e10c.css",revision:"e883b9d6cff8e10c"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/apple-touch-icon.png",revision:"fa2d4d791b90148a18d49bc3bfd7a43a"},{url:"/check-updates.js",revision:"bc016a0ceb6c72a5fe9ba02ad05d78be"},{url:"/favicon-16x16.png",revision:"db2da3355c89a6149f6d9ee35ebe6bf3"},{url:"/favicon-32x32.png",revision:"0fd88d56aa584bd0546d05ffc63ef777"},{url:"/icon-192x192.png",revision:"b8ef7f117472c4399cceffea644eb8bd"},{url:"/icons/icon-128x128.png",revision:"96cff3b189d9c1daa1edf470290a90cd"},{url:"/icons/icon-144x144.png",revision:"b627c346c431d7e306005aec5f51baff"},{url:"/icons/icon-152x152.png",revision:"012071830c13d310e51f833baed531af"},{url:"/icons/icon-192x192.png",revision:"dfb20132ddb628237eccd4b0e2ee4aaa"},{url:"/icons/icon-384x384.png",revision:"d032b25376232878a2a29b5688992a8d"},{url:"/icons/icon-512x512.png",revision:"ffda0043571d60956f4e321cba706670"},{url:"/icons/icon-72x72.png",revision:"cc89e74126e7e1109f0186774b3c0d77"},{url:"/icons/icon-96x96.png",revision:"32813cdad5b636fc09eec01c7d705936"},{url:"/manifest.json",revision:"5cbf1ecd33b05c4772688ce7d00c2c23"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/vercel.svg",revision:"61c6b19abff40ea7acd577be818f3976"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:a,event:s,state:c})=>a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/api\/.*$/i,new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/.*/i,new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET")}); +if(!self.define){let e,a={};const s=(s,c)=>(s=new URL(s+".js",c).href,a[s]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=a,document.head.appendChild(e)}else e=s,importScripts(s),a()}).then(()=>{let e=a[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(c,i)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(a[n])return;let t={};const d=e=>s(e,n),r={module:{uri:n},exports:t,require:d};a[n]=Promise.all(c.map(e=>r[e]||d(e))).then(e=>(i(...e),t))}}define(["./workbox-4d767a27"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"4471892493745c9bb059364e414ea5ac"},{url:"/_next/static/Hyo8VeuH7updWxR-hbgDI/_buildManifest.js",revision:"673df67655213af81147283455f8956d"},{url:"/_next/static/Hyo8VeuH7updWxR-hbgDI/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1091.c762d795c6885f94.js",revision:"c762d795c6885f94"},{url:"/_next/static/chunks/1188.88c14dc0b9d46cf9.js",revision:"88c14dc0b9d46cf9"},{url:"/_next/static/chunks/1255-b2f7fd83e387a9e1.js",revision:"b2f7fd83e387a9e1"},{url:"/_next/static/chunks/1280-077bbec6d00a7de6.js",revision:"077bbec6d00a7de6"},{url:"/_next/static/chunks/1514-a6ed8a01b9885870.js",revision:"a6ed8a01b9885870"},{url:"/_next/static/chunks/1543-530e0f57f7af68aa.js",revision:"530e0f57f7af68aa"},{url:"/_next/static/chunks/164f4fb6.cb2a48d4da4418c4.js",revision:"cb2a48d4da4418c4"},{url:"/_next/static/chunks/189-453061dd646fdba4.js",revision:"453061dd646fdba4"},{url:"/_next/static/chunks/1930-cd8328eb1cfa4178.js",revision:"cd8328eb1cfa4178"},{url:"/_next/static/chunks/2239-b3f7ecc33c6fd306.js",revision:"b3f7ecc33c6fd306"},{url:"/_next/static/chunks/2262-26293d6453fcc927.js",revision:"26293d6453fcc927"},{url:"/_next/static/chunks/2619-04bc32f026a0d946.js",revision:"04bc32f026a0d946"},{url:"/_next/static/chunks/2931.14c1e0fb7788f4ba.js",revision:"14c1e0fb7788f4ba"},{url:"/_next/static/chunks/2f0b94e8.3186a98eb4c9012b.js",revision:"3186a98eb4c9012b"},{url:"/_next/static/chunks/3127-49a95e7cb556ace3.js",revision:"49a95e7cb556ace3"},{url:"/_next/static/chunks/3452-86647d15ff7842a5.js",revision:"86647d15ff7842a5"},{url:"/_next/static/chunks/3460-a2b6a712ec21acfb.js",revision:"a2b6a712ec21acfb"},{url:"/_next/static/chunks/3664-56dedfcaec4aaceb.js",revision:"56dedfcaec4aaceb"},{url:"/_next/static/chunks/3762-96365c71edc342bf.js",revision:"96365c71edc342bf"},{url:"/_next/static/chunks/4199.bc1715114dd19eda.js",revision:"bc1715114dd19eda"},{url:"/_next/static/chunks/4337-6c756374da7aa8e3.js",revision:"6c756374da7aa8e3"},{url:"/_next/static/chunks/4648-bc0ced32f57b916b.js",revision:"bc0ced32f57b916b"},{url:"/_next/static/chunks/4710-9f9aefe46e6a48d5.js",revision:"9f9aefe46e6a48d5"},{url:"/_next/static/chunks/4bd1b696-100b9d70ed4e49c1.js",revision:"100b9d70ed4e49c1"},{url:"/_next/static/chunks/5125-c990fc036d2a6ce4.js",revision:"c990fc036d2a6ce4"},{url:"/_next/static/chunks/5380-9004e1ac3565daca.js",revision:"9004e1ac3565daca"},{url:"/_next/static/chunks/5385-7ecda8e4ba984edc.js",revision:"7ecda8e4ba984edc"},{url:"/_next/static/chunks/5482-7535aa0aab02d518.js",revision:"7535aa0aab02d518"},{url:"/_next/static/chunks/551.26e2933365d2f96d.js",revision:"26e2933365d2f96d"},{url:"/_next/static/chunks/6088-c165c565edce02be.js",revision:"c165c565edce02be"},{url:"/_next/static/chunks/6181-66be9b76f10d48f6.js",revision:"66be9b76f10d48f6"},{url:"/_next/static/chunks/6357-0263657691f8e0c3.js",revision:"0263657691f8e0c3"},{url:"/_next/static/chunks/658-1d9d4c0c8b5fb129.js",revision:"1d9d4c0c8b5fb129"},{url:"/_next/static/chunks/670-a4ca0f366ee779f5.js",revision:"a4ca0f366ee779f5"},{url:"/_next/static/chunks/6873-ff265086321345c8.js",revision:"ff265086321345c8"},{url:"/_next/static/chunks/6886-40f1779ffff00d58.js",revision:"40f1779ffff00d58"},{url:"/_next/static/chunks/710-7e96cbf5d461482a.js",revision:"7e96cbf5d461482a"},{url:"/_next/static/chunks/7359-1abfb9f346309354.js",revision:"1abfb9f346309354"},{url:"/_next/static/chunks/7741-0af8b5a61d8e63d3.js",revision:"0af8b5a61d8e63d3"},{url:"/_next/static/chunks/7855-72c79224370eff7b.js",revision:"72c79224370eff7b"},{url:"/_next/static/chunks/787-032067ae978e62a8.js",revision:"032067ae978e62a8"},{url:"/_next/static/chunks/7902-e1f71c3b4c62bff9.js",revision:"e1f71c3b4c62bff9"},{url:"/_next/static/chunks/7981-1205285ee8c556da.js",revision:"1205285ee8c556da"},{url:"/_next/static/chunks/8221-d51102291d5ddaf9.js",revision:"d51102291d5ddaf9"},{url:"/_next/static/chunks/8241-eaf1b9c6054e9ad8.js",revision:"eaf1b9c6054e9ad8"},{url:"/_next/static/chunks/8412-8ce7440f3599e2d9.js",revision:"8ce7440f3599e2d9"},{url:"/_next/static/chunks/8423-ac92fec5ac4dabe7.js",revision:"ac92fec5ac4dabe7"},{url:"/_next/static/chunks/8466-ffa71cea7998f777.js",revision:"ffa71cea7998f777"},{url:"/_next/static/chunks/8544.74f59dd908783038.js",revision:"74f59dd908783038"},{url:"/_next/static/chunks/8746-92ff3ad56eb06d6e.js",revision:"92ff3ad56eb06d6e"},{url:"/_next/static/chunks/8900-ff82add2eebd43fa.js",revision:"ff82add2eebd43fa"},{url:"/_next/static/chunks/9205-f540995b767df00b.js",revision:"f540995b767df00b"},{url:"/_next/static/chunks/9333-edf14831f0a39549.js",revision:"edf14831f0a39549"},{url:"/_next/static/chunks/9392-2887c5e5703ed90a.js",revision:"2887c5e5703ed90a"},{url:"/_next/static/chunks/9397-40b8ac68e22a4d87.js",revision:"40b8ac68e22a4d87"},{url:"/_next/static/chunks/9515-e88b59e87a9d336f.js",revision:"e88b59e87a9d336f"},{url:"/_next/static/chunks/9517-17518b5fffe76114.js",revision:"17518b5fffe76114"},{url:"/_next/static/chunks/9580-031d243edbbe82e5.js",revision:"031d243edbbe82e5"},{url:"/_next/static/chunks/9738-d4ae78df35beeba7.js",revision:"d4ae78df35beeba7"},{url:"/_next/static/chunks/ad2866b8.e13a3cf75ccf0eb8.js",revision:"e13a3cf75ccf0eb8"},{url:"/_next/static/chunks/app/(auth)/forgot-password/page-ca00943cf66c3e17.js",revision:"ca00943cf66c3e17"},{url:"/_next/static/chunks/app/(auth)/login/page-6337793313f43fb1.js",revision:"6337793313f43fb1"},{url:"/_next/static/chunks/app/(auth)/onboarding/page-f759aa2ff8e242a7.js",revision:"f759aa2ff8e242a7"},{url:"/_next/static/chunks/app/(auth)/register/page-26c9ab5378556580.js",revision:"26c9ab5378556580"},{url:"/_next/static/chunks/app/(auth)/reset-password/page-2eec6b4142e79702.js",revision:"2eec6b4142e79702"},{url:"/_next/static/chunks/app/_not-found/page-95f11f5fe94340f1.js",revision:"95f11f5fe94340f1"},{url:"/_next/static/chunks/app/ai-assistant/page-3edb2cda7412d8b4.js",revision:"3edb2cda7412d8b4"},{url:"/_next/static/chunks/app/analytics/advanced/page-8dce8adb1ed3736a.js",revision:"8dce8adb1ed3736a"},{url:"/_next/static/chunks/app/analytics/page-938a3b366d2969b4.js",revision:"938a3b366d2969b4"},{url:"/_next/static/chunks/app/api/ai/chat/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/login/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/password-reset/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/auth/register/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/health/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/tracking/feeding/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/api/voice/transcribe/route-a631d97a33877f8a.js",revision:"a631d97a33877f8a"},{url:"/_next/static/chunks/app/children/page-40f1bfa952ee593c.js",revision:"40f1bfa952ee593c"},{url:"/_next/static/chunks/app/family/page-d7ded6a4620cb8d8.js",revision:"d7ded6a4620cb8d8"},{url:"/_next/static/chunks/app/history/page-36e2f94462dd67ae.js",revision:"36e2f94462dd67ae"},{url:"/_next/static/chunks/app/insights/page-296df3d508143098.js",revision:"296df3d508143098"},{url:"/_next/static/chunks/app/layout-974781e155547ac5.js",revision:"974781e155547ac5"},{url:"/_next/static/chunks/app/legal/cookies/page-c39a3fa6e27a8806.js",revision:"c39a3fa6e27a8806"},{url:"/_next/static/chunks/app/legal/eula/page-8015f749ab4dd660.js",revision:"8015f749ab4dd660"},{url:"/_next/static/chunks/app/legal/page-3de074f0b9741bc6.js",revision:"3de074f0b9741bc6"},{url:"/_next/static/chunks/app/legal/privacy/page-3cb58024b6fd8e21.js",revision:"3cb58024b6fd8e21"},{url:"/_next/static/chunks/app/legal/terms/page-b5a1c96cae251767.js",revision:"b5a1c96cae251767"},{url:"/_next/static/chunks/app/logout/page-83925cf53bb9c692.js",revision:"83925cf53bb9c692"},{url:"/_next/static/chunks/app/offline/page-28c005360c2b2736.js",revision:"28c005360c2b2736"},{url:"/_next/static/chunks/app/page-c5729e7d614eb749.js",revision:"c5729e7d614eb749"},{url:"/_next/static/chunks/app/settings/page-c89cad68dc101709.js",revision:"c89cad68dc101709"},{url:"/_next/static/chunks/app/track/activity/page-3767427eaacf5fff.js",revision:"3767427eaacf5fff"},{url:"/_next/static/chunks/app/track/diaper/page-c62c6bfb393c13a1.js",revision:"c62c6bfb393c13a1"},{url:"/_next/static/chunks/app/track/feeding/page-5acbeddc0db06597.js",revision:"5acbeddc0db06597"},{url:"/_next/static/chunks/app/track/growth/page-aac0bf91cb288a19.js",revision:"aac0bf91cb288a19"},{url:"/_next/static/chunks/app/track/medicine/page-dcad90e6224b0800.js",revision:"dcad90e6224b0800"},{url:"/_next/static/chunks/app/track/page-dd5ade1eb19ad389.js",revision:"dd5ade1eb19ad389"},{url:"/_next/static/chunks/app/track/sleep/page-b586a2d14249bb9a.js",revision:"b586a2d14249bb9a"},{url:"/_next/static/chunks/bc98253f.2a96f718cf128d0e.js",revision:"2a96f718cf128d0e"},{url:"/_next/static/chunks/framework-bd61ec64032c2de7.js",revision:"bd61ec64032c2de7"},{url:"/_next/static/chunks/main-520e5ec2d671abe7.js",revision:"520e5ec2d671abe7"},{url:"/_next/static/chunks/main-app-02fc3649960ba6c7.js",revision:"02fc3649960ba6c7"},{url:"/_next/static/chunks/pages/_app-4b3fb5e477a0267f.js",revision:"4b3fb5e477a0267f"},{url:"/_next/static/chunks/pages/_error-c970d8b55ace1b48.js",revision:"c970d8b55ace1b48"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-898276ad52dbad4b.js",revision:"898276ad52dbad4b"},{url:"/_next/static/css/dd1dff55aa5f7521.css",revision:"dd1dff55aa5f7521"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/apple-touch-icon.png",revision:"fa2d4d791b90148a18d49bc3bfd7a43a"},{url:"/check-updates.js",revision:"bc016a0ceb6c72a5fe9ba02ad05d78be"},{url:"/favicon-16x16.png",revision:"db2da3355c89a6149f6d9ee35ebe6bf3"},{url:"/favicon-32x32.png",revision:"0fd88d56aa584bd0546d05ffc63ef777"},{url:"/icon-192x192.png",revision:"b8ef7f117472c4399cceffea644eb8bd"},{url:"/icons/icon-128x128.png",revision:"96cff3b189d9c1daa1edf470290a90cd"},{url:"/icons/icon-144x144.png",revision:"b627c346c431d7e306005aec5f51baff"},{url:"/icons/icon-152x152.png",revision:"012071830c13d310e51f833baed531af"},{url:"/icons/icon-192x192.png",revision:"dfb20132ddb628237eccd4b0e2ee4aaa"},{url:"/icons/icon-384x384.png",revision:"d032b25376232878a2a29b5688992a8d"},{url:"/icons/icon-512x512.png",revision:"ffda0043571d60956f4e321cba706670"},{url:"/icons/icon-72x72.png",revision:"cc89e74126e7e1109f0186774b3c0d77"},{url:"/icons/icon-96x96.png",revision:"32813cdad5b636fc09eec01c7d705936"},{url:"/manifest.json",revision:"5cbf1ecd33b05c4772688ce7d00c2c23"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/push-sw.js",revision:"45385b2ab1bb9d8db17e3707d4364890"},{url:"/vercel.svg",revision:"61c6b19abff40ea7acd577be818f3976"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:a,event:s,state:c})=>a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/api\/.*$/i,new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/.*/i,new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET")});