Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<section class="flex flex-col flex-1" data-testid="dashboard-my-meetings-section">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-4 h-8">
<h2 class="flex items-center gap-2">
<i class="fa-light fa-calendar text-lg"></i>
<span>My Meetings</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

<section class="flex flex-col flex-1" data-testid="dashboard-pending-actions-section">
<!-- Header -->
<div class="flex items-center justify-between mb-4 px-2">
<h2 class="py-1 flex items-center gap-2">
<div class="flex items-center justify-between mb-4 px-2 h-8">
<h2 class="flex items-center gap-2">
<i class="fa-light fa-list-check text-lg"></i>
<span>My Pending Actions</span>
</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,28 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
</lfx-meeting-rsvp-details>
} @else if (!pastMeeting()) {
<!-- Show RSVP Selection for authenticated non-organizers (upcoming meetings only) -->
<lfx-rsvp-button-group
[meeting]="meeting()"
[occurrenceId]="occurrence()?.occurrence_id"
[disabled]="isLegacyMeeting()"
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
</lfx-rsvp-button-group>
<!-- Show RSVP Selection for authenticated invited non-organizers (upcoming meetings only) -->
@if (isInvited()) {
<lfx-rsvp-button-group
[meeting]="meeting()"
[occurrenceId]="occurrence()?.occurrence_id"
[disabled]="isLegacyMeeting()"
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
</lfx-rsvp-button-group>
} @else if (canRegisterForMeeting()) {
<div class="h-full flex items-center justify-center">
<lfx-button
class="w-full"
icon="fa-light fa-user-plus"
label="Register for Meeting"
size="small"
severity="secondary"
styleClass="w-full"
data-testid="register-meeting-button"
(click)="registerForMeeting()">
</lfx-button>
</div>
}
} @else if (pastMeeting() && !meeting().organizer) {
<!-- Show Recording and AI Summary buttons for past meetings (non-organizers) -->
<div class="flex flex-col gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { catchError, combineLatest, map, of, switchMap, take, tap } from 'rxjs';

import { CancelOccurrenceConfirmationComponent } from '../../components/cancel-occurrence-confirmation/cancel-occurrence-confirmation.component';
import { MeetingRsvpDetailsComponent } from '../../components/meeting-rsvp-details/meeting-rsvp-details.component';
import { PublicRegistrationModalComponent } from '../../components/public-registration-modal/public-registration-modal.component';

@Component({
selector: 'lfx-meeting-card',
Expand Down Expand Up @@ -135,6 +136,12 @@ export class MeetingCardComponent implements OnInit {
public readonly isLegacyMeeting: Signal<boolean> = this.initIsLegacyMeeting();
public readonly meetingDetailUrl: Signal<string> = this.initMeetingDetailUrl();

// Computed signals for invited/registration status to ensure reactivity after registration
public readonly isInvited: Signal<boolean> = computed(() => this.meeting().invited ?? false);
public readonly canRegisterForMeeting: Signal<boolean> = computed(
() => !this.isInvited() && !this.meeting().restricted && this.meeting().visibility === 'public'
);

// V1/V2 fallback fields
public readonly meetingTitle: Signal<string> = this.initMeetingTitle();
public readonly meetingDescription: Signal<string> = this.initMeetingDescription();
Expand All @@ -147,7 +154,9 @@ export class MeetingCardComponent implements OnInit {

public constructor() {
effect(() => {
this.meeting.set(this.meetingInput());
if (!this.meeting()?.uid && !this.meeting().id) {
this.meeting.set(this.meetingInput());
}
// Priority: explicit occurrenceInput > current occurrence for upcoming > null for past without input
if (this.occurrenceInput()) {
// If explicitly passed an occurrence, always use it
Expand Down Expand Up @@ -251,6 +260,32 @@ export class MeetingCardComponent implements OnInit {
});
}

public registerForMeeting(): void {
const meeting = this.meeting();
const user = this.userService.user();

this.dialogService
.open(PublicRegistrationModalComponent, {
header: 'Register for Meeting',
width: '500px',
modal: true,
closable: true,
dismissableMask: true,
data: {
meetingId: meeting.uid,
meetingTitle: this.meetingTitle(),
user: user,
},
})
.onClose.pipe(take(1))
.subscribe((result: { registered: boolean } | undefined) => {
if (result?.registered) {
this.additionalRegistrantsCount.set(this.additionalRegistrantsCount() + 1);
this.refreshMeeting();
}
});
}

public openRecordingModal(): void {
if (!this.recordingShareUrl()) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<form [formGroup]="form" (ngSubmit)="onSubmit()" class="flex flex-col gap-4" data-testid="public-registration-modal-form">
<p class="text-sm text-gray-600 mb-2">
Register for <span class="font-semibold">{{ meetingTitle }}</span>
</p>

<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- First Name -->
<div class="flex flex-col gap-1" data-testid="public-registration-firstname-field">
<label for="first_name" class="text-sm font-medium text-gray-700">First Name <span class="text-red-500">*</span></label>
<lfx-input-text
size="small"
[form]="form"
control="first_name"
label="First Name"
placeholder="Enter first name"
data-testid="public-registration-firstname-input">
</lfx-input-text>
</div>

<!-- Last Name -->
<div class="flex flex-col gap-1" data-testid="public-registration-lastname-field">
<label for="last_name" class="text-sm font-medium text-gray-700">Last Name <span class="text-red-500">*</span></label>
<lfx-input-text
size="small"
[form]="form"
control="last_name"
label="Last Name"
placeholder="Enter last name"
data-testid="public-registration-lastname-input">
</lfx-input-text>
</div>

<!-- Email -->
<div class="md:col-span-2 flex flex-col gap-1" data-testid="public-registration-email-field">
<label for="email" class="text-sm font-medium text-gray-700">Email <span class="text-red-500">*</span></label>
<lfx-input-text
size="small"
[form]="form"
control="email"
label="Email"
placeholder="Enter email address"
type="email"
data-testid="public-registration-email-input">
</lfx-input-text>
@if (form.get('email')?.errors?.['required'] && form.get('email')?.touched && form.get('email')?.dirty) {
<p class="text-sm text-red-500">Email is required</p>
}
@if (form.get('email')?.errors?.['email'] && form.get('email')?.touched && form.get('email')?.dirty) {
<p class="text-sm text-red-500">Invalid email address</p>
}
</div>

<!-- Job Title -->
<div class="flex flex-col gap-1" data-testid="public-registration-jobtitle-field">
<label for="job_title" class="text-sm font-medium text-gray-700">Job Title</label>
<lfx-input-text
size="small"
[form]="form"
control="job_title"
label="Job Title"
placeholder="Enter job title"
data-testid="public-registration-jobtitle-input">
</lfx-input-text>
</div>

<!-- Organization -->
<div class="flex flex-col gap-1" data-testid="public-registration-organization-field">
<label for="org_name" class="text-sm font-medium text-gray-700">Organization</label>
<lfx-organization-search
[form]="form"
nameControl="org_name"
placeholder="Search organizations..."
styleClass="w-full"
inputStyleClass="text-sm w-full"
panelStyleClass="text-sm w-full"
dataTestId="public-registration-organization-input">
</lfx-organization-search>
</div>
</div>

<!-- Action Buttons -->
<div class="flex border-t border-gray-200 pt-4 justify-end gap-2" data-testid="public-registration-modal-actions">
<lfx-button
label="Cancel"
severity="secondary"
[text]="true"
size="small"
type="button"
(onClick)="onCancel()"
data-testid="public-registration-cancel-button">
</lfx-button>
<lfx-button
label="Register"
[loading]="submitting()"
[disabled]="form.invalid || submitting()"
type="submit"
size="small"
data-testid="public-registration-submit-button">
</lfx-button>
</div>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { CommonModule } from '@angular/common';
import { Component, inject, signal, WritableSignal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonComponent } from '@components/button/button.component';
import { InputTextComponent } from '@components/input-text/input-text.component';
import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component';
import { MeetingRegistrant, User } from '@lfx-one/shared/interfaces';
import { markFormControlsAsTouched } from '@lfx-one/shared/utils';
import { MeetingService } from '@services/meeting.service';
import { MessageService } from 'primeng/api';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';

@Component({
selector: 'lfx-public-registration-modal',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ButtonComponent, InputTextComponent, OrganizationSearchComponent],
templateUrl: './public-registration-modal.component.html',
})
export class PublicRegistrationModalComponent {
private readonly meetingService = inject(MeetingService);
private readonly messageService = inject(MessageService);
private readonly ref = inject(DynamicDialogRef);
private readonly config = inject(DynamicDialogConfig);

public readonly meetingId: string = this.config.data.meetingId;
public readonly meetingTitle: string = this.config.data.meetingTitle;
public readonly user: User | null = this.config.data.user;

public submitting: WritableSignal<boolean> = signal(false);
public form: FormGroup;

public constructor() {
this.form = new FormGroup({
first_name: new FormControl('', [Validators.required, Validators.minLength(2)]),
last_name: new FormControl('', [Validators.required, Validators.minLength(2)]),
email: new FormControl('', [Validators.required, Validators.email]),
job_title: new FormControl(''),
org_name: new FormControl(''),
});

// Pre-populate with user data if available
if (this.user) {
let firstName: string | null = null;
let lastName: string | null = null;

if (this.user.name) {
const nameParts = this.user.name.split(' ');
firstName = nameParts[0];
lastName = this.user.name.split(' ').slice(1).join(' ');
} else {
firstName = this.user.given_name || this.user.first_name || '';
lastName = this.user.family_name || this.user.last_name || '';
}

this.form.patchValue({ first_name: firstName, last_name: lastName, email: this.user.email || '' });
}
}

public onSubmit(): void {
if (this.submitting()) {
return;
}

if (this.form.valid) {
this.submitting.set(true);
const formValue = this.form.value;

this.meetingService
.registerForPublicMeeting({
meeting_uid: this.meetingId,
email: formValue.email,
first_name: formValue.first_name,
last_name: formValue.last_name,
job_title: formValue.job_title || null,
org_name: formValue.org_name || null,
})
.subscribe({
next: (registrant: MeetingRegistrant) => {
this.submitting.set(false);
this.messageService.add({
severity: 'success',
summary: 'Registration Successful',
detail: 'You have been registered for this meeting',
});
this.ref.close({ registered: true, registrant });
},
error: (error: any) => {
this.submitting.set(false);
const errorMessage = error?.error?.message || 'Failed to register for this meeting';
this.messageService.add({
severity: 'error',
summary: 'Registration Failed',
detail: errorMessage,
});
},
});
} else {
markFormControlsAsTouched(this.form);
}
}

public onCancel(): void {
this.ref.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@
<!-- Yes Button -->
<button
(click)="handleRsvpClick('accepted')"
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'accepted')"
[disabled]="isLoading()"
[ngClass]="{
'bg-blue-500 border-blue-500 text-white': selectedResponse() === 'accepted',
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'accepted',
'bg-white border-blue-500 text-blue-700 hover:bg-blue-50': selectedResponse() === null,
'bg-white border-blue-500 text-blue-700 hover:bg-blue-50': selectedResponse() !== 'accepted',
}"
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
[attr.data-testid]="'meeting-rsvp-button-yes'">
Expand All @@ -47,11 +46,10 @@
<!-- No Button -->
<button
(click)="handleRsvpClick('declined')"
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'declined')"
[disabled]="isLoading()"
[ngClass]="{
'bg-red-500 border-red-500 text-white': selectedResponse() === 'declined',
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'declined',
'bg-white border-red-500 text-red-700 hover:bg-red-50': selectedResponse() === null,
'bg-white border-red-500 text-red-700 hover:bg-red-50': selectedResponse() !== 'declined',
}"
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
[attr.data-testid]="'meeting-rsvp-button-no'">
Expand All @@ -62,11 +60,10 @@
<!-- Maybe Button -->
<button
(click)="handleRsvpClick('maybe')"
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'maybe')"
[disabled]="isLoading()"
[ngClass]="{
'bg-amber-500 border-amber-500 text-white': selectedResponse() === 'maybe',
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'maybe',
'bg-white border-amber-500 text-amber-700 hover:bg-amber-50': selectedResponse() === null,
'bg-white border-amber-500 text-amber-700 hover:bg-amber-50': selectedResponse() !== 'maybe',
}"
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
[attr.data-testid]="'meeting-rsvp-button-maybe'">
Expand Down
Loading