diff --git a/bizmatch-server/src/file/file.service.ts b/bizmatch-server/src/file/file.service.ts index 11d9b16..88f23e6 100644 --- a/bizmatch-server/src/file/file.service.ts +++ b/bizmatch-server/src/file/file.service.ts @@ -80,13 +80,14 @@ export class FileService { return false } } - async storePropertyPicture(file: Express.Multer.File, listingId: string) { + async storePropertyPicture(file: Express.Multer.File, listingId: string) : Promise { const suffix = file.mimetype.includes('png') ? 'png' : 'jpg' const directory = `./pictures/property/${listingId}` fs.ensureDirSync(`${directory}`); const imageName = await this.getNextImageName(directory); //await fs.outputFile(`${directory}/${imageName}`, file.buffer); await this.resizeImageToAVIF(file.buffer,150 * 1024,imageName,directory); + return `${imageName}.avif` } async getNextImageName(directory) { try { diff --git a/bizmatch-server/src/image/image.controller.ts b/bizmatch-server/src/image/image.controller.ts index 01d14ac..12a9232 100644 --- a/bizmatch-server/src/image/image.controller.ts +++ b/bizmatch-server/src/image/image.controller.ts @@ -4,11 +4,15 @@ import { Logger } from 'winston'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileService } from '../file/file.service.js'; import { SelectOptionsService } from '../select-options/select-options.service.js'; +import { ListingsService } from '../listings/listings.service.js'; +import { CommercialPropertyListing } from 'src/models/main.model.js'; +import { Entity, EntityData } from 'redis-om'; @Controller('image') export class ImageController { constructor(private fileService:FileService, + private listingService:ListingsService, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, private selectOptions:SelectOptionsService) { } @@ -16,7 +20,8 @@ export class ImageController { @Post('uploadPropertyPicture/:id') @UseInterceptors(FileInterceptor('file'),) async uploadPropertyPicture(@UploadedFile() file: Express.Multer.File,@Param('id') id:string) { - await this.fileService.storePropertyPicture(file,id); + const imagename = await this.fileService.storePropertyPicture(file,id); + await this.listingService.addImage(id,imagename); } @Post('uploadProfile/:id') @@ -33,7 +38,16 @@ export class ImageController { @Get(':id') async getPropertyImagesById(@Param('id') id:string): Promise { - return await this.fileService.getPropertyImages(id); + const result = await this.listingService.getCommercialPropertyListingById(id); + const listing = result as CommercialPropertyListing; + if (listing.imageOrder){ + return listing.imageOrder + } else { + const imageOrder = await this.fileService.getPropertyImages(id); + listing.imageOrder=imageOrder; + this.listingService.saveListing(listing); + return imageOrder; + } } @Get('profileImages/:userids') async getProfileImagesForUsers(@Param('userids') userids:string): Promise { @@ -43,9 +57,11 @@ export class ImageController { async getCompanyLogosForUsers(@Param('userids') userids:string): Promise { return await this.fileService.getCompanyLogosForUsers(userids); } + @Delete('propertyPicture/:listingid/:imagename') async deletePropertyImagesById(@Param('listingid') listingid:string,@Param('imagename') imagename:string): Promise { - this.fileService.deleteImage(`pictures/property/${listingid}/${imagename}`) + this.fileService.deleteImage(`pictures/property/${listingid}/${imagename}`); + await this.listingService.deleteImage(listingid,imagename); } @Delete('logo/:userid/') async deleteLogoImagesById(@Param('id') id:string): Promise { diff --git a/bizmatch-server/src/image/image.module.ts b/bizmatch-server/src/image/image.module.ts index c9db704..57d2b89 100644 --- a/bizmatch-server/src/image/image.module.ts +++ b/bizmatch-server/src/image/image.module.ts @@ -3,8 +3,11 @@ import { ImageController } from './image.controller.js'; import { ImageService } from './image.service.js'; import { FileService } from '../file/file.service.js'; import { SelectOptionsService } from '../select-options/select-options.service.js'; +import { ListingsService } from '../listings/listings.service.js'; +import { ListingsModule } from '../listings/listings.module.js'; @Module({ + imports: [ListingsModule], controllers: [ImageController], providers: [ImageService,FileService,SelectOptionsService] }) diff --git a/bizmatch-server/src/listings/commercial-property-listings.controller.ts b/bizmatch-server/src/listings/commercial-property-listings.controller.ts index 61be7e5..6577ee0 100644 --- a/bizmatch-server/src/listings/commercial-property-listings.controller.ts +++ b/bizmatch-server/src/listings/commercial-property-listings.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Delete, Get, Inject, Param, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Inject, Param, Post, Put, UploadedFile, UseInterceptors } from '@nestjs/common'; import { ListingsService } from './listings.service.js'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileService } from '../file/file.service.js'; +import { CommercialPropertyListing, ImageProperty } from 'src/models/main.model.js'; @Controller('listings/commercialProperty') export class CommercialPropertyListingsController { @@ -21,7 +22,11 @@ export class CommercialPropertyListingsController { find(@Body() criteria: any): any { return this.listingsService.findCommercialPropertyListings(criteria); } - + + @Put('imageOrder/:id') + async changeImageOrder(@Param('id') id:string,@Body() imageOrder: ImageProperty[]) { + this.listingsService.updateImageOrder(id, imageOrder) + } /** * @param listing creates a new listing */ diff --git a/bizmatch-server/src/listings/listings.module.ts b/bizmatch-server/src/listings/listings.module.ts index 1bb5da1..bb9815d 100644 --- a/bizmatch-server/src/listings/listings.module.ts +++ b/bizmatch-server/src/listings/listings.module.ts @@ -12,6 +12,7 @@ import { UserService } from '../user/user.service.js'; @Module({ imports: [RedisModule], controllers: [BusinessListingsController, CommercialPropertyListingsController,UnknownListingsController,BrokerListingsController], - providers: [ListingsService,FileService,UserService] + providers: [ListingsService,FileService,UserService], + exports: [ListingsService], }) export class ListingsModule {} diff --git a/bizmatch-server/src/listings/listings.service.ts b/bizmatch-server/src/listings/listings.service.ts index 72812f0..4b0a48c 100644 --- a/bizmatch-server/src/listings/listings.service.ts +++ b/bizmatch-server/src/listings/listings.service.ts @@ -3,7 +3,8 @@ import { BusinessListing, CommercialPropertyListing, ListingCriteria, - ListingType + ListingType, + ImageProperty } from '../models/main.model.js'; import { convertStringToNullUndefined } from '../utils.js'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; @@ -77,8 +78,8 @@ export class ListingsService { } return result; } - async getCommercialPropertyListingById(id: string) { - return await this.commercialPropertyListingRepository.fetch(id) + async getCommercialPropertyListingById(id: string): Promise{ + return await this.commercialPropertyListingRepository.fetch(id) as unknown as CommercialPropertyListing; } async getBusinessListingById(id: string) { return await this.businessListingRepository.fetch(id) @@ -134,4 +135,23 @@ export class ListingsService { } return listings } + + async updateImageOrder(id:string,imageOrder: ImageProperty[]){ + const listing = await this.getCommercialPropertyListingById(id) as unknown as CommercialPropertyListing + listing.imageOrder=imageOrder; + this.saveListing(listing); + } + async deleteImage(listingid:string,name:string,){ + const listing = await this.getCommercialPropertyListingById(listingid) as unknown as CommercialPropertyListing + const index = listing.imageOrder.findIndex(im=>im.name===name); + if (index>-1){ + listing.imageOrder.splice(index,1); + this.saveListing(listing); + } + } + async addImage(id:string,imagename: string){ + const listing = await this.getCommercialPropertyListingById(id) as unknown as CommercialPropertyListing + listing.imageOrder.push({name:imagename,code:'',id:''}); + this.saveListing(listing); + } } diff --git a/bizmatch/package.json b/bizmatch/package.json index 008142f..ae089e1 100644 --- a/bizmatch/package.json +++ b/bizmatch/package.json @@ -30,6 +30,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.1", "@types/uuid": "^9.0.8", "angular-cropperjs": "^14.0.1", + "angular-mixed-cdk-drag-drop": "^2.2.3", "browser-bunyan": "^1.8.0", "cropperjs": "^1.6.1", "express": "^4.18.2", diff --git a/bizmatch/src/app/components/image-cropper/image-cropper.component.ts b/bizmatch/src/app/components/image-cropper/image-cropper.component.ts index e44c561..ee13b12 100644 --- a/bizmatch/src/app/components/image-cropper/image-cropper.component.ts +++ b/bizmatch/src/app/components/image-cropper/image-cropper.component.ts @@ -44,13 +44,15 @@ export class ImageCropperComponent { } } sendImage(){ - this.loadingService.startLoading('uploadImage'); - setTimeout(()=>{ - this.angularCropper.cropper.getCroppedCanvas().toBlob(async(blob) => { - this.fileUpload.clear() - this.ref.close(blob); - }, 'image/png'); - }) + // setTimeout(()=>{ + // this.angularCropper.cropper.getCroppedCanvas().toBlob(async(blob) => { + // this.ref.close(blob); + // this.fileUpload.clear() + // }, 'image/jpg'); + // },0) + + this.fileUpload.clear() + this.ref.close(this.angularCropper.cropper); } cancelUpload(){ diff --git a/bizmatch/src/app/pages/home/home.component.html b/bizmatch/src/app/pages/home/home.component.html index 9a120e7..41de0c6 100644 --- a/bizmatch/src/app/pages/home/home.component.html +++ b/bizmatch/src/app/pages/home/home.component.html @@ -8,7 +8,7 @@
@if(userService.isLoggedIn()){ - + } @else { } diff --git a/bizmatch/src/app/pages/listings/listings.component.html b/bizmatch/src/app/pages/listings/listings.component.html index 9bc1a14..6c9cc27 100644 --- a/bizmatch/src/app/pages/listings/listings.component.html +++ b/bizmatch/src/app/pages/listings/listings.component.html @@ -86,37 +86,38 @@
} @for (listing of filteredListings; track listing.id) { -
-
-
-
- Image -

{{selectOptions.getState(listing.state)}}

-
-
-
-

{{listing.title}}

- +
+
+
+
+ @if (listing.imageOrder.length>0){ + Image + } @else { + + Image + } +

{{selectOptions.getState(listing.state)}}

-

{{listing.city}}

-
-

- - {{selectOptions.getCommercialProperty(listing.type)}} -

- +
+
+

{{listing.title}}

+ +
+

{{listing.city}}

+
+

+ + {{selectOptions.getCommercialProperty(listing.type)}} +

+
+

{{listing.price | currency}}

-

{{listing.price | currency}}

-
-
-
- -
-
+
+
+ +
+
} @@ -126,7 +127,7 @@
@if(user.hasProfile){ - + } @else { } @@ -147,9 +148,7 @@ class="p-button-rounded p-button-success" [routerLink]="['/details-user',user.id]">
- + } diff --git a/bizmatch/src/app/pages/listings/listings.component.ts b/bizmatch/src/app/pages/listings/listings.component.ts index bcb2307..b04cb6a 100644 --- a/bizmatch/src/app/pages/listings/listings.component.ts +++ b/bizmatch/src/app/pages/listings/listings.component.ts @@ -43,6 +43,7 @@ export class ListingsComponent { first: number = 0; rows: number = 12; totalRecords:number = 0; + ts = new Date().getTime() public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined; constructor(public selectOptions: SelectOptionsService, @@ -118,4 +119,5 @@ export class ListingsComponent { imageErrorHandler(listing: ListingType) { listing.hideImage = true; // Bild ausblenden, wenn es nicht geladen werden kann } + } diff --git a/bizmatch/src/app/pages/subscription/account/account.component.ts b/bizmatch/src/app/pages/subscription/account/account.component.ts index bd2a87e..d392cbf 100644 --- a/bizmatch/src/app/pages/subscription/account/account.component.ts +++ b/bizmatch/src/app/pages/subscription/account/account.component.ts @@ -133,27 +133,24 @@ export class AccountComponent { '640px': '90vw' }, }); - this.dialogRef.onClose.subscribe(blob => { - if (blob) { - this.imageUploadService.uploadImage(blob, type==='company'?'uploadCompanyLogo':'uploadProfile',this.user.id).subscribe(async(event) => { - if (event.type === HttpEventType.UploadProgress) { - const progress = event.total ? event.loaded / event.total : 0; - console.log(`Upload-Fortschritt: ${progress * 100}%`); - } else if (event.type === HttpEventType.Response) { - console.log('Upload abgeschlossen', event.body); - this.loadingService.stopLoading('uploadImage'); - if (this.type==='company'){ - this.user.hasCompanyLogo=true; - this.companyLogoUrl=`${environment.apiBaseUrl}/logo/${this.user.id}.avif?_ts=${new Date().getTime()}` - } else { - this.user.hasProfile=true; - this.profileUrl=`${environment.apiBaseUrl}/profile/${this.user.id}.avif?_ts=${new Date().getTime()}` + this.dialogRef.onClose.subscribe(cropper => { + if (cropper){ + this.loadingService.startLoading('uploadImage'); + cropper.getCroppedCanvas().toBlob(async (blob) => { + this.imageUploadService.uploadImage(blob, type==='company'?'uploadCompanyLogo':'uploadProfile',this.user.id).subscribe(async(event) => { + if (event.type === HttpEventType.Response) { + this.loadingService.stopLoading('uploadImage'); + if (this.type==='company'){ + this.user.hasCompanyLogo=true; + this.companyLogoUrl=`${environment.apiBaseUrl}/logo/${this.user.id}.avif?_ts=${new Date().getTime()}` + } else { + this.user.hasProfile=true; + this.profileUrl=`${environment.apiBaseUrl}/profile/${this.user.id}.avif?_ts=${new Date().getTime()}` + } } - } - }, error => console.error('Fehler beim Upload:', error)); + }, error => console.error('Fehler beim Upload:', error)); + }) } - - }); + }) } - } diff --git a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html index 5312f7e..df65275 100644 --- a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html +++ b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.html @@ -94,30 +94,19 @@ - -
-
    -
  • - - - -
  • -
+ @for (image of propertyImages; track image) { + +
+ + + +
+
+ }
} @if (listing.listingsCategory==='business'){ diff --git a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.scss b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.scss index 9779c9c..88018d9 100644 --- a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.scss +++ b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.scss @@ -2,86 +2,56 @@ transform: translateY(5px); } -// .image { -// width: 120px; -// height: 30px; -// border: 1px solid #6b7280; -// padding: 1px 1px; -// object-fit: contain; -// } - -// .image-container img { -// width: 200px; -// box-shadow: 0 3px 6px #00000029, 0 3px 6px #0000003b; -// margin-right: 1rem; -// } - -// .container { -// width: 100%; -// min-height: 200px; -// border: 1px solid #ccc; -// display: flex; -// flex-wrap: wrap; -// } .image-container { - width: 100%; - /* oder eine spezifische Breite */ - overflow-x: auto; - /* Ermöglicht das Scrollen, wenn die Bilder zu breit sind */ -} + display: flex; /* Erlaubt ein flexibles Box-Layout */ + flex-wrap: wrap; /* Erlaubt das Umfließen der Elemente auf die nächste Zeile */ + justify-content: flex-start; /* Startet die Anordnung der Elemente am Anfang des Containers */ + align-items: flex-start; /* Ausrichtung der Elemente am Anfang der Querachse */ + padding: 10px; /* Abstand zwischen den Inhalten des Containers und dessen Rand */ + } -.image-container ul { +.image-container span { + flex-flow: row; display: flex; - padding: 0; - /* Entfernt den Standard-Abstand des ul-Elements */ - margin: 0; - /* Entfernt den Standard-Außenabstand des ul-Elements */ - list-style-type: none; - /* Entfernt die Listenpunkte */ + width: fit-content; + height: fit-content; } -.image-container li { - flex: 1 1 auto; - /* Erlaubt den li-Elementen, zu wachsen und zu schrumpfen, aber füllt den Raum gleichmäßig */ - /* Optional: Füge hier Abstände zwischen den li-Elementen hinzu */ -} - -.image-container img { - max-width: 100%; - /* Stellt sicher, dass die Bilder nicht über ihre natürliche Größe hinaus wachsen */ - height: auto; - /* Behält das Seitenverhältnis bei */ +.image-container span img { + max-height: 150px; /* Maximale Höhe der Bilder */ + width: auto; /* Die Breite der Bilder passt sich automatisch an die Höhe an */ cursor: pointer; + margin: 10px; } +// .image-container fa-icon { +// top: 0; /* Positioniert das Icon am oberen Rand des Bildes */ +// right: 0; /* Positioniert das Icon am rechten Rand des Bildes */ +// color: #fff; /* Weiße Farbe für das Icon */ +// background-color: rgba(0,0,0,0.5); /* Halbtransparenter Hintergrund für bessere Sichtbarkeit */ +// padding: 5px; /* Ein wenig Platz um das Icon */ +// cursor: pointer; /* Verwandelt den Cursor in eine Hand, um Interaktivität anzudeuten */ +// } -.draggable-image { - margin: 8px; - cursor: grab; -} - -.draggable-image:active { - box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), - 0 8px 10px 1px rgba(0, 0, 0, 0.14), - 0 3px 14px 2px rgba(0, 0, 0, 0.12); -} - -// .cdk-drag-preview { -// box-shadow: 0 5px 5px rgba(0, 0, 0, 0.2); -// } - -// .cdk-drag-placeholder { -// background-color: #ccc; -// } - -.drop-area { - border: 2px dashed #ccc; - padding: 20px; - text-align: center; - transition: all 0.3s; -} - -/* CSS-Klasse für den Drop-Bereich, wenn ein Element darüber gezogen wird */ -.drop-area-active { - border-color: #2196F3; - background-color: #E3F2FD; -} \ No newline at end of file + .image-wrap { + position: relative; /* Ermöglicht die absolute Positionierung des Icons bezogen auf diesen Container */ + display: inline-block; /* Erlaubt die Inline-Anordnung, falls mehrere Bilder vorhanden sind */ + } + + /* Stil für das Bild */ + .image-wrap img { + max-height: 150px; + width: auto; + display: block; /* Verhindert unerwünschten Abstand unter dem Bild */ + } + + /* Stil für das FontAwesome Icon */ + .image-wrap fa-icon { + position: absolute; + top: 15px; /* Positioniert das Icon am oberen Rand des Bildes */ + right: 15px; /* Positioniert das Icon am rechten Rand des Bildes */ + color: #fff; /* Weiße Farbe für das Icon */ + background-color: rgba(0,0,0,0.5); /* Halbtransparenter Hintergrund für bessere Sichtbarkeit */ + padding: 5px; /* Ein wenig Platz um das Icon */ + cursor: pointer; /* Verwandelt den Cursor in eine Hand, um Interaktivität anzudeuten */ + border-radius: 8px; /* Optional: Abrunden der linken unteren Ecke für ästhetische Zwecke */ + } \ No newline at end of file diff --git a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts index 98f0c8e..9de170c 100644 --- a/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts +++ b/bizmatch/src/app/pages/subscription/edit-listing/edit-listing.component.ts @@ -43,10 +43,14 @@ import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dy import { ImageCropperComponent } from '../../../components/image-cropper/image-cropper.component'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { CdkDragDrop, CdkDragEnter, CdkDragExit, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; +import { MixedCdkDragDropModule } from 'angular-mixed-cdk-drag-drop'; +import { faTrash } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'create-listing', standalone: true, - imports: [SharedModule, ArrayToStringPipe, InputNumberModule, CarouselModule, DialogModule, AngularCropperjsModule, FileUploadModule, EditorModule, DynamicDialogModule, ConfirmDialogModule,DragDropModule], + imports: [SharedModule, ArrayToStringPipe, InputNumberModule, CarouselModule, + DialogModule, AngularCropperjsModule, FileUploadModule, EditorModule, DynamicDialogModule, DragDropModule, + ConfirmDialogModule, MixedCdkDragDropModule], providers: [MessageService, DialogService, ConfirmationService], templateUrl: './edit-listing.component.html', styleUrl: './edit-listing.component.scss' @@ -85,8 +89,8 @@ export class EditListingComponent { config = { aspectRatio: 16 / 9 } editorModules = TOOLBAR_OPTIONS dialogRef: DynamicDialogRef | undefined; - draggedImage:ImageProperty - dropAreaActive = false; + draggedImage: ImageProperty + faTrash = faTrash; constructor(public selectOptions: SelectOptionsService, private router: Router, private activatedRoute: ActivatedRoute, @@ -155,18 +159,35 @@ export class EditListingComponent { '640px': '90vw' }, }); - this.dialogRef.onClose.subscribe(blob => { - if (blob) { - this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => { - if (event.type === HttpEventType.Response) { - console.log('Upload abgeschlossen', event.body); - this.loadingService.stopLoading('uploadImage'); - this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id) - } - }, error => console.error('Fehler beim Upload:', error)); + this.dialogRef.onClose.subscribe(cropper => { + if (cropper){ + this.loadingService.startLoading('uploadImage'); + cropper.getCroppedCanvas().toBlob(async (blob) => { + this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => { + if (event.type === HttpEventType.Response) { + console.log('Upload abgeschlossen', event.body); + this.loadingService.stopLoading('uploadImage'); + this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id) + } + }, error => console.error('Fehler beim Upload:', error)); + }, 'image/jpg'); + cropper.destroy(); } - - }); + }) + // this.dialogRef.onClose.subscribe(blob => { + // if (blob) { + // // this.loadingService.startLoading('uploadImage'); + // setTimeout(()=>{ + // this.imageService.uploadImage(blob, 'uploadPropertyPicture', this.listing.id).subscribe(async (event) => { + // if (event.type === HttpEventType.Response) { + // console.log('Upload abgeschlossen', event.body); + // // this.loadingService.stopLoading('uploadImage'); + // this.propertyImages = await this.listingsService.getPropertyImages(this.listing.id) + // } + // }, error => console.error('Fehler beim Upload:', error)); + // },10) + // } + // }); } deleteConfirm(imageName: string) { @@ -193,19 +214,9 @@ export class EditListingComponent { }); } - onDrop(event: CdkDragDrop) { - this.dropAreaActive = false; + onDrop(event: { previousIndex: number; currentIndex: number }) { moveItemInArray(this.propertyImages, event.previousIndex, event.currentIndex); - //console.log(event.previousIndex, event.currentIndex); - } - - - onDragEnter(event: CdkDragEnter) { - this.dropAreaActive = true; - } - - onDragExit(event: CdkDragExit) { - this.dropAreaActive = false; + this.listingsService.changeImageOrder(this.listing.id, this.propertyImages) } } diff --git a/bizmatch/src/app/services/image.service.ts b/bizmatch/src/app/services/image.service.ts index 2902ab0..a3eacee 100644 --- a/bizmatch/src/app/services/image.service.ts +++ b/bizmatch/src/app/services/image.service.ts @@ -20,7 +20,7 @@ export class ImageService { return this.http.post(uploadUrl, formData,{ // headers: this.headers, - reportProgress: true, + //reportProgress: true, observe: 'events', }); } diff --git a/bizmatch/src/app/services/listings.service.ts b/bizmatch/src/app/services/listings.service.ts index bdc30cc..ef98b0f 100644 --- a/bizmatch/src/app/services/listings.service.ts +++ b/bizmatch/src/app/services/listings.service.ts @@ -36,4 +36,7 @@ export class ListingsService { async getPropertyImages(id:string):Promise{ return await lastValueFrom(this.http.get(`${this.apiBaseUrl}/bizmatch/image/${id}`)); } + async changeImageOrder(id:string, propertyImages: ImageProperty[]):Promise{ + return await lastValueFrom(this.http.put(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/imageOrder/${id}`,propertyImages)); + } } diff --git a/bizmatch/src/assets/images/placeholder_properties.jpg b/bizmatch/src/assets/images/placeholder_properties.jpg new file mode 100644 index 0000000..e411207 Binary files /dev/null and b/bizmatch/src/assets/images/placeholder_properties.jpg differ diff --git a/bizmatch/src/assets/images/placeholder_properties.jpg:Zone.Identifier b/bizmatch/src/assets/images/placeholder_properties.jpg:Zone.Identifier new file mode 100644 index 0000000..60f0f97 --- /dev/null +++ b/bizmatch/src/assets/images/placeholder_properties.jpg:Zone.Identifier @@ -0,0 +1,3 @@ +[ZoneTransfer] +LastWriterPackageFamilyName=Microsoft.Windows.Photos_8wekyb3d8bbwe +ZoneId=3 diff --git a/common-models/src/main.model.ts b/common-models/src/main.model.ts index c2a25a0..ec32974 100644 --- a/common-models/src/main.model.ts +++ b/common-models/src/main.model.ts @@ -59,7 +59,7 @@ export interface CommercialPropertyListing extends Listing { email?: string; website?: string; phoneNumber?: string; - hasImages:boolean; + imageOrder?:ImageProperty[]; } export type ListingType = | BusinessListing diff --git a/crawler/data/listings.json b/crawler/data/listings.json new file mode 100644 index 0000000..9729df2 --- /dev/null +++ b/crawler/data/listings.json @@ -0,0 +1,192 @@ +[ + { + "userId": "8a2b1c5d-7e6f-4g3h-9i1j-2k3l4m5n6o7p", + "listingsCategory": "business", + "title": "Gourmet Coffee Shop", + "description": "

Community-Centric Café

A beloved local coffee shop offering a wide variety of gourmet coffees, teas, and handmade pastries. Known for its cozy atmosphere and strong community ties. Ideal for someone passionate about coffee and community engagement.

", + "type": "2", + "state": "CA", + "city": "San Francisco", + "id": "02JRPQ2YPK7CMXS55XP9G8UIJ2", + "price": 220000, + "salesRevenue": 340000, + "temporary": false, + "leasedLocation": false, + "established": 2015, + "employees": 10, + "reasonForSale": "Retirement", + "supportAndTraining": "2 weeks of training provided.", + "cashFlow": 95000 + }, + { + "userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f", + "listingsCategory": "business", + "title": "Organic Farm Supply", + "description": "

Sustainable Agriculture

Offering a range of products for organic farming, including seeds, tools, and fertilizers. Committed to promoting sustainable farming practices. A great opportunity for someone with a green thumb and a passion for sustainability.

", + "type": "3", + "state": "OR", + "city": "Portland", + "id": "03KRQQ3ZPL8DNYS66YQ1H9VJK3", + "price": 180000, + "salesRevenue": 260000, + "temporary": false, + "leasedLocation": true, + "established": 2017, + "employees": 6, + "reasonForSale": "Focusing on other ventures", + "supportAndTraining": "1 month of comprehensive training.", + "cashFlow": 78000 + }, + { + "userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b", + "listingsCategory": "business", + "title": "Modern Italian Restaurant", + "description": "

Authentic Italian Cuisine

An upscale Italian restaurant known for its modern twist on traditional dishes. Features a full bar and an elegant dining room. A fantastic opportunity for a culinary entrepreneur or chef.

", + "type": "4", + "state": "NY", + "city": "New York", + "id": "04LRPP4AOQ9ENZT77ZR2I0WKL4", + "price": 450000, + "salesRevenue": 670000, + "temporary": false, + "leasedLocation": false, + "established": 2012, + "employees": 15, + "reasonForSale": "New business interests", + "supportAndTraining": "Owner willing to consult for 3 months.", + "cashFlow": 220000 + }, + { + "userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f", + "listingsCategory": "business", + "title": "Boutique Clothing Store", + "description": "

High-End Fashion

A boutique store offering an exclusive selection of women's clothing and accessories from top designers. Located in a prime shopping district, it's known for its exceptional customer service and unique fashion finds.

", + "type": "5", + "state": "IL", + "city": "Chicago", + "id": "05MSQQ5BPRAFO1U88ZS3J1XML5", + "price": 310000, + "salesRevenue": 430000, + "temporary": false, + "leasedLocation": true, + "established": 2018, + "employees": 8, + "reasonForSale": "Personal reasons", + "supportAndTraining": "3 weeks of training and support.", + "cashFlow": 120000 + }, + { + "userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b", + "listingsCategory": "business", + "title": "Tech Startup - Mobile Apps", + "description": "

Innovative Technology Solutions

A tech startup specializing in developing cutting-edge mobile applications for both iOS and Android. With several successful apps already in the market, this is an exciting opportunity for tech enthusiasts or investors.

", + "type": "6", + "state": "WA", + "city": "Seattle", + "id": "06NTQR6CQSBGP2V99AT4K2YNM6", + "price": 500000, + "salesRevenue": 800000, + "temporary": false, + "leasedLocation": false, + "established": 2020, + "employees": 20, + "reasonForSale": "Seeking new challenges", + "supportAndTraining": "Technical and business support for 1 month.", + "cashFlow": 300000 + }, + { + "userId": "8a2b1c5d-7e6f-4g3h-9i1j-2k3l4m5n6o7p", + "listingsCategory": "business", + "title": "Eco-Friendly Cleaning Service", + "description": "

Green Home and Office Cleaning

A fast-growing cleaning service that uses eco-friendly products. Offers home and office cleaning with a focus on sustainability and environmentally friendly practices. A perfect venture for those passionate about the environment.

", + "type": "7", + "state": "CO", + "city": "Denver", + "id": "07OUQR7DRTCGQ3WAAAU5L3ZON7", + "price": 90000, + "salesRevenue": 150000, + "temporary": false, + "leasedLocation": true, + "established": 2021, + "employees": 12, + "reasonForSale": "Expanding to new markets", + "supportAndTraining": "Comprehensive eco-friendly cleaning training.", + "cashFlow": 65000 + }, + { + "userId": "3m4n5o6p-7q8r-9s0t-1u2v-3w4x5y6z7a8b", + "listingsCategory": "business", + "title": "Independent Bookstore", + "description": "

A Haven for Book Lovers

An independent bookstore with a strong community presence, offering a wide range of books, from bestsellers to local authors. Includes a cozy café space for readers to relax and enjoy their books.

", + "type": "8", + "state": "MA", + "city": "Boston", + "id": "08PVRS8ESUDHR4XBBBV6M4APO8", + "price": 130000, + "salesRevenue": 210000, + "temporary": false, + "leasedLocation": false, + "established": 2016, + "employees": 5, + "reasonForSale": "Owner relocating", + "supportAndTraining": "Training on operations and inventory management.", + "cashFlow": 70000 + }, + { + "userId": "7w8x9y0z-1a2b-3c4d-5e6f-7g8h9i0j1k2l", + "listingsCategory": "business", + "title": "Luxury Spa and Wellness Center", + "description": "

Ultimate Relaxation and Wellness

A luxury spa offering a comprehensive range of services, including massages, facials, and wellness programs. Situated in a tranquil and upscale area, it's the perfect retreat for health and wellness enthusiasts.

", + "type": "9", + "state": "FL", + "city": "Miami", + "id": "09QWTR9FTVEIS5YCCDW7N5BQP9", + "price": 350000, + "salesRevenue": 520000, + "temporary": false, + "leasedLocation": true, + "established": 2014, + "employees": 18, + "reasonForSale": "Pursuing other interests", + "supportAndTraining": "Owner available for a smooth transition over 2 months.", + "cashFlow": 190000 + }, + { + "userId": "1g2h3i4j-5k6l-7m8n-9o0p-1q2r3s4t5u6v", + "listingsCategory": "business", + "title": "Custom Jewelry Shop", + "description": "

Exquisite Handcrafted Jewelry

A shop specializing in custom-made jewelry, from engagement rings to unique pieces for special occasions. Utilizes high-quality materials and offers a personalized design service.

", + "type": "10", + "state": "NV", + "city": "Las Vegas", + "id": "10RXUS0GUWFJT6ZDDDX8O6CQR0", + "price": 275000, + "salesRevenue": 400000, + "temporary": false, + "leasedLocation": false, + "established": 2018, + "employees": 4, + "reasonForSale": "Health reasons", + "supportAndTraining": "Design and crafting training provided.", + "cashFlow": 110000 + }, + { + "userId": "4q5r6s7t-8u9v-0w1x-2y3z-4a5b6c7d8e9f", + "listingsCategory": "business", + "title": "Digital Marketing Agency", + "description": "

Online Branding and Marketing Solutions

A full-service digital marketing agency offering SEO, PPC, social media marketing, and web design services. Known for its innovative strategies and results-driven approach. Ideal for those with a background in marketing or business.

", + "type": "11", + "state": "TX", + "city": "Dallas", + "id": "11SYVT1HVXGKU7AEEEX9P7DRR1", + "price": 400000, + "salesRevenue": 600000, + "temporary": false, + "leasedLocation": true, + "established": 2019, + "employees": 25, + "reasonForSale": "Scaling down", + "supportAndTraining": "4 weeks of digital marketing training.", + "cashFlow": 250000 + } +] diff --git a/crawler/listings.json b/crawler/data/listings_bizmatch.json similarity index 100% rename from crawler/listings.json rename to crawler/data/listings_bizmatch.json diff --git a/crawler/data/listings_cp.json b/crawler/data/listings_cp.json new file mode 100644 index 0000000..fe77346 --- /dev/null +++ b/crawler/data/listings_cp.json @@ -0,0 +1,142 @@ +[ + { + "id": "2b5c900f-894d-5e48-c987-8cf735170b5f", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Downtown Retail Space", + "state": "NY", + "hasImages": true, + "price": 3200000, + "city": "New York", + "description": "

Prime retail space in the heart of New York City. An excellent opportunity for investors to own a piece of the vibrant downtown shopping district. High foot traffic and visibility make this an ideal location for any retail business.

", + "type": "100", + "imageOrder": [] + }, + { + "id": "3c6d901g-905e-6f59-d098-9dg846280c6g", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Expansive Land Development Opportunity", + "state": "CA", + "hasImages": true, + "price": 5000000, + "city": "Los Angeles", + "description": "

Offering a unique opportunity to purchase vast acreage in Los Angeles. Perfect for developers looking to create a new residential community or commercial complex. Accessibility to major highways and urban centers.

", + "type": "101", + "imageOrder": [] + }, + { + "id": "4d7ea012-h16f-7g6a-e109-feh957390d7h", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Industrial Warehouse Complex", + "state": "IL", + "hasImages": true, + "price": 7500000, + "city": "Chicago", + "description": "

A modern industrial warehouse complex suitable for manufacturing or distribution businesses. Features multiple loading docks, office spaces, and state-of-the-art facilities. Strategically located for easy transport access.

", + "type": "102", + "imageOrder": [] + }, + { + "id": "5e8fb123-i27g-8h7b-f210-gfi0684a1e8i", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Luxury Office Building", + "state": "TX", + "hasImages": true, + "price": 8900000, + "city": "Houston", + "description": "

An architectural masterpiece, this luxury office building offers state-of-the-art amenities, including a fitness center, conference rooms, and rooftop gardens. Ideal for businesses looking for a prestigious address.

", + "type": "103", + "imageOrder": [] + }, + { + "id": "6f9gc234-j38h-9i8c-g311-hgj1795b2f9j", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Mixed-Use Building Downtown", + "state": "FL", + "hasImages": true, + "price": 4200000, + "city": "Miami", + "description": "

A prominent mixed-use building featuring retail spaces on the ground floor and modern apartments above. Located in the bustling downtown area, it offers a perfect blend of commercial and residential opportunities.

", + "type": "104", + "imageOrder": [] + }, + { + "id": "7hagd345-k49i-aj9d-h422-ikj2806c3gak", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Multifamily Residential Complex", + "state": "WA", + "hasImages": true, + "price": 6700000, + "city": "Seattle", + "description": "

This modern multifamily complex features a variety of amenities including a gym, pool, and community center. Offering a mix of one, two, and three-bedroom units, it's a perfect investment for steady rental income.

", + "type": "105", + "imageOrder": [] + }, + { + "id": "8ibhe456-l5aj-bk0e-i533-jkl3917d4hab", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Versatile Commercial Property", + "state": "CO", + "hasImages": true, + "price": 3900000, + "city": "Denver", + "description": "

A versatile commercial property that can accommodate various business types, from retail to offices. Located in a high-traffic area, it offers great visibility and accessibility. Ample parking and flexible space configurations available.

", + "type": "106", + "imageOrder": [] + }, + { + "id": "9jcfi567-m6bk-cl1f-j644-kml4028e5icm", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Premier Shopping Center", + "state": "AZ", + "hasImages": true, + "price": 12000000, + "city": "Phoenix", + "description": "

A leading shopping center with a mix of national and local retailers, restaurants, and entertainment options. High annual foot traffic and a loyal customer base make this a highly desirable investment.

", + "type": "100", + "imageOrder": [] + }, + { + "id": "akdjh678-n7cl-dm2g-k755-lnm5139f6jdn", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "Expansive Industrial Park", + "state": "NV", + "hasImages": true, + "price": 15000000, + "city": "Las Vegas", + "description": "

An expansive industrial park offering a range of facilities for light manufacturing, warehousing, and logistics. Includes customizable units, ample parking, and is strategically located for easy access to major transport routes.

", + "type": "102", + "imageOrder": [] + }, + { + "id": "blemj789-o8dm-en3h-l866-mno6240g7keo", + "temporary": false, + "userId": "", + "listingsCategory": "commercialProperty", + "title": "High-End Office Suites", + "state": "GA", + "hasImages": true, + "price": 5300000, + "city": "Atlanta", + "description": "

Elegant and modern office suites in a prestigious part of Atlanta. Offers businesses a high-profile location with top-notch amenities, including secure parking, high-speed internet, and on-site management.

", + "type": "103", + "imageOrder": [] + } +] diff --git a/crawler/users.json b/crawler/data/users.json similarity index 100% rename from crawler/users.json rename to crawler/data/users.json diff --git a/crawler/filechooser.ts b/crawler/filechooser.ts new file mode 100644 index 0000000..2d93e7e --- /dev/null +++ b/crawler/filechooser.ts @@ -0,0 +1,193 @@ +import fs from 'fs-extra'; +import path from 'path'; +// import { prompt, Question} from 'inquirer'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + + +const COMPLETED = 'SELECTION_COMPLETED'; +const CANCELLED = 'SELECTION_CANCELLED'; +const CHECKMARK = '\u2713'; + +class FilesSystemService { + directories(directoryPath, directoryFilter ?: (joinedPath) => true) { + return fs.readdirSync(directoryPath).filter((name) => { + const joinedPath = path.join(directoryPath, name); + return this.isDirectory(joinedPath) && directoryFilter(joinedPath); + }); + } + + files(directoryPath, fileFilter = (joinedPath) => true) { + return fs.readdirSync(directoryPath).filter((name) => { + const joinedPath = path.join(directoryPath, name); + return this.isFile(joinedPath) && fileFilter(joinedPath); + }); + } + + isDirectory(directoryPath) { + return fs.statSync(directoryPath).isDirectory(); + } + + isFile(filePath) { + return fs.statSync(filePath).isFile(); + } +} + +class FilesSelectionService extends Set { + lastFileSelected = null; + constructor(selectedFiles) { + super(selectedFiles); + } + + get selectedFiles() { + return Array.from(this); + } + + isSelected(file) { + return this.has(file); + } + + selectFile(file) { + this.add(file); + this.lastFileSelected = file; + } + + removeFile(file) { + this.delete(file); + } +} + +class LocationService { + constructor(public currentPath) { + this.currentPath = currentPath; + } +} + +class OptionsService { + constructor(public options) { + this.options = { ...this.defaultOptions, ...options }; + } + + get defaultOptions() { + return { + directoryFilter: () => true, + fileFilter: () => true, + root: process.cwd(), + startingPath: process.cwd(), + multi: true, + pageSize: 10, + selectedFiles: [], + clearConsole: true, + }; + } +} + +export const selectFiles = function (options = {}) { + const optionsService = new OptionsService(options); + const locationService = new LocationService( + optionsService.options.startingPath + ); + const fileSystemService = new FilesSystemService(); + const filesSelectionService = new FilesSelectionService( + optionsService.options.selectedFiles + ); + + return new Promise((resolve):FCResult|void => { + (async function promptUserToSelectFiles() { + const directories = fileSystemService.directories( + locationService.currentPath, + optionsService.options.directoryFilter + ); + + if (locationService.currentPath !== optionsService.options.root) { + directories.unshift('..'); + } + + const files = fileSystemService.files( + locationService.currentPath, + optionsService.options.fileFilter + ); + + const choices = [ + ...directories.map((directoryName) => { + const value = path.join(locationService.currentPath, directoryName); + const name = chalk.yellow(directoryName); + return { value, name }; + }), + ...files.map((fileName) => { + const value = path.join(locationService.currentPath, fileName); + const name = `${fileName} ${ + filesSelectionService.isSelected(value) + ? chalk.green(CHECKMARK) + : '' + }`; + return { value, name }; + }), + ]; + + if (filesSelectionService.selectedFiles.length) { + choices.push({ + name: chalk.green('-- File Selection Complete --'), + value: COMPLETED, + }); + } + + choices.push({ + name: chalk.red('-- Cancel File Selection --'), + value: CANCELLED, + }); + + if (optionsService.options.clearConsole) { + console.clear(); + } + + const { selection } = await inquirer.prompt([ + { + type: 'list', + message: `Select file(s) in ${locationService.currentPath}`, + name: 'selection', + pageSize: optionsService.options.pageSize, + choices, + default: () => filesSelectionService.lastFileSelected, + }, + ]); + + if (optionsService.options.clearConsole) { + console.clear(); + } + + if (selection === COMPLETED || selection === CANCELLED) { + return resolve({ + selectedFiles: filesSelectionService.selectedFiles, + status: selection, + }); + } else if (!optionsService.options.multi) { + return resolve({ + selectedFiles: [selection], + status: COMPLETED, + }); + } + + if (fileSystemService.isDirectory(selection)) { + locationService.currentPath = selection; + } else { + if (filesSelectionService.isSelected(selection)) { + filesSelectionService.removeFile(selection); + } else { + filesSelectionService.selectFile(selection); + } + } + + promptUserToSelectFiles(); + })(); + }); +}; +export type FCResult = { + selectedFiles:Array + status:'SELECTION_COMPLETED'|'SELECTION_CANCELLED' +} +// const exports = { +// COMPLETED, +// CANCELLED, +// selectFiles, +// }; \ No newline at end of file diff --git a/crawler/importListing.ts b/crawler/importListing.ts index 6408ae8..85ea603 100644 --- a/crawler/importListing.ts +++ b/crawler/importListing.ts @@ -1,10 +1,22 @@ import fs from 'fs-extra'; +import { selectFiles } from './filechooser.js'; +import path from 'path'; (async () => { - const listings = await fs.readJson('./listings.json'); + const result = await selectFiles({startingPath:'./data',directoryFilter: (directoryName)=>{ + return false; + },fileFilter: (fileName) => { + return /\.json$/gi.test(fileName); + },pageSize:10,multi:false}); + console.log(result['selectedFiles'][0]); + const file = result['selectedFiles'][0]; + // const extension = path.extname(file); + // const basefileName = path.basename(file,extension); + const listings = await fs.readJson(file); //listings.forEach(element => { for (const listing of listings) { - const response = await fetch('http://localhost:3000/bizmatch/listings', { + const type = listing.listingsCategory + const response = await fetch(`http://localhost:3000/bizmatch/listings/${type}`, { method: 'POST', body: JSON.stringify(listing), headers: { 'Content-Type': 'application/json' }, diff --git a/crawler/package.json b/crawler/package.json index df855a0..ba42da6 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -15,6 +16,7 @@ "dependencies": { "currency.js": "^2.0.4", "fs-extra": "^11.2.0", + "inquirer": "^9.2.17", "ioredis": "^5.3.2", "node-fetch": "^3.3.2", "puppeteer": "^22.1.0", diff --git a/crawler/tsconfig.json b/crawler/tsconfig.json index 7b99a6b..684a21e 100644 --- a/crawler/tsconfig.json +++ b/crawler/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ @@ -25,9 +25,9 @@ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "ESNext", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ @@ -55,7 +55,7 @@ "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ + "outDir": "./build", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */