mailFunc #2
@ -6,8 +6,16 @@
|
||||
|
||||
DATABASE_URL="postgresql://root:admin@localhost:5432/imk?schema=public"
|
||||
JWT_SECRET=some-secret
|
||||
JWT_RESET_SECRET=some-reset-secret-key
|
||||
AWS_REGION=EU2
|
||||
AWS_ACCESS_KEY_ID=4d2f5655369a02100375e3247d7e1fe6
|
||||
AWS_SECRET_ACCESS_KEY=6d4723e14c0d799b89948c24dbe983e4
|
||||
AWS_S3_BUCKET_NAME=imk-data
|
||||
AWS_ENDPOINT_URL=https://eu2.contabostorage.com
|
||||
AWS_ENDPOINT_URL=https://eu2.contabostorage.com
|
||||
SMTP_HOST=imk.mk
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=mailer@imk.mk
|
||||
SMTP_PASSWORD=76Avtostoperski76
|
||||
SMTP_FROM=mailer@imk.mk
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
ADMIN_EMAIL=petrovskidimitar@yandex.com
|
||||
2355
backend/imk-backend/package-lock.json
generated
2355
backend/imk-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,7 @@
|
||||
"@aws-sdk/client-s3": "^3.679.0",
|
||||
"@aws-sdk/lib-storage": "^3.679.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.679.0",
|
||||
"@nestjs-modules/mailer": "^1.6.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@ -36,6 +37,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@ -45,13 +47,14 @@
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/supertest": "^2.0.12",
|
||||
|
||||
@ -1,118 +1,3 @@
|
||||
// import {
|
||||
// Controller,
|
||||
// Get,
|
||||
// Post,
|
||||
// Body,
|
||||
// Param,
|
||||
// Put,
|
||||
// UseInterceptors,
|
||||
// UploadedFile,
|
||||
// ParseIntPipe,
|
||||
// UseGuards,
|
||||
// } from '@nestjs/common';
|
||||
// import { FileInterceptor } from '@nestjs/platform-express';
|
||||
// import { AdminService } from './admin.service';
|
||||
// //import { CreateDocumentDto } from '../dto/create-document.dto';
|
||||
// import { UpdateDocumentDto } from '../dto/update-document.dto';
|
||||
// import { AdminGuard } from '../auth/admin.guard';
|
||||
// import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
// import { CreateUserDto } from '../dto/create-user.dto';
|
||||
// import { S3Service } from 'src/s3/s3.service';
|
||||
// import { PrismaService } from 'src/prisma/prisma.service';
|
||||
|
||||
// @Controller('admin')
|
||||
// @UseGuards(JwtAuthGuard, AdminGuard)
|
||||
// export class AdminController {
|
||||
// constructor(
|
||||
// private readonly adminService: AdminService,
|
||||
// private readonly s3Service: S3Service,
|
||||
// private readonly prisma: PrismaService,
|
||||
// ) {}
|
||||
|
||||
// @Get('documents')
|
||||
// getAllDocuments() {
|
||||
// return this.adminService.getAllDocuments();
|
||||
// }
|
||||
|
||||
// @Put('documents/:id')
|
||||
// @UseInterceptors(FileInterceptor('file'))
|
||||
// updateDocument(
|
||||
// @Param('id', ParseIntPipe) id: number,
|
||||
// @Body() updateDocumentDto: UpdateDocumentDto,
|
||||
// @UploadedFile() file?: Express.Multer.File,
|
||||
// ) {
|
||||
// return this.adminService.updateDocument(id, updateDocumentDto, file);
|
||||
// }
|
||||
|
||||
// @Get('users')
|
||||
// getAllUsers() {
|
||||
// return this.adminService.getAllUsers();
|
||||
// }
|
||||
// @Post('test-document')
|
||||
// async testDocumentCreation() {
|
||||
// try {
|
||||
// const document = await this.prisma.document.create({
|
||||
// data: {
|
||||
// title: 'Test Document',
|
||||
// s3Key: 'test-key',
|
||||
// status: 'completed',
|
||||
// sharedWithId: 2, // ID of 'pero' user
|
||||
// },
|
||||
// });
|
||||
// return document;
|
||||
// } catch (error) {
|
||||
// console.error('Test document creation error:', error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// @Post('users')
|
||||
// async createUser(@Body() createUserDto: CreateUserDto) {
|
||||
// return this.adminService.createUser(createUserDto);
|
||||
// }
|
||||
|
||||
// @Post('documents/:id/share')
|
||||
// async shareDocument(
|
||||
// @Param('id') id: string,
|
||||
// @Body() { userId }: { userId: number },
|
||||
// ) {
|
||||
// return this.adminService.shareDocument(+id, userId);
|
||||
// }
|
||||
|
||||
// @Put('documents/:id/status')
|
||||
// async updateDocumentStatus(
|
||||
// @Param('id') id: string,
|
||||
// @Body() { status }: { status: string },
|
||||
// ) {
|
||||
// return this.adminService.updateDocumentStatus(+id, status);
|
||||
// }
|
||||
|
||||
// @Post('documents')
|
||||
// @UseInterceptors(FileInterceptor('file'))
|
||||
// async uploadDocument(
|
||||
// @UploadedFile() file: Express.Multer.File,
|
||||
// @Body('title') title: string,
|
||||
// @Body('sharedWithId') sharedWithId: number,
|
||||
// @Body('uploadedById') uploadedById: number
|
||||
// ) {
|
||||
// const document = await this.adminService.uploadDocument(
|
||||
// file,
|
||||
// title,
|
||||
// sharedWithId,
|
||||
// uploadedById // Add this missing parameter
|
||||
// );
|
||||
// return document;
|
||||
// }
|
||||
// @Get('test-s3-connection')
|
||||
// async testS3Connection() {
|
||||
// const isConnected = await this.s3Service.testConnection();
|
||||
// if (isConnected) {
|
||||
// return { message: 'Successfully connected to S3' };
|
||||
// } else {
|
||||
// return { message: 'Failed to connect to S3' };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -160,59 +45,41 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('documents')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadDocument(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('title') title: string,
|
||||
@Body('sharedWithId') sharedWithId: string, // Accept as string first
|
||||
@Body('uploadedById') uploadedById: string // Accept as string first
|
||||
) {
|
||||
if (!sharedWithId || !uploadedById) {
|
||||
throw new BadRequestException('sharedWithId and uploadedById are required');
|
||||
}
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadDocument(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('title') title: string,
|
||||
@Body('sharedWithId') sharedWithId: string, // Accept as string first
|
||||
@Body('uploadedById') uploadedById: string // Accept as string first
|
||||
) {
|
||||
if (!sharedWithId || !uploadedById) {
|
||||
throw new BadRequestException('sharedWithId and uploadedById are required');
|
||||
}
|
||||
|
||||
// Parse the string values to numbers
|
||||
const parsedSharedWithId = parseInt(sharedWithId, 10);
|
||||
const parsedUploadedById = parseInt(uploadedById, 10);
|
||||
// Parse the string values to numbers
|
||||
const parsedSharedWithId = parseInt(sharedWithId, 10);
|
||||
const parsedUploadedById = parseInt(uploadedById, 10);
|
||||
|
||||
// Validate that the parsing was successful
|
||||
if (isNaN(parsedSharedWithId) || isNaN(parsedUploadedById)) {
|
||||
throw new BadRequestException('sharedWithId and uploadedById must be valid numbers');
|
||||
}
|
||||
// Validate that the parsing was successful
|
||||
if (isNaN(parsedSharedWithId) || isNaN(parsedUploadedById)) {
|
||||
throw new BadRequestException('sharedWithId and uploadedById must be valid numbers');
|
||||
}
|
||||
|
||||
const document = await this.adminService.uploadDocument(
|
||||
file,
|
||||
title,
|
||||
parsedSharedWithId,
|
||||
parsedUploadedById
|
||||
);
|
||||
return document;
|
||||
}
|
||||
|
||||
const document = await this.adminService.uploadDocument(
|
||||
file,
|
||||
title,
|
||||
parsedSharedWithId,
|
||||
parsedUploadedById
|
||||
);
|
||||
return document;
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
getAllUsers() {
|
||||
return this.adminService.getAllUsers();
|
||||
}
|
||||
|
||||
// @Post('test-document')
|
||||
// async testDocumentCreation() {
|
||||
// try {
|
||||
// const document = await this.prisma.document.create({
|
||||
// data: {
|
||||
// title: 'Test Document',
|
||||
// s3Key: 'test-key',
|
||||
// status: 'completed',
|
||||
// sharedWith: {
|
||||
// connect: { id: 2 } // ID of 'pero' user
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
// return document;
|
||||
// } catch (error) {
|
||||
// console.error('Test document creation error:', error);
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
@Post('users')
|
||||
async createUser(@Body() createUserDto: CreateUserDto) {
|
||||
@ -245,4 +112,4 @@ async uploadDocument(
|
||||
// return { message: 'Failed to connect to S3' };
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,11 @@ import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { S3Module } from '../s3/s3.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
imports: [PrismaModule, S3Module],
|
||||
imports: [PrismaModule, S3Module, EmailModule],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@ -4,12 +4,14 @@ import { S3Service } from '../s3/s3.service';
|
||||
import { UpdateDocumentDto } from '../dto/update-document.dto';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { EmailService } from 'src/email/email.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly s3Service: S3Service,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async getAllDocuments() {
|
||||
@ -41,14 +43,22 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async createUser(createUserDto: CreateUserDto) {
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...createUserDto,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: { ...createUserDto, password: hashedPassword},
|
||||
});
|
||||
try {
|
||||
await this.emailService.sendWelcomeEmail(user.email, user.name );
|
||||
console.log('Welcome email sent successfully');
|
||||
} catch (error) {
|
||||
console.log('Error sending welcome email:', error);
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error );
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateDocument(
|
||||
@ -71,14 +81,32 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async shareDocument(documentId: number, userId: number) {
|
||||
return this.prisma.document.update({
|
||||
const document = await this.prisma.document.update({
|
||||
where: { id: documentId },
|
||||
data: {
|
||||
sharedWith: {
|
||||
connect: { id: userId },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
uploadedBy: true,
|
||||
sharedWith: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sharedWithUser = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
// Send email notification
|
||||
await this.emailService.sendDocumentSharedNotification(
|
||||
sharedWithUser.email,
|
||||
document.title,
|
||||
document.uploadedBy.name,
|
||||
);
|
||||
console.log('Document shared with user:', sharedWithUser.email);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
async updateDocumentStatus(documentId: number, status: string) {
|
||||
@ -93,7 +121,9 @@ export class AdminService {
|
||||
title: string,
|
||||
sharedWithId: number,
|
||||
uploadedById: number
|
||||
) {
|
||||
)
|
||||
|
||||
{
|
||||
const s3Key = await this.s3Service.uploadFile(file, 'documents');
|
||||
|
||||
return this.prisma.document.create({
|
||||
@ -126,4 +156,5 @@ export class AdminService {
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -15,18 +15,11 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthController } from './auth/auth.controller';
|
||||
import { DocumentsController } from './documents/documents.controller';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { EmailModule } from './email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// TypeOrmModule.forRoot({
|
||||
// type: 'postgres',
|
||||
// host: 'localhost',
|
||||
// port: 5432,
|
||||
// username: 'root',
|
||||
// password: 'admin',
|
||||
// database: 'imk',
|
||||
// synchronize: true,
|
||||
// }),
|
||||
EmailModule,
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
UseGuards,
|
||||
Get,
|
||||
Request,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
@ -37,7 +38,24 @@ export class AuthController {
|
||||
//@UseGuards(JwtAuthGuard)
|
||||
@Post('create-admin')
|
||||
async createAdmin(@Body() createUserDto: CreateUserDto) {
|
||||
return this.authService.createUser(createUserDto, true);
|
||||
return this.authService.createUser(createUserDto);
|
||||
}
|
||||
@Post('forgot-password')
|
||||
async forgotPassword(@Body('email') email: string) {
|
||||
if (!email) {
|
||||
throw new BadRequestException('Email is required');
|
||||
}
|
||||
return this.authService.requestPasswordReset(email);
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
async resetPassword(
|
||||
@Body() body: { token: string; newPassword: string },
|
||||
) {
|
||||
if (!body.token || !body.newPassword) {
|
||||
throw new BadRequestException('Token and new password are required');
|
||||
}
|
||||
return this.authService.resetPassword(body.token, body.newPassword);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@ -7,6 +7,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConstants } from './constants';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { EmailModule } from 'src/email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -16,6 +17,7 @@ import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
secret: jwtConstants.secret,
|
||||
signOptions: { expiresIn: '60m' },
|
||||
}),
|
||||
EmailModule,
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EmailService } from 'src/email/email.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, password: string): Promise<any> {
|
||||
@ -39,46 +41,51 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(
|
||||
createUserDto: CreateUserDto,
|
||||
isAdmin: boolean = false,
|
||||
): Promise<any> {
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: createUserDto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email already exists');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
const newUser = await this.prisma.user.create({
|
||||
async createUser(createUserDto: CreateUserDto) {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: createUserDto.email,
|
||||
password: hashedPassword,
|
||||
name: createUserDto.name,
|
||||
isAdmin: isAdmin,
|
||||
...createUserDto,
|
||||
password: await bcrypt.hash(createUserDto.password, 10),
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password, ...result } = newUser;
|
||||
console.log(result);
|
||||
return result;
|
||||
// Send welcome email
|
||||
await this.emailService.sendWelcomeEmail(user.email, user.name);
|
||||
return user;
|
||||
}
|
||||
|
||||
// async getUserInfo(userId: number) {
|
||||
// return this.prisma.user.findUnique({
|
||||
// where: { id: userId },
|
||||
// select: {
|
||||
// id: true,
|
||||
// name: true,
|
||||
// email: true,
|
||||
// isAdmin: true,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
async requestPasswordReset(email: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const resetToken = this.jwtService.sign(
|
||||
{ email, userId: user.id },
|
||||
{ expiresIn: '1h', secret: process.env.JWT_RESET_SECRET },
|
||||
);
|
||||
|
||||
await this.emailService.sendPasswordResetEmail(email, resetToken);
|
||||
return { message: 'Password reset instructions sent to your email' };
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token, {
|
||||
secret: process.env.JWT_RESET_SECRET,
|
||||
});
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { email: payload.email },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
|
||||
return { message: 'Password reset successful' };
|
||||
} catch (error) {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
}
|
||||
async getUserInfo(userId: number) {
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required');
|
||||
|
||||
@ -2,14 +2,17 @@ import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nest
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { S3Service } from '../s3/s3.service';
|
||||
import { Document, User, Prisma } from '@prisma/client';
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
private readonly logger = new Logger(DocumentsService.name);
|
||||
//private readonly Logger = new Logger(DocumentsService.user);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly s3Service: S3Service
|
||||
private readonly s3Service: S3Service,
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
async findDocumentByS3Key(s3Key: string) {
|
||||
@ -116,5 +119,7 @@ export class DocumentsService {
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(title, sharedWithId, uploadedById)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
36
backend/imk-backend/src/email/email.controller.ts
Normal file
36
backend/imk-backend/src/email/email.controller.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
// import { MailerService } from '@nestjs-modules/mailer';
|
||||
class ContactFormDto {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Controller('email')
|
||||
export class EmailController {
|
||||
constructor(private emailService: EmailService) {}
|
||||
|
||||
@Post()
|
||||
async sendContactEmail(@Body() contactData: {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}) {
|
||||
await this.emailService.sendContactEmail(
|
||||
contactData.name,
|
||||
contactData.email,
|
||||
contactData.message
|
||||
);
|
||||
return { message: 'Contact form submitted successfully' };
|
||||
}
|
||||
@Post('contact')
|
||||
async handleContactForm(@Body() contactData: ContactFormDto) {
|
||||
await this.emailService.sendContactEmail(
|
||||
contactData.name,
|
||||
contactData.email,
|
||||
contactData.message
|
||||
);
|
||||
return { message: 'Thank you for your message. We will contact you soon.' };
|
||||
}
|
||||
}
|
||||
32
backend/imk-backend/src/email/email.module.ts
Normal file
32
backend/imk-backend/src/email/email.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { EmailService } from './email.service';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EmailController } from './email.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (config: ConfigService) => ({
|
||||
transport: {
|
||||
host: config.get('SMTP_HOST'),
|
||||
port: config.get('SMTP_PORT'),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: config.get('SMTP_USER'),
|
||||
pass: config.get('SMTP_PASSWORD'),
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
from: `"No Reply" <${config.get('SMTP_FROM')}>`,
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [EmailController],
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
82
backend/imk-backend/src/email/email.service.ts
Normal file
82
backend/imk-backend/src/email/email.service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Injectable, Post, Body } from '@nestjs/common';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private mailerService: MailerService) {}
|
||||
|
||||
async sendWelcomeEmail(email: string, name: string) {
|
||||
await this.mailerService.sendMail({
|
||||
to: email,
|
||||
subject: 'Welcome to IMK Platform',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2563eb;">Welcome to IMK Platform!</h1>
|
||||
<p>Dear ${name},</p>
|
||||
<p>Thank you for joining IMK Platform. Your account has been created successfully.</p>
|
||||
<p>You can now log in to access your documents and start collaborating with your team.</p>
|
||||
<p>If you have any questions or need assistance, please don't hesitate to contact our support team.</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="${process.env.FRONTEND_URL}/login"
|
||||
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
|
||||
Login to Your Account
|
||||
</a>
|
||||
</div>
|
||||
<p>Best regards,<br>IMK Team</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail(email: string, resetToken: string) {
|
||||
const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
|
||||
await this.mailerService.sendMail({
|
||||
to: email,
|
||||
subject: 'Password Reset Request',
|
||||
html: `
|
||||
<h1>Password Reset</h1>
|
||||
<p>Click the link below to reset your password:</p>
|
||||
<a href="${resetLink}">Reset Password</a>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendDocumentSharedNotification(email: string, documentTitle: string, sharedByName: string) {
|
||||
await this.mailerService.sendMail({
|
||||
to: email,
|
||||
subject: 'New Document Shared With You',
|
||||
html: `
|
||||
<h1>New Document Available</h1>
|
||||
<p>${sharedByName} has shared a document with you: "${documentTitle}"</p>
|
||||
<p>Login to your account to view the document.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
async sendContactEmail(name: string, email: string, message: string) {
|
||||
// Send to admin
|
||||
await this.mailerService.sendMail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `New Contact Form Submission from ${name}`,
|
||||
html: `
|
||||
<h2>New Contact Form Submission</h2>
|
||||
<p><strong>From:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> ${email}</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${message}</p>
|
||||
`,
|
||||
});
|
||||
await this.mailerService.sendMail({
|
||||
to: email,
|
||||
subject: 'Thank you for contacting us',
|
||||
html: `
|
||||
<h2>Thank you for contacting us</h2>
|
||||
<p>Dear ${name},</p>
|
||||
<p>We have received your message and will get back to you soon.</p>
|
||||
<p>Your message:</p>
|
||||
<blockquote>${message}</blockquote>
|
||||
<p>Best regards,<br>IMK Team</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ import AdminPanel from './components/adminPanel/AdminPanel';
|
||||
import Dashboard from './components/dashboard/Dashboard';
|
||||
import Login from './components/login/login';
|
||||
import ProtectedRoute from './components/protectedRoute/ProtectedRoute';
|
||||
import ForgotPassword from './components/ForgotPassword';
|
||||
import ResetPassword from './components/ResetPassword';
|
||||
|
||||
function App() {
|
||||
|
||||
@ -33,6 +35,8 @@ function App() {
|
||||
<Route path='/ultrasound' element={<UltraSound />} />
|
||||
<Route path='/gallery' element={<Gallery />} />
|
||||
<Route path='/certificates' element={<Certificates />} />
|
||||
<Route path='/forgot-password' element={<ForgotPassword />} />
|
||||
<Route path='/reset-password' element={<ResetPassword />} />
|
||||
<Route path='/clients' element={
|
||||
<ProtectedRoute>
|
||||
<Clients />
|
||||
|
||||
69
frontend/imk/src/components/ForgotPassword.jsx
Normal file
69
frontend/imk/src/components/ForgotPassword.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [status, setStatus] = useState({ type: '', message: '' });
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setStatus({ type: 'loading', message: 'Sending reset instructions...' });
|
||||
|
||||
try {
|
||||
await axios.post(`http://localhost:3000/auth/forgot-password`, { email });
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: 'Password reset instructions have been sent to your email.',
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: error.response?.data?.message || 'An error occurred. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-center text-3xl font-extrabold text-white">
|
||||
Reset your password
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-white">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 bg-gray-700 text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.message && (
|
||||
<div className={`text-sm ${status.type === 'error' ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.type === 'loading'}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{status.type === 'loading' ? 'Sending...' : 'Send Reset Instructions'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/imk/src/components/ResetPassword.jsx
Normal file
94
frontend/imk/src/components/ResetPassword.jsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [status, setStatus] = useState({ type: '', message: '' });
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setStatus({ type: 'error', message: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ type: 'loading', message: 'Resetting password...' });
|
||||
const token = searchParams.get('token');
|
||||
|
||||
try {
|
||||
await axios.post(`http://localhost:3000/auth/reset-password`, {
|
||||
token,
|
||||
newPassword: password,
|
||||
});
|
||||
setStatus({ type: 'success', message: 'Password reset successful!' });
|
||||
setTimeout(() => navigate('/login'), 2000);
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: error.response?.data?.message || 'An error occurred. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="text-center text-3xl font-extrabold text-white">
|
||||
Reset your password
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 bg-gray-700 text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-white">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 bg-gray-700 text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.message && (
|
||||
<div className={`text-sm ${status.type === 'error' ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.type === 'loading'}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{status.type === 'loading' ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { getSharedDocuments } from '../../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import { FiFolder, FiFile, FiDownload, FiChevronRight, FiLoader } from 'react-icons/fi';
|
||||
import { FiFolder, FiFile, FiDownload, FiChevronRight, FiLoader, FiUser, FiKey, FiMail } from 'react-icons/fi';
|
||||
import { downloadDocument } from '../../services/api';
|
||||
|
||||
function Dashboard() {
|
||||
@ -11,6 +12,13 @@ function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const { user } = useAuth();
|
||||
const [userInfo, setUserInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUserInfo(user);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocuments = async () => {
|
||||
@ -88,10 +96,47 @@ function Dashboard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<header className="mb-8 mt-20">
|
||||
{/* <header className="mb-8 mt-20">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Your Documents</h1>
|
||||
<p className="text-gray-400">Access and manage your shared documents</p>
|
||||
</header>
|
||||
</header> */}
|
||||
{/* User Info Card */}
|
||||
<div className="bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-indigo-600 rounded-full">
|
||||
<FiUser className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Welcome, {userInfo?.name}</h2>
|
||||
<p className="text-gray-400">{userInfo?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="flex items-center space-x-3 p-4 bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<FiKey className="w-5 h-5 text-indigo-500" />
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Reset Password</h3>
|
||||
<p className="text-sm text-gray-400">Change your current password</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="flex items-center space-x-3 p-4 bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<FiMail className="w-5 h-5 text-indigo-500" />
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Forgot Password</h3>
|
||||
<p className="text-sm text-gray-400">Request a password reset link</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
|
||||
<div className="grid gap-6">
|
||||
{Object.entries(documents).length > 0 ? (
|
||||
@ -144,7 +189,8 @@ function Dashboard() {
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No documents available</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,115 +1,126 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { Switch } from '@headlessui/react'
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Contact() {
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
});
|
||||
const [status, setStatus] = useState({ type: '', message: '' });
|
||||
|
||||
return (
|
||||
<div className="isolate bg-gray px-6 bg-gradient-to-b from-cyan-900 to-cyan-800 sm:py-32 lg:px-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
>
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setStatus({ type: 'loading', message: 'Sending...' });
|
||||
|
||||
try {
|
||||
await axios.post(`http://localhost:3000/email/contact`, formData);
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: 'Thank you for your message. We will contact you soon.'
|
||||
});
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
// Redirect to home page after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: 'There was an error sending your message. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="isolate bg-gray-900 px-6 py-24 sm:py-32 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Контактирајте нѐ</h2>
|
||||
<p className="mt-2 text-lg leading-8 text-gray-300">
|
||||
Имате прашања? Ние сме тука да помогнеме.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="mx-auto mt-16 max-w-xl sm:mt-20">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="name" className="block text-sm font-semibold leading-6 text-white">
|
||||
Име и Презиме
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full rounded-md border-0 bg-white/5 px-3.5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl text-center mt-10">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-3xl">Пишете ни порака | Закажете средба</h2>
|
||||
{/* <p className="mt-2 text-lg leading-8 text-gray-600">
|
||||
take a wheel or let it slide
|
||||
|
||||
</p> */}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="email" className="block text-sm font-semibold leading-6 text-white">
|
||||
Емаил
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full rounded-md border-0 bg-white/5 px-3.5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
<form action="https://formsubmit.co/taratur@gmail.com" method="POST" className="mx-auto mt-16 max-w-xl sm:mt-20">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="first-name" className="block text-sm font-semibold leading-6 text-white">
|
||||
Име
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="text"
|
||||
name="first-name"
|
||||
id="first-name"
|
||||
autoComplete="given-name"
|
||||
className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last-name" className="block text-sm font-semibold leading-6 text-white">
|
||||
Презиме
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="text"
|
||||
name="last-name"
|
||||
id="last-name"
|
||||
autoComplete="family-name"
|
||||
className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="company" className="block text-sm font-semibold leading-6 text-white">
|
||||
Компанија
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
id="company"
|
||||
autoComplete="organization"
|
||||
className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="email" className="block text-sm font-semibold leading-6 text-white">
|
||||
Мејл
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
autoComplete="email"
|
||||
className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="message" className="block text-sm font-semibold leading-6 text-white">
|
||||
Порака
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
className="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
defaultValue={''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Switch.Group as="div" className="flex gap-x-4 sm:col-span-2">
|
||||
</Switch.Group>
|
||||
</div>
|
||||
<input type="hidden" name="_next" value="https://imk.mk/"></input>
|
||||
<div className="mt-10">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full rounded-md bg-gray-500 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
||||
>
|
||||
Испрати
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="message" className="block text-sm font-semibold leading-6 text-white">
|
||||
Порака
|
||||
</label>
|
||||
<div className="mt-2.5">
|
||||
<textarea
|
||||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full rounded-md border-0 bg-white/5 px-3.5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
{status.message && (
|
||||
<div className={`mt-4 text-center ${status.type === 'error' ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status.type === 'loading'}
|
||||
className="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status.type === 'loading' ? 'Испраќање...' : 'Испрати Порака'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2962
node_modules/.package-lock.json
generated
vendored
2962
node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
2977
package-lock.json
generated
2977
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"axios": "^1.7.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.11",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user