diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21743e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI - Build Containers + +on: + push: + branches: ['main', 'dev'] + pull_request: + branches: ['main', 'dev'] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate-compose: + name: Validate docker-compose configuration + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Ensure .env for docker-compose + run: | + if [ -f .env ]; then + echo ".env already exists" + elif [ -f .env.example ]; then + cp .env.example .env + else + touch .env + fi + + - name: Validate docker-compose.yaml + run: docker compose -f docker-compose.yaml config -q + + build-images: + name: Build Docker images + needs: validate-compose + runs-on: ubuntu-latest + strategy: + # Realistically, in production, `fail-fast` should be true, + # but we want to see every issue during these early stages of implementing + fail-fast: false + matrix: + include: + - name: server + context: server + dockerfile: server/Dockerfile + - name: client + context: client + dockerfile: client/Dockerfile + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.name }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: false + tags: ghcr.io/${{ github.repository }}:${{ matrix.name }}-ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 0b36567..3d7fb73 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ *.env +# System files .DS_Store Thumbs.db diff --git a/client/Dockerfile b/client/Dockerfile index 5c100b0..42ee95c 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -2,6 +2,20 @@ FROM node:22.18-alpine3.22 WORKDIR /app +# Install Chrome for testing +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont + +# Set Chrome binary path +ENV CHROME_BIN=/usr/bin/chromium-browser +ENV CHROME_PATH=/usr/bin/chromium-browser + RUN npm install -g pnpm COPY package.json pnpm-lock.yaml ./ diff --git a/client/karma.conf.js b/client/karma.conf.js new file mode 100644 index 0000000..f51c274 --- /dev/null +++ b/client/karma.conf.js @@ -0,0 +1,47 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution order + // random: false + }, + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true, // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, './coverage/source-client'), + subdir: '.', + reporters: [{ type: 'html' }, { type: 'text-summary' }], + }, + reporters: ['progress', 'kjhtml'], + browsers: ['ChromeHeadless'], + customLaunchers: { + ChromeHeadless: { + base: 'Chrome', + flags: [ + '--no-sandbox', + '--disable-web-security', + '--disable-gpu', + '--remote-debugging-port=9222', + ], + }, + }, + restartOnFileChange: true, + }) +} diff --git a/client/src/app/app.config.ts b/client/src/app/app.config.ts index ebd6467..18d879e 100644 --- a/client/src/app/app.config.ts +++ b/client/src/app/app.config.ts @@ -9,7 +9,7 @@ import { provideClientHydration, withEventReplay } from '@angular/platform-brows import { provideRouter } from '@angular/router' import { provideAnimationsAsync } from '@angular/platform-browser/animations/async' import { provideMarkdown } from 'ngx-markdown' -import { MessageService as PrimeNGMessageService } from 'primeng/api' +import { ConfirmationService, MessageService as PrimeNGMessageService } from 'primeng/api' import { providePrimeNG } from 'primeng/config' import { baseInterceptor } from './shared/interceptors/base.interceptor' import { routes } from './app.routes' @@ -36,6 +36,6 @@ export const appConfig: ApplicationConfig = { }, }, }), - makeEnvironmentProviders([PrimeNGMessageService]), + makeEnvironmentProviders([PrimeNGMessageService, ConfirmationService]), ], } diff --git a/client/src/app/core/repositories/auth.repository.ts b/client/src/app/core/repositories/auth.repository.ts new file mode 100644 index 0000000..ef72e93 --- /dev/null +++ b/client/src/app/core/repositories/auth.repository.ts @@ -0,0 +1,39 @@ +import { HttpClient, HttpResponse } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' +import { SessionUser } from '~/shared/interfaces/session-user.interface' + +type MeResponse = LaravelApiResponse +type LoginResponse = LaravelApiResponse<{ + token: string + user: SessionUser +}> +type RegisterResponse = LoginResponse + +@Injectable({ providedIn: 'root' }) +export class AuthRepository { + protected http = inject(HttpClient) + + me(): Observable { + return this.http.get('/api/me') + } + + register(formValue: { + name: string + email: string + password: string + password_confirmation: string + avatar_url?: string + }): Observable> { + return this.http.post('/api/register', formValue, { observe: 'response' }) + } + + login(formValue: { email: string; password: string }): Observable> { + return this.http.post('/api/login', formValue, { observe: 'response' }) + } + + logout(): Observable> { + return this.http.post('/api/logout', null, { observe: 'response' }) + } +} diff --git a/client/src/app/core/repositories/user.repository.ts b/client/src/app/core/repositories/user.repository.ts index e0f95f8..984952d 100644 --- a/client/src/app/core/repositories/user.repository.ts +++ b/client/src/app/core/repositories/user.repository.ts @@ -12,15 +12,15 @@ export class UserRepository { protected http = inject(HttpClient) storeFigmaToken(token: string): Observable { - const formData = new FormData() - formData.append('figma_access_token', token) - return this.http.post('/api/profile/figma-token', formData) + return this.http.post('/api/profile/figma-token', { + figma_access_token: token, + }) } storeBrevoToken(token: string): Observable { - const formData = new FormData() - formData.append('brevo_api_token', token) - return this.http.post('/api/profile/brevo-token', formData) + return this.http.post('/api/profile/brevo-token', { + brevo_api_token: token, + }) } removeFigmaToken(): Observable { diff --git a/client/src/app/core/services/auth.service.ts b/client/src/app/core/services/auth.service.ts index ab1d318..c1167e0 100644 --- a/client/src/app/core/services/auth.service.ts +++ b/client/src/app/core/services/auth.service.ts @@ -1,26 +1,18 @@ -import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { inject, Injectable, signal } from '@angular/core' import { Observable, of } from 'rxjs' import { catchError, map } from 'rxjs/operators' -import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' import { SessionUser } from '~/shared/interfaces/session-user.interface' - -type MeResponse = LaravelApiResponse -type LoginResponse = LaravelApiResponse<{ - token: string - user: SessionUser -}> -type RegisterResponse = LoginResponse +import { AuthRepository } from '~/core/repositories/auth.repository' @Injectable({ providedIn: 'root' }) export class AuthService { - protected http = inject(HttpClient) + protected authRepository = inject(AuthRepository) public user = signal(null) public isAuthenticated = signal(false) checkIfAuthenticated(): Observable { - return this.http.get('/api/me').pipe( + return this.authRepository.me().pipe( map(res => { this.user.set(res.payload) this.isAuthenticated.set(true) @@ -30,56 +22,12 @@ export class AuthService { ) } - register(formData: FormData): Observable<{ success: boolean; errorMessage: string | null }> { - return this.http - .post('/api/register', formData, { observe: 'response' }) - .pipe( - map(res => { - if (res.ok && res.body?.payload?.user) { - this.user.set(res.body.payload.user) - this.isAuthenticated.set(true) - return { success: true, errorMessage: null } - } - return { success: false, errorMessage: res.body?.message || 'Login failed.' } - }), - catchError((errResp: HttpErrorResponse) => { - const errorMessage = - (errResp.error?.message as string) || - (typeof errResp.error === 'string' ? errResp.error : null) || - 'Login failed. Please try again.' - - return of({ success: false, errorMessage }) - }), - ) - } - - login(formData: FormData): Observable<{ success: boolean; errorMessage: string | null }> { - return this.http.post('/api/login', formData, { observe: 'response' }).pipe( - map(res => { - if (res.ok && res.body?.payload?.user) { - this.user.set(res.body.payload.user) - this.isAuthenticated.set(true) - return { success: true, errorMessage: null } - } - return { success: false, errorMessage: res.body?.message || 'Login failed.' } - }), - catchError((errResp: HttpErrorResponse) => { - const errorMessage = - (errResp.error?.message as string) || - (typeof errResp.error === 'string' ? errResp.error : null) || - 'Login failed. Please try again.' - - return of({ success: false, errorMessage }) - }), - ) - } - getUser() { return this.user.asReadonly() } logout(): Observable { - return this.http.post('/api/logout', null, { observe: 'response' }).pipe( + return this.authRepository.logout().pipe( map(res => { if (res.ok) { this.user.set(null) diff --git a/client/src/app/modules/auth/components/register-form/register-form.ts b/client/src/app/modules/auth/components/register-form/register-form.ts index 5bea3d6..2b42b75 100644 --- a/client/src/app/modules/auth/components/register-form/register-form.ts +++ b/client/src/app/modules/auth/components/register-form/register-form.ts @@ -8,10 +8,13 @@ import { } from '@angular/forms' import { Router, RouterLink } from '@angular/router' import { HttpErrorResponse } from '@angular/common/http' +import { catchError } from 'rxjs/operators' +import { of } from 'rxjs' import { Message } from 'primeng/message' import { Button } from 'primeng/button' import { InputText } from 'primeng/inputtext' import { AuthService } from '~/core/services/auth.service' +import { AuthRepository } from '~/core/repositories/auth.repository' @Component({ selector: 'app-register-form', @@ -22,6 +25,7 @@ import { AuthService } from '~/core/services/auth.service' export class RegisterForm { protected fb = inject(NonNullableFormBuilder) protected authService = inject(AuthService) + protected authRepository = inject(AuthRepository) protected router = inject(Router) protected isLoading = signal(false) @@ -58,32 +62,38 @@ export class RegisterForm { this.isLoading.set(true) this.errorMessage.set(null) - const formData = new FormData() - formData.append('name', this.registerFb.get('name')?.value || '') - formData.append('email', this.registerFb.get('email')?.value || '') - formData.append('password', this.registerFb.get('password')?.value || '') - formData.append( - 'password_confirmation', - this.registerFb.get('password_confirmation')?.value || '', - ) + const formValue = this.registerFb.getRawValue() - this.authService.register(formData).subscribe({ - next: ({ success, errorMessage }) => { + this.authRepository + .register(formValue) + .pipe( + catchError((err: HttpErrorResponse) => { + const errMsg = + (err.error?.message as string) || + (typeof err.error === 'string' ? err.error : null) || + 'Registration failed. Please try again.' + + this.errorMessage.set(errMsg) + this.isLoading.set(false) + return of(null) + }), + ) + .subscribe(async resp => { this.isLoading.set(false) - if (success) { - this.router.navigate(['/dashboard']) - } else { - this.errorMessage.set(errorMessage || 'Registration failed. Please try again.') + + if (!resp) { + return } - }, - error: (err: HttpErrorResponse) => { - this.isLoading.set(false) - const errMsg = - (err.error?.message as string) || - (typeof err.error === 'string' ? err.error : null) || - 'Registration failed. Please try again.' - this.errorMessage.set(errMsg) - }, - }) + + if (!(resp.ok && resp.body?.payload?.user)) { + const message = resp.body?.message || 'Registration failed. Please try again.' + this.errorMessage.set(message) + return + } + + this.authService.user.set(resp.body.payload.user) + this.authService.isAuthenticated.set(true) + await this.router.navigate(['/dashboard']) + }) } } diff --git a/client/src/app/modules/auth/containers/login/login.html b/client/src/app/modules/auth/containers/login/login.html index 0bf9990..fd63faf 100644 --- a/client/src/app/modules/auth/containers/login/login.html +++ b/client/src/app/modules/auth/containers/login/login.html @@ -18,6 +18,7 @@

Log in to your ac pInputText id="email" formControlName="email" + autocomplete="email" type="email" name="email" class="w-full" @@ -38,6 +39,7 @@

Log in to your ac pInputText id="password" formControlName="password" + autocomplete="current-password" type="password" name="password" class="w-full" diff --git a/client/src/app/modules/auth/containers/login/login.ts b/client/src/app/modules/auth/containers/login/login.ts index 0e8730b..537163a 100644 --- a/client/src/app/modules/auth/containers/login/login.ts +++ b/client/src/app/modules/auth/containers/login/login.ts @@ -8,6 +8,7 @@ import { AuthService } from '~/core/services/auth.service' import { Logo } from '~/shared/components/logo/logo' import { InputText } from 'primeng/inputtext' import { Button } from 'primeng/button' +import { AuthRepository } from '~/core/repositories/auth.repository' @Component({ selector: 'app-login', @@ -18,6 +19,7 @@ import { Button } from 'primeng/button' export class Login implements OnInit { protected fb = inject(NonNullableFormBuilder) protected authService = inject(AuthService) + protected authRepository = inject(AuthRepository) protected router = inject(Router) protected isLoading = signal(false) @@ -40,17 +42,19 @@ export class Login implements OnInit { this.isLoading.set(true) this.errorMessage.set(null) - const formData = new FormData() - formData.append('email', this.loginForm.get('email')?.value || '') - formData.append('password', this.loginForm.get('password')?.value || '') + const formValue = this.loginForm.getRawValue() - this.authService.login(formData).subscribe({ - next: ({ success, errorMessage }) => { + this.authRepository.login(formValue).subscribe({ + next: async resp => { this.isLoading.set(false) - if (success) { - this.router.navigate(['/dashboard']) + if (resp.ok && resp.body?.payload?.user) { + this.authService.user.set(resp.body.payload.user) + this.authService.isAuthenticated.set(true) + + await this.router.navigate(['/dashboard']) } else { - this.errorMessage.set(errorMessage || 'Login failed. Please try again.') + const message = resp.body?.message || 'Login failed. Please try again.' + this.errorMessage.set(message) } }, error: (err: HttpErrorResponse) => { diff --git a/client/src/app/modules/dashboard/containers/account/account.html b/client/src/app/modules/dashboard/containers/account/account.html index 7ec56d6..d83a3e8 100644 --- a/client/src/app/modules/dashboard/containers/account/account.html +++ b/client/src/app/modules/dashboard/containers/account/account.html @@ -1,6 +1,3 @@ - - -

Account Settings

@@ -146,3 +143,6 @@

+ + + diff --git a/client/src/app/modules/dashboard/containers/account/account.ts b/client/src/app/modules/dashboard/containers/account/account.ts index 052c7b6..0c30539 100644 --- a/client/src/app/modules/dashboard/containers/account/account.ts +++ b/client/src/app/modules/dashboard/containers/account/account.ts @@ -14,7 +14,6 @@ import { MessageService } from '~/core/services/message.service' @Component({ selector: 'app-account', imports: [ReactiveFormsModule, Card, InputText, Button, Message, Toast, ConfirmPopup], - providers: [ConfirmationService], templateUrl: './account.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'grow' }, diff --git a/client/src/app/modules/dashboard/containers/dashboard/dashboard.html b/client/src/app/modules/dashboard/containers/dashboard/dashboard.html index 51b375a..4d36b3c 100644 --- a/client/src/app/modules/dashboard/containers/dashboard/dashboard.html +++ b/client/src/app/modules/dashboard/containers/dashboard/dashboard.html @@ -42,14 +42,13 @@ Back to projects - Log out - + diff --git a/client/src/app/modules/dashboard/containers/dashboard/dashboard.ts b/client/src/app/modules/dashboard/containers/dashboard/dashboard.ts index 388dd25..10d8b03 100644 --- a/client/src/app/modules/dashboard/containers/dashboard/dashboard.ts +++ b/client/src/app/modules/dashboard/containers/dashboard/dashboard.ts @@ -15,6 +15,7 @@ import { Avatar } from 'primeng/avatar' import { Toolbar } from 'primeng/toolbar' import { Popover } from 'primeng/popover' import { AuthService } from '~/core/services/auth.service' +import { MessageService } from '~/core/services/message.service' @Component({ selector: 'app-dashboard', @@ -23,11 +24,12 @@ import { AuthService } from '~/core/services/auth.service' changeDetection: ChangeDetectionStrategy.OnPush, }) export class Dashboard implements OnInit { - destroyRef = inject(DestroyRef) protected router = inject(Router) protected activatedRoute = inject(ActivatedRoute) protected authService = inject(AuthService) + protected message = inject(MessageService) protected isInsideProject = signal(false) + destroyRef = inject(DestroyRef) protected user = this.authService.getUser() @@ -47,4 +49,22 @@ export class Dashboard implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe() } + + logoutAndGoHome() { + this.authService + .logout() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: async () => { + await this.router.navigate(['/']) + }, + error: err => { + console.warn(err) + this.message.error( + 'Oops!', + "Something wrong happened and we couldn't log you out normally", + ) + }, + }) + } } diff --git a/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.html b/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.html index fcd7e18..e967e58 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.html +++ b/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.html @@ -1,5 +1,3 @@ - -
@if (isLoading()) { diff --git a/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.ts b/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.ts index 1f46cca..14a6567 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.ts +++ b/client/src/app/modules/dashboard/containers/projects/components/ai-chat-panel/ai-chat-panel.ts @@ -8,29 +8,30 @@ import { DestroyRef, } from '@angular/core' import { FormsModule } from '@angular/forms' -import { HttpClient } from '@angular/common/http' +import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { catchError, of } from 'rxjs' import { Button } from 'primeng/button' import { InputText } from 'primeng/inputtext' import { ProgressSpinner } from 'primeng/progressspinner' import { AiChatMessage } from '../ai-chat-message/ai-chat-message' import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' -import { Toast } from 'primeng/toast' import { AiChatMessageData } from '../../shared/interfaces/ai-chat-message-data.interface' import { EmptyState } from '~/shared/components/empty-state/empty-state' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { MessageService } from '~/core/services/message.service' +import { AiChatRepository } from '../../shared/repositories/ai-chat.respository' @Component({ selector: 'app-ai-chat-panel', - imports: [FormsModule, InputText, Button, ProgressSpinner, AiChatMessage, Toast, EmptyState], + imports: [FormsModule, InputText, Button, ProgressSpinner, AiChatMessage, EmptyState], templateUrl: './ai-chat-panel.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'h-full' }, }) export class AiChatPanel implements OnInit { - http = inject(HttpClient) - message = inject(MessageService) + protected http = inject(HttpClient) + protected message = inject(MessageService) + protected aiChatRepository = inject(AiChatRepository) destroyRef = inject(DestroyRef) emailTemplateId = input() @@ -49,7 +50,7 @@ export class AiChatPanel implements OnInit { return this.emailTemplateId() ?? this.screenId() ?? 0 } - get chatType(): string { + get chatType() { return this.emailTemplateId() ? 'email-templates' : 'screens' } @@ -57,14 +58,11 @@ export class AiChatPanel implements OnInit { if (!this.chatId) return this.isLoading.set(true) - this.http - .get>( - `/api/${this.chatType}/${encodeURIComponent(this.chatId)}/chats`, - ) + this.aiChatRepository + .getChatMessages(this.chatType, this.chatId) .pipe( takeUntilDestroyed(this.destroyRef), - catchError(err => { - console.warn('Failed to load chat messages:', err) + catchError((err: HttpErrorResponse) => { this.message.error( 'Error', `Failed to load chat. ${err.error?.message || err.message}`, @@ -84,10 +82,10 @@ export class AiChatPanel implements OnInit { return } - // Add user message immediately + // Add user message immediately (optimistic ui) const userMessage: AiChatMessageData = { id: Date.now(), - user_id: 1, + user_id: 1, // placeholder content: messageContent, sender: 'user', created_at: new Date().toISOString(), @@ -98,36 +96,18 @@ export class AiChatPanel implements OnInit { this.newMessage.set('') this.isWaitingForAiResponse.set(true) - const formData = new FormData() - formData.set('content', messageContent) - - // For email templates, set update_template to true to enable AI template updates - if (this.chatType === 'email-templates') { - formData.set('update_template', '1') - } - - this.http - .post< - LaravelApiResponse<{ - user: AiChatMessageData - ai: { content: string } - template_updated?: boolean - }> - >(`/api/${this.chatType}/${encodeURIComponent(this.chatId)}/chats`, formData) + this.aiChatRepository + .sendMessage(this.chatType, this.chatId, { + content: messageContent, + update_template: this.chatType === 'email-templates', + }) .pipe( - catchError(err => { - console.warn('Failed to send message:', err) + catchError((err: HttpErrorResponse) => { this.message.error( 'Error', `Failed to send message. ${err.error?.message || err.message}`, ) - return of< - LaravelApiResponse<{ - user: AiChatMessageData - ai: { content: string } - template_updated?: boolean - }> - >({ + return of>({ message: '', payload: null, }) @@ -137,12 +117,12 @@ export class AiChatPanel implements OnInit { if (response.payload) { // Add AI message const aiMessage: AiChatMessageData = { - id: Date.now() + 1, + id: response.payload.ai.id, user_id: null, - content: response.payload!.ai.content, + content: response.payload.ai.content, sender: 'ai', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: response.payload.ai.created_at, + updated_at: response.payload.ai.updated_at, } this.messages.update(messages => [...messages, aiMessage]) diff --git a/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.html b/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.html index 8abfaaa..a902bba 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.html +++ b/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.html @@ -6,7 +6,7 @@ (onHide)="onVisibleChange(false)" (onShow)="onVisibleChange(true)" [modal]="true" - [closable]="true" + [closable]="false" [draggable]="false" [resizable]="false" styleClass="w-full max-w-4xl" diff --git a/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.ts b/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.ts index 3f60a2a..dae5928 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.ts +++ b/client/src/app/modules/dashboard/containers/projects/components/brevo-template-selector/brevo-template-selector.ts @@ -158,7 +158,6 @@ export class BrevoTemplateSelector { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: response => { - this.message.success('Success', 'Successfully imported template!') this.templatesImported.emit([response.payload!]) this.onVisibleChange(false) }, @@ -186,10 +185,6 @@ export class BrevoTemplateSelector { }), ) .subscribe(response => { - this.message.success( - 'Success', - `Successfully imported ${response.payload?.length ?? 0} templates!`, - ) this.templatesImported.emit(response.payload ?? []) this.onVisibleChange(false) }) diff --git a/client/src/app/modules/dashboard/containers/projects/components/comments-panel/comments-panel.ts b/client/src/app/modules/dashboard/containers/projects/components/comments-panel/comments-panel.ts index 0da75a1..710c3f4 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/comments-panel/comments-panel.ts +++ b/client/src/app/modules/dashboard/containers/projects/components/comments-panel/comments-panel.ts @@ -63,7 +63,6 @@ export class CommentsPanel implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), catchError(err => { - console.warn('Failed to load comments:', err) this.message.error( 'Error', `Failed to load comments. ${err.error?.message || err.message}`, @@ -84,18 +83,15 @@ export class CommentsPanel implements OnInit { } this.isSubmitting.set(true) - const formData = new FormData() - formData.set('content', commentContent) this.http .post>( `/api/${this.commentType}/${this.commentId}/comments`, - formData, + { content: commentContent }, ) .pipe( takeUntilDestroyed(this.destroyRef), catchError(err => { - console.warn('Failed to send comment:', err) this.message.error( 'Error', `Failed to send comment. ${err.error?.message || err.message}`, diff --git a/client/src/app/modules/dashboard/containers/projects/components/new-release-dialog/new-release-dialog.ts b/client/src/app/modules/dashboard/containers/projects/components/new-release-dialog/new-release-dialog.ts index 2d0a6e6..568d2b3 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/new-release-dialog/new-release-dialog.ts +++ b/client/src/app/modules/dashboard/containers/projects/components/new-release-dialog/new-release-dialog.ts @@ -43,11 +43,11 @@ import { EmailTemplate } from '../../shared/interfaces/email.interface' changeDetection: ChangeDetectionStrategy.OnPush, }) export class NewReleaseDialog { - fb = inject(NonNullableFormBuilder) - releaseRepository = inject(ReleaseRepository) - screenRepository = inject(ScreenRepository) - emailTemplateRepository = inject(EmailTemplateRepository) - message = inject(MessageService) + private fb = inject(NonNullableFormBuilder) + private releaseRepository = inject(ReleaseRepository) + private screenRepository = inject(ScreenRepository) + private emailTemplateRepository = inject(EmailTemplateRepository) + private message = inject(MessageService) destroyRef = inject(DestroyRef) visible = input(false) diff --git a/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.html b/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.html index 72cba1a..642cfa1 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.html +++ b/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.html @@ -18,7 +18,7 @@

{{ release().version }}

@if (tags().length) {
- + @for (tag of tags(); track tag) { {{ release().version }}

- - diff --git a/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.ts b/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.ts index 3822dfb..7abfe71 100644 --- a/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.ts +++ b/client/src/app/modules/dashboard/containers/projects/components/release-card/release-card.ts @@ -11,7 +11,6 @@ import { Chip } from 'primeng/chip' import { Button } from 'primeng/button' import { Menu } from 'primeng/menu' import { MenuItem } from 'primeng/api' -import { ConfirmDialog } from 'primeng/confirmdialog' import { ConfirmationService } from 'primeng/api' import { ReleaseData } from '../../shared/interfaces/release.interface' import { DatePipe, TitleCasePipe } from '@angular/common' @@ -22,7 +21,7 @@ import { catchError, of } from 'rxjs' @Component({ selector: 'app-release-card', - imports: [Chip, DatePipe, TitleCasePipe, Button, Menu, ConfirmDialog], + imports: [Chip, DatePipe, TitleCasePipe, Button, Menu], templateUrl: './release-card.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, @@ -82,6 +81,12 @@ export class ReleaseCard { message: 'Are you sure you want to delete this release? This action cannot be undone.', header: 'Confirm Delete', icon: 'pi pi-exclamation-triangle', + rejectButtonProps: { + outlined: true, + severity: 'secondary', + label: 'Cancel', + }, + acceptButtonProps: { severity: 'danger', label: 'Delete' }, accept: () => this.deleteRelease(), }) } @@ -92,7 +97,6 @@ export class ReleaseCard { .pipe( takeUntilDestroyed(this.destroyRef), catchError(err => { - console.error('Failed to delete release:', err) this.message.error( 'Error', `Failed to delete release: ${err.error?.message || err.message}`, diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.html b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.html index c79a08e..d02c68b 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.html @@ -59,5 +59,3 @@

{{ audit().name }}

} - - diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.ts b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.ts index 5ec4d7b..28b8ef8 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/audit-card/audit-card.ts @@ -12,7 +12,6 @@ import { Button } from 'primeng/button' import { Tag } from 'primeng/tag' import { Menu } from 'primeng/menu' import { MenuItem } from 'primeng/api' -import { ConfirmDialog } from 'primeng/confirmdialog' import { ProgressSpinner } from 'primeng/progressspinner' import { DatePipe } from '@angular/common' import { ConfirmationService } from 'primeng/api' @@ -24,8 +23,7 @@ import { AuditData } from '../../shared/interfaces/audit.interface' @Component({ selector: 'app-audit-card', - imports: [Button, Tag, Menu, ConfirmDialog, ProgressSpinner, DatePipe], - providers: [ConfirmationService], + imports: [Button, Tag, Menu, ProgressSpinner, DatePipe], templateUrl: './audit-card.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block' }, @@ -130,6 +128,12 @@ export class AuditCard { message: 'Are you sure you want to delete this audit? This action cannot be undone.', header: 'Confirm Delete', icon: 'pi pi-exclamation-triangle', + rejectButtonProps: { + outlined: true, + severity: 'secondary', + label: 'Cancel', + }, + acceptButtonProps: { severity: 'danger', label: 'Delete' }, accept: () => this.deleteAudit(), }) } diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.html b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.html index 60e58af..000e3ae 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.html @@ -48,7 +48,7 @@

Create New Audit

id="description" name="description" placeholder="Describe what this audit will analyze..." - [rows]="3" + [rows]="7" class="resize-none" fluid > diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.ts b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.ts index c631873..bbe66c4 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/components/create-audit-dialog/create-audit-dialog.ts @@ -9,7 +9,7 @@ import { signal, DestroyRef, } from '@angular/core' -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms' import { Dialog } from 'primeng/dialog' import { Button } from 'primeng/button' import { InputText } from 'primeng/inputtext' @@ -23,7 +23,6 @@ import { AuditRepository } from '~/modules/dashboard/containers/projects/contain import { ScreenData } from '~/modules/dashboard/containers/projects/shared/interfaces/screen.interface' import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { CreateAuditRequest } from '../../shared/interfaces/audit.interface' @Component({ selector: 'app-create-audit-dialog', @@ -34,7 +33,7 @@ import { CreateAuditRequest } from '../../shared/interfaces/audit.interface' }) export class CreateAuditDialog implements OnInit { private auditRepository = inject(AuditRepository) - private fb = inject(FormBuilder) + private fb = inject(NonNullableFormBuilder) private message = inject(MessageService) private http = inject(HttpClient) destroyRef = inject(DestroyRef) @@ -49,10 +48,13 @@ export class CreateAuditDialog implements OnInit { private isLoading = signal(false) private isSubmitting = signal(false) - auditForm: FormGroup = this.fb.group({ - name: ['', [Validators.required, Validators.maxLength(255)]], - description: [''], - screen_ids: [[], [Validators.required, Validators.minLength(2), Validators.maxLength(7)]], + auditForm = this.fb.group({ + name: this.fb.control('', [Validators.required, Validators.maxLength(255)]), + description: this.fb.control(''), + screen_ids: this.fb.control( + [], + [Validators.required, Validators.minLength(2), Validators.maxLength(7)], + ), }) screensList = this.screens.asReadonly() @@ -114,10 +116,10 @@ export class CreateAuditDialog implements OnInit { } this.isSubmitting.set(true) - const formData = this.auditForm.value as CreateAuditRequest + const formValue = this.auditForm.getRawValue() this.auditRepository - .createAudit(this.projectId(), formData) + .createAudit(this.projectId(), formValue) .pipe( takeUntilDestroyed(this.destroyRef), catchError(err => { diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audit-details/audit-details.html b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audit-details/audit-details.html index c29eb76..8649bf2 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audit-details/audit-details.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audit-details/audit-details.html @@ -9,28 +9,30 @@ /> } @else if (audit) { -
-
-
-

{{ audit.name }}

- @if (audit.description) { -

{{ audit.description }}

- } -
-
- - @if (audit.overall_score !== null) { -
-
- {{ audit.overall_score }}/10 -
-
Overall Score
+
+
+

{{ audit.name }}

+ @if (audit.description) { +

+ {{ audit.description }} +

+ } +
+
+ + @if (audit.overall_score !== null) { +
+
+ {{ audit.overall_score }}/10
- } -
+
Overall Score
+
+ }
+

Findings

+ @if (audit.status === 'completed' && audit.results) {
diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.html b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.html index b783a92..cc0576d 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.html @@ -50,4 +50,4 @@

Audits

(auditCreated)="onAuditCreated()" /> - + diff --git a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.ts b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.ts index 1ca7e14..142751b 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/audits/containers/audits/audits.ts @@ -11,7 +11,6 @@ import { import { ActivatedRoute } from '@angular/router' import { Button } from 'primeng/button' import { ProgressSpinner } from 'primeng/progressspinner' -import { Toast } from 'primeng/toast' import { catchError, finalize, interval, of, Subscription, switchMap, takeWhile } from 'rxjs' import { AuditRepository } from '~/modules/dashboard/containers/projects/containers/audits/shared/repositories/audit.repository' import { EmptyState } from '~/shared/components/empty-state/empty-state' @@ -21,10 +20,19 @@ import { MessageService } from '~/core/services/message.service' import { AuditCard } from '../../components/audit-card/audit-card' import { CreateAuditDialog } from '../../components/create-audit-dialog/create-audit-dialog' import { AuditData } from '../../shared/interfaces/audit.interface' +import { ConfirmDialog } from 'primeng/confirmdialog' @Component({ selector: 'app-audits', - imports: [Button, ProgressSpinner, Toast, AuditCard, CreateAuditDialog, EmptyState, NgClass], + imports: [ + Button, + ProgressSpinner, + AuditCard, + CreateAuditDialog, + EmptyState, + NgClass, + ConfirmDialog, + ], templateUrl: './audits.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'flex flex-1 flex-col' }, @@ -63,9 +71,11 @@ export class Audits implements OnInit, OnDestroy { .getAudits(this.projectId()) .pipe( takeUntilDestroyed(this.destroyRef), - catchError(error => { - console.error('Failed to load audits:', error) - this.message.error('Error', 'Failed to load audits. Please try again.') + catchError(err => { + this.message.error( + 'Error', + `Failed to load audits. ${err.error?.message || err.message}`, + ) return of({ message: '', payload: [] }) }), finalize(() => this.isLoading.set(false)), diff --git a/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.html b/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.html index 819a837..b325b3b 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.html @@ -1,5 +1,3 @@ - - @let id = shownEmailId(); @if (id !== null) {
diff --git a/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.ts b/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.ts index 04debeb..6c2d7a1 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/email-templates/email-templates.ts @@ -10,7 +10,6 @@ import { FormsModule } from '@angular/forms' import { HttpClient } from '@angular/common/http' import { ActivatedRoute } from '@angular/router' import { catchError, of } from 'rxjs' -import { Toast } from 'primeng/toast' import { Button } from 'primeng/button' import { Drawer } from 'primeng/drawer' import { Select } from 'primeng/select' @@ -35,7 +34,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop' Drawer, Select, TabsModule, - Toast, ProgressSpinner, ExpandedImage, EmptyState, diff --git a/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.html b/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.html index 492367f..de5afdb 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.html @@ -27,3 +27,5 @@
+ + diff --git a/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.ts b/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.ts index 05da1fd..27ed9fe 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/project-layout/project-layout.ts @@ -1,9 +1,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router' +import { Toast } from 'primeng/toast' @Component({ selector: 'app-project-layout', - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [RouterOutlet, RouterLink, RouterLinkActive, Toast], templateUrl: './project-layout.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { diff --git a/client/src/app/modules/dashboard/containers/projects/containers/projects/projects.ts b/client/src/app/modules/dashboard/containers/projects/containers/projects/projects.ts index bb09e31..9078395 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/projects/projects.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/projects/projects.ts @@ -15,7 +15,7 @@ import { Textarea } from 'primeng/textarea' import { Message } from 'primeng/message' import { Toast } from 'primeng/toast' import { ProgressSpinner } from 'primeng/progressspinner' -import { HttpClient } from '@angular/common/http' +import { HttpErrorResponse } from '@angular/common/http' import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' import { catchError, of } from 'rxjs' import { EmptyState } from '~/shared/components/empty-state/empty-state' @@ -23,14 +23,8 @@ import { AuthService } from '~/core/services/auth.service' import { MessageService } from '~/core/services/message.service' import { ProjectCard } from '../../components/project-card/project-card' import { NgClass } from '@angular/common' - -interface Project { - id: number - name: string - description: string | null - created_at: string - updated_at: string -} +import { ProjectData } from '../../shared/interfaces/project-data.interface' +import { ProjectRepository } from '../../shared/repositories/project.repository' @Component({ selector: 'app-projects', @@ -52,16 +46,16 @@ interface Project { host: { class: 'grow' }, }) export class Projects implements OnInit { - authService = inject(AuthService) - fb = inject(NonNullableFormBuilder) - http = inject(HttpClient) - message = inject(MessageService) + private authService = inject(AuthService) + private message = inject(MessageService) + private projectRepository = inject(ProjectRepository) + private fb = inject(NonNullableFormBuilder) destroyRef = inject(DestroyRef) user = this.authService.getUser() showDialog = false - projects = signal([]) + projects = signal([]) isLoading = signal(true) projectForm = this.fb.group({ @@ -79,19 +73,20 @@ export class Projects implements OnInit { loadProjects() { this.isLoading.set(true) - this.http - .get>('/api/projects') + + this.projectRepository + .getProjects() .pipe( takeUntilDestroyed(this.destroyRef), - catchError(err => { + catchError((err: HttpErrorResponse) => { this.message.error( 'Error', `Failed to load projects. ${err.error?.message || err.message}`, ) - return of>({ message: '', payload: [] }) + return of>({ message: '', payload: [] }) }), ) - .subscribe(response => { + .subscribe((response: LaravelApiResponse) => { this.projects.set(response.payload || []) this.isLoading.set(false) }) @@ -102,25 +97,21 @@ export class Projects implements OnInit { return } - const formData = new FormData() - formData.append('name', this.projectForm.value.name as string) - formData.append('description', this.projectForm.value.description || '') + const formValue = this.projectForm.getRawValue() - this.http - .post>('/api/projects', { - name: this.projectForm.value.name as string, - }) + this.projectRepository + .createProject(formValue) .pipe( takeUntilDestroyed(this.destroyRef), - catchError(err => { + catchError((err: HttpErrorResponse) => { this.message.error( 'Error', `Failed to create project. ${err.error?.message || err.message}`, ) - return of>({ message: '', payload: null }) + return of>({ message: '', payload: null }) }), ) - .subscribe(response => { + .subscribe((response: LaravelApiResponse) => { if (response.payload) { this.projects.update(projects => [...projects, response.payload!]) this.projectForm.reset() diff --git a/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.html b/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.html index 5be4e16..2f630c6 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.html @@ -51,4 +51,4 @@ (onNewRelease)="addNewRelease($event)" /> - + diff --git a/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.ts b/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.ts index d1d0d67..a030a49 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/releases/releases.ts @@ -12,26 +12,24 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { catchError, of } from 'rxjs' import { ProgressSpinner } from 'primeng/progressspinner' import { Button } from 'primeng/button' -import { Toast } from 'primeng/toast' -import { ConfirmationService } from 'primeng/api' import { ReleaseCard } from '../../components/release-card/release-card' import { NewReleaseDialog } from '../../components/new-release-dialog/new-release-dialog' import { ReleaseData } from '../../shared/interfaces/release.interface' import { ReleaseRepository } from '../../shared/repositories/release.repository' import { MessageService } from '~/core/services/message.service' import { EmptyState } from '~/shared/components/empty-state/empty-state' +import { ConfirmDialog } from 'primeng/confirmdialog' @Component({ selector: 'app-releases', - imports: [Button, ReleaseCard, NewReleaseDialog, Toast, EmptyState, ProgressSpinner], - providers: [ConfirmationService], + imports: [Button, ReleaseCard, NewReleaseDialog, EmptyState, ProgressSpinner, ConfirmDialog], templateUrl: './releases.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class Releases implements OnInit { - route = inject(ActivatedRoute) - message = inject(MessageService) - releaseRepository = inject(ReleaseRepository) + private route = inject(ActivatedRoute) + private message = inject(MessageService) + private releaseRepository = inject(ReleaseRepository) destroyRef = inject(DestroyRef) showNewReleaseDialog = signal(false) diff --git a/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.html b/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.html index 4ab2a41..3c66e47 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.html +++ b/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.html @@ -1 +1,33 @@ -

settings works!

+
+
+ + +
+ +
+ + +
+ + + diff --git a/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.ts b/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.ts index a2d46fc..9f2da55 100644 --- a/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.ts +++ b/client/src/app/modules/dashboard/containers/projects/containers/settings/settings.ts @@ -1,11 +1,94 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit, + signal, +} from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { HttpErrorResponse } from '@angular/common/http' +import { Button } from 'primeng/button' +import { InputText } from 'primeng/inputtext' +import { ReactiveFormsModule, Validators, NonNullableFormBuilder } from '@angular/forms' +import { ActivatedRoute } from '@angular/router' +import { ProjectRepository } from '../../shared/repositories/project.repository' +import { ProjectData } from '../../shared/interfaces/project-data.interface' +import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' +import { MessageService } from '~/core/services/message.service' +import { Textarea } from 'primeng/textarea' +import { Toast } from 'primeng/toast' @Component({ - selector: 'app-settings', - imports: [], - templateUrl: './settings.html', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-settings', + imports: [ReactiveFormsModule, InputText, Button, Textarea, Toast], + templateUrl: './settings.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Settings { +export class Settings implements OnInit { + private fb = inject(NonNullableFormBuilder) + private route = inject(ActivatedRoute) + private projectRepository = inject(ProjectRepository) + private message = inject(MessageService) + destroyRef = inject(DestroyRef) + isSubmitting = signal(false) + + projectId = this.route.parent!.snapshot.paramMap.get('projectId')! + + basicInfoForm = this.fb.group({ + name: ['', Validators.required], + description: [''], + }) + + ngOnInit() { + this.projectRepository + .getProject(this.projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response: LaravelApiResponse) => { + this.basicInfoForm.patchValue({ + name: response.payload!.name, + description: response.payload!.description ?? '', + }) + }, + error: (err: HttpErrorResponse) => { + this.message.error( + 'Error', + `Failed to load project details. ${err.error?.message || err.message}`, + ) + }, + }) + } + + saveBasicInfo(): void { + if (this.basicInfoForm.invalid) { + return + } + + this.isSubmitting.set(true) + this.basicInfoForm.disable() + + const formValue = this.basicInfoForm.getRawValue() + + this.projectRepository + .updateProject(this.projectId, formValue) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.message.success('Success', 'Project updated successfully!') + + this.isSubmitting.set(false) + this.basicInfoForm.enable() + }, + error: (err: HttpErrorResponse) => { + this.message.error( + 'Error', + `Failed to update project. ${err.error?.message || err.message}`, + ) + this.isSubmitting.set(false) + this.basicInfoForm.enable() + }, + }) + } } diff --git a/client/src/app/modules/dashboard/containers/projects/shared/interfaces/project-data.interface.ts b/client/src/app/modules/dashboard/containers/projects/shared/interfaces/project-data.interface.ts new file mode 100644 index 0000000..64f831e --- /dev/null +++ b/client/src/app/modules/dashboard/containers/projects/shared/interfaces/project-data.interface.ts @@ -0,0 +1,7 @@ +export interface ProjectData { + id: number + name: string + description: string | null + created_at: string + updated_at: string +} diff --git a/client/src/app/modules/dashboard/containers/projects/shared/repositories/ai-chat.respository.ts b/client/src/app/modules/dashboard/containers/projects/shared/repositories/ai-chat.respository.ts new file mode 100644 index 0000000..f9263a2 --- /dev/null +++ b/client/src/app/modules/dashboard/containers/projects/shared/repositories/ai-chat.respository.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { AiChatMessageData } from '../interfaces/ai-chat-message-data.interface' +import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' + +export interface SendMessageResponse { + user: AiChatMessageData + ai: AiChatMessageData + template_updated?: boolean +} + +const e = encodeURIComponent + +interface SendMessageBody { + content: string + update_template: boolean +} + +@Injectable({ providedIn: 'root' }) +export class AiChatRepository { + protected http = inject(HttpClient) + + getChatMessages( + chatType: 'email-templates' | 'screens', + chatId: number, + ): Observable> { + return this.http.get>( + `/api/${e(chatType)}/${e(chatId)}/chats`, + ) + } + + sendMessage( + chatType: 'email-templates' | 'screens', + chatId: number, + { content, update_template = false }: SendMessageBody, + ): Observable> { + return this.http.post>( + `/api/${e(chatType)}/${e(chatId)}/chats`, + { + content, + update_template, + }, + ) + } +} diff --git a/client/src/app/modules/dashboard/containers/projects/shared/repositories/brevo.repository.ts b/client/src/app/modules/dashboard/containers/projects/shared/repositories/brevo.repository.ts index 1bd3fee..5301df6 100644 --- a/client/src/app/modules/dashboard/containers/projects/shared/repositories/brevo.repository.ts +++ b/client/src/app/modules/dashboard/containers/projects/shared/repositories/brevo.repository.ts @@ -26,12 +26,9 @@ export class BrevoRepository { projectId: string, brevoTemplateId: string, ): Observable { - const formData = new FormData() - formData.append('brevo_template_id', brevoTemplateId) - return this.http.post( `/api/projects/${projectId}/email-templates/import-brevo`, - formData, + { brevo_template_id: brevoTemplateId }, ) } diff --git a/client/src/app/modules/dashboard/containers/projects/shared/repositories/project.repository.ts b/client/src/app/modules/dashboard/containers/projects/shared/repositories/project.repository.ts new file mode 100644 index 0000000..fed1b37 --- /dev/null +++ b/client/src/app/modules/dashboard/containers/projects/shared/repositories/project.repository.ts @@ -0,0 +1,37 @@ +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface' +import { ProjectData } from '../interfaces/project-data.interface' + +const e = encodeURIComponent + +@Injectable({ providedIn: 'root' }) +export class ProjectRepository { + protected http = inject(HttpClient) + + getProjects(): Observable> { + return this.http.get>('/api/projects') + } + + getProject(projectId: string): Observable> { + return this.http.get>(`/api/projects/${e(projectId)}`) + } + + createProject(formValue: { + name: string + description?: string + }): Observable> { + return this.http.post>('/api/projects', formValue) + } + + updateProject( + projectId: string, + formValue: { name: string; description: string }, + ): Observable> { + return this.http.put>( + `/api/projects/${e(projectId)}`, + formValue, + ) + } +} diff --git a/client/src/app/shared/components/navbar/navbar.html b/client/src/app/shared/components/navbar/navbar.html index 2735109..8d403d9 100644 --- a/client/src/app/shared/components/navbar/navbar.html +++ b/client/src/app/shared/components/navbar/navbar.html @@ -13,7 +13,7 @@ Dashboard