First version AI Search

This commit is contained in:
Andreas Knuth 2024-07-15 19:37:35 +02:00
parent b7b34dacab
commit af982d19d8
21 changed files with 702 additions and 105 deletions

View File

@ -19,13 +19,15 @@
{
"type": "node",
"request": "launch",
"name": "Debug Current TS File",
"program": "${workspaceFolder}/dist/src/drizzle/${fileBasenameNoExtension}.js",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"name": "Launch TypeScript file with tsx",
"runtimeExecutable": "npx",
"runtimeArgs": ["tsx", "--inspect"],
"args": ["${workspaceFolder}/src/drizzle/import.ts"],
"cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js", "!**/node_modules/**"],
"sourceMaps": true,
"smartStep": true,
"internalConsoleOptions": "openOnSessionStart"
"resolveSourceMapLocations": ["${workspaceFolder}/src/**/*.ts", "!**/node_modules/**"],
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**/*.js"]
},
{
"type": "node",

View File

@ -54,7 +54,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.2",
"tsx": "^4.7.2",
"tsx": "^4.16.2",
"urlcat": "^3.1.0",
"winston": "^3.11.0"
},
@ -78,6 +78,7 @@
"@typescript-eslint/parser": "^6.0.0",
"commander": "^12.0.0",
"drizzle-kit": "^0.23.0",
"esbuild-register": "^3.5.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",

View File

@ -7,10 +7,12 @@ import { join } from 'path';
import pkg from 'pg';
import { rimraf } from 'rimraf';
import sharp from 'sharp';
import { SelectOptionsService } from 'src/select-options/select-options.service.js';
import winston from 'winston';
import { BusinessListing, CommercialPropertyListing, User, UserData } from '../models/db.model.js';
import { emailToDirName, KeyValueStyle } from '../models/main.model.js';
import * as schema from './schema.js';
import { users } from './schema.js';
const typesOfBusiness: Array<KeyValueStyle> = [
{ name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
{ name: 'Industrial Services', value: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
@ -44,6 +46,7 @@ await db.delete(schema.commercials);
await db.delete(schema.businesses);
await db.delete(schema.users);
const sso = new SelectOptionsService();
//Broker
let filePath = `./data/broker.json`;
let data: string = readFileSync(filePath, 'utf8');
@ -63,6 +66,8 @@ fs.ensureDirSync(`./pictures/logo`);
fs.ensureDirSync(`./pictures/profile`);
fs.ensureDirSync(`./pictures/property`);
type UserProfile = Omit<User, 'created' | 'updated' | 'hasCompanyLogo' | 'hasProfile' | 'id'>;
type NewUser = typeof users.$inferInsert;
//for (const userData of usersData) {
for (let index = 0; index < usersData.length; index++) {
const userData = usersData[index];
@ -100,18 +105,17 @@ for (let index = 0; index < usersData.length; index++) {
const userProfile = createUserProfile(user);
logger.info(`${index} - ${JSON.stringify(userProfile)}`);
const embedding = await createEmbedding(JSON.stringify(userProfile));
sleep(500);
sleep(200);
const u = await db
.insert(schema.users)
.values({
...user,
embedding: embedding,
})
.returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email });
// const u = await db.insert(schema.users).values(user).returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email });
} as NewUser)
.returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
generatedUserData.push(u[0]);
i++;
logger.info(`user_${index} inserted`);
if (u[0].gender === 'male') {
male++;
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
@ -125,46 +129,12 @@ for (let index = 0; index < usersData.length; index++) {
await storeCompanyLogo(data, emailToDirName(u[0].email));
}
//Business Listings
filePath = `./data/businesses.json`;
data = readFileSync(filePath, 'utf8');
const businessJsonData = JSON.parse(data) as BusinessListing[]; // Erwartet ein Array von Objekten
for (const business of businessJsonData) {
delete business.id;
business.created = new Date(business.created);
business.updated = new Date(business.created);
const user = getRandomItem(generatedUserData);
business.email = user.email;
business.imageName = emailToDirName(user.email);
const embeddingText = JSON.stringify({
type: typesOfBusiness.find(b => b.value === String(business.type))?.name,
title: business.title,
description: business.description,
city: business.city,
state: business.state,
price: business.price,
realEstateIncluded: business.realEstateIncluded,
leasedLocation: business.leasedLocation,
franchiseResale: business.franchiseResale,
salesRevenue: business.salesRevenue,
cashFlow: business.cashFlow,
supportAndTraining: business.supportAndTraining,
employees: business.employees,
established: business.established,
reasonForSale: business.reasonForSale,
});
const embedding = await createEmbedding(embeddingText);
sleep(300);
await db.insert(schema.businesses).values({
...business,
embedding: embedding,
});
}
//Corporate Listings
filePath = `./data/commercials.json`;
data = readFileSync(filePath, 'utf8');
const commercialJsonData = JSON.parse(data) as CommercialPropertyListing[]; // Erwartet ein Array von Objekten
for (const commercial of commercialJsonData) {
for (let index = 0; index < commercialJsonData.length; index++) {
const commercial = commercialJsonData[index];
const id = commercial.id;
delete commercial.id;
const user = getRandomItem(generatedUserData);
@ -175,7 +145,25 @@ for (const commercial of commercialJsonData) {
commercial.updated = insertionDate;
commercial.email = user.email;
commercial.draft = false;
const result = await db.insert(schema.commercials).values(commercial).returning();
const reducedCommercial = {
city: commercial.city,
description: commercial.description,
email: commercial.email,
price: commercial.price,
state: sso.locations.find(l => l.value === commercial.state)?.name,
title: commercial.title,
name: `${user.firstname} ${user.lastname}`,
};
const embedding = await createEmbedding(JSON.stringify(reducedCommercial));
sleep(200);
const result = await db
.insert(schema.commercials)
.values({
...commercial,
embedding: embedding,
})
.returning();
logger.info(`commercial_${index} inserted`);
try {
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result[0].imagePath}/${result[0].serialId}`);
} catch (err) {
@ -183,6 +171,46 @@ for (const commercial of commercialJsonData) {
}
}
//Business Listings
filePath = `./data/businesses.json`;
data = readFileSync(filePath, 'utf8');
const businessJsonData = JSON.parse(data) as BusinessListing[]; // Erwartet ein Array von Objekten
for (let index = 0; index < businessJsonData.length; index++) {
const business = businessJsonData[index];
delete business.id;
business.created = new Date(business.created);
business.updated = new Date(business.created);
const user = getRandomItem(generatedUserData);
business.email = user.email;
business.imageName = emailToDirName(user.email);
const embeddingText = JSON.stringify({
type: typesOfBusiness.find(b => b.value === String(business.type))?.name,
title: business.title,
description: business.description,
email: business.email,
city: business.city,
state: sso.locations.find(l => l.value === business.state)?.name,
price: business.price,
realEstateIncluded: business.realEstateIncluded,
leasedLocation: business.leasedLocation,
franchiseResale: business.franchiseResale,
salesRevenue: business.salesRevenue,
cashFlow: business.cashFlow,
supportAndTraining: business.supportAndTraining,
employees: business.employees,
established: business.established,
reasonForSale: business.reasonForSale,
name: `${user.firstname} ${user.lastname}`,
});
const embedding = await createEmbedding(embeddingText);
sleep(200);
await db.insert(schema.businesses).values({
...business,
embedding: embedding,
});
logger.info(`business_${index} inserted`);
}
//End
await client.end();

View File

@ -26,11 +26,17 @@ export class BusinessListingsController {
}
@UseGuards(OptionalJwtAuthGuard)
@Post('search')
@Post('find')
find(@Request() req, @Body() criteria: ListingCriteria): any {
return this.listingsService.findBusinessListings(criteria, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('search')
search(@Request() req, @Body() criteria: ListingCriteria): any {
return this.listingsService.searchBusinessListings(criteria.prompt);
}
@Post()
create(@Body() listing: any) {
this.logger.info(`Save Listing`);

View File

@ -28,7 +28,7 @@ export class CommercialPropertyListingsController {
return this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
}
@UseGuards(OptionalJwtAuthGuard)
@Post('search')
@Post('find')
async find(@Request() req, @Body() criteria: ListingCriteria): Promise<any> {
return await this.listingsService.findCommercialPropertyListings(criteria, req.user as JwtUser);
}

View File

@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, ilike, lte, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import OpenAI from 'openai';
import { Logger } from 'winston';
import * as schema from '../drizzle/schema.js';
import { PG_CONNECTION, businesses, commercials } from '../drizzle/schema.js';
@ -11,11 +12,16 @@ import { JwtUser, ListingCriteria, emailToDirName } from '../models/main.model.j
@Injectable()
export class ListingsService {
openai: OpenAI;
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private fileService: FileService,
) {}
) {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen
});
}
private getConditions(criteria: ListingCriteria, table: typeof businesses | typeof commercials, user: JwtUser): any[] {
const conditions = [];
if (criteria.type) {
@ -42,6 +48,19 @@ export class ListingsService {
// Listings general
// ##############################################################
// #### Find by embeddng ########################################
async searchBusinessListings(query: string, limit: number = 20): Promise<BusinessListing[]> {
const queryEmbedding = await this.createEmbedding(query);
const results = await this.conn
.select()
.from(businesses)
.orderBy(sql`embedding <-> ${JSON.stringify(queryEmbedding)}`)
.limit(limit);
return results as BusinessListing[];
}
// #### Find by criteria ########################################
async findCommercialPropertyListings(criteria: ListingCriteria, user: JwtUser): Promise<any> {
const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12;
@ -86,6 +105,8 @@ export class ListingsService {
]);
return { total, data };
}
// #### Find by ID ########################################
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
let result = await this.conn
.select()
@ -102,6 +123,8 @@ export class ListingsService {
result = result.filter(r => !r.draft || r.imageName === emailToDirName(user?.username) || user?.roles.includes('ADMIN'));
return result[0] as BusinessListing;
}
// #### Find by User EMail ########################################
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
const conditions = [];
conditions.push(eq(commercials.imagePath, emailToDirName(email)));
@ -124,6 +147,8 @@ export class ListingsService {
.from(businesses)
.where(and(...conditions))) as CommercialPropertyListing[];
}
// #### Find by imagePath ########################################
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
const result = await this.conn
.select()
@ -131,13 +156,15 @@ export class ListingsService {
.where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
return result[0] as CommercialPropertyListing;
}
// #### CREATE ########################################
async createListing(data: BusinessListing | CommercialPropertyListing, table: typeof businesses | typeof commercials): Promise<BusinessListing | CommercialPropertyListing> {
data.created = new Date();
data.updated = new Date();
const [createdListing] = await this.conn.insert(table).values(data).returning();
return createdListing as BusinessListing | CommercialPropertyListing;
}
// #### UPDATE CommercialProps ########################################
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise<BusinessListing | CommercialPropertyListing> {
data.updated = new Date();
data.created = new Date(data.created);
@ -150,22 +177,18 @@ export class ListingsService {
const [updateListing] = await this.conn.update(commercials).set(data).where(eq(commercials.id, id)).returning();
return updateListing as BusinessListing | CommercialPropertyListing;
}
// #### UPDATE Business ########################################
async updateBusinessListing(id: string, data: BusinessListing): Promise<BusinessListing | CommercialPropertyListing> {
data.updated = new Date();
data.created = new Date(data.created);
const [updateListing] = await this.conn.update(businesses).set(data).where(eq(businesses.id, id)).returning();
return updateListing as BusinessListing | CommercialPropertyListing;
}
// #### DELETE ########################################
async deleteListing(id: string, table: typeof businesses | typeof commercials): Promise<void> {
await this.conn.delete(table).where(eq(table.id, id));
}
async getStates(table: typeof businesses | typeof commercials): Promise<any[]> {
return await this.conn
.select({ state: table.state, count: sql<number>`count(${table.id})`.mapWith(Number) })
.from(table)
.groupBy(sql`${table.state}`)
.orderBy(sql`count desc`);
}
// ##############################################################
// Images for commercial Properties
// ##############################################################
@ -182,4 +205,24 @@ export class ListingsService {
listing.imageOrder.push(imagename);
await this.updateCommercialPropertyListing(listing.id, listing);
}
// ##############################################################
// States
// ##############################################################
async getStates(table: typeof businesses | typeof commercials): Promise<any[]> {
return await this.conn
.select({ state: table.state, count: sql<number>`count(${table.id})`.mapWith(Number) })
.from(table)
.groupBy(sql`${table.state}`)
.orderBy(sql`count desc`);
}
// ##############################################################
// Embedding
// ##############################################################
async createEmbedding(text: string): Promise<number[]> {
const response = await this.openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
}

View File

@ -65,6 +65,7 @@ export interface ListingCriteria {
title: string;
category: 'professional' | 'broker';
name: string;
prompt: string;
}
export interface KeycloakUser {

View File

@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { initFlowbite } from 'flowbite';
import { KeycloakService } from 'keycloak-angular';
import onChange from 'on-change';
@ -42,7 +41,11 @@ export class AppComponent {
ngOnInit() {}
@HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
initFlowbite();
// this.router.events.subscribe(event => {
// if (event instanceof NavigationEnd) {
// initFlowbite();
// }
// });
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
this.showVersionDialog();
}

View File

@ -0,0 +1 @@
<p>dropdown works!</p>

View File

@ -0,0 +1,129 @@
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
@Component({
selector: 'app-dropdown',
template: `
<div #targetEl [class.hidden]="!isVisible" class="z-10">
<ng-content></ng-content>
</div>
`,
standalone: true,
})
export class DropdownComponent implements AfterViewInit, OnDestroy {
@ViewChild('targetEl') targetEl!: ElementRef<HTMLElement>;
@Input() triggerEl!: HTMLElement;
@Input() placement: any = 'bottom';
@Input() triggerType: 'click' | 'hover' = 'click';
@Input() offsetSkidding: number = 0;
@Input() offsetDistance: number = 10;
@Input() delay: number = 300;
@Input() ignoreClickOutsideClass: string | false = false;
@HostBinding('class.hidden') isHidden: boolean = true;
private popperInstance: PopperInstance | null = null;
isVisible: boolean = false;
private clickOutsideListener: any;
private hoverShowListener: any;
private hoverHideListener: any;
ngAfterViewInit() {
if (!this.triggerEl) {
console.error('Trigger element is not provided to the dropdown component.');
return;
}
this.initializePopper();
this.setupEventListeners();
}
ngOnDestroy() {
this.destroyPopper();
this.removeEventListeners();
}
private initializePopper() {
this.popperInstance = createPopper(this.triggerEl, this.targetEl.nativeElement, {
placement: this.placement,
modifiers: [
{
name: 'offset',
options: {
offset: [this.offsetSkidding, this.offsetDistance],
},
},
],
});
}
private setupEventListeners() {
if (this.triggerType === 'click') {
this.triggerEl.addEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.hoverShowListener = () => this.show();
this.hoverHideListener = () => this.hide();
this.triggerEl.addEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.addEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.addEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.addEventListener('mouseleave', this.hoverHideListener);
}
this.clickOutsideListener = (event: MouseEvent) => this.handleClickOutside(event);
document.addEventListener('click', this.clickOutsideListener);
}
private removeEventListeners() {
if (this.triggerType === 'click') {
this.triggerEl.removeEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.triggerEl.removeEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.removeEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.removeEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.removeEventListener('mouseleave', this.hoverHideListener);
}
document.removeEventListener('click', this.clickOutsideListener);
}
toggle() {
this.isVisible ? this.hide() : this.show();
}
show() {
this.isVisible = true;
this.isHidden = false;
this.targetEl.nativeElement.classList.remove('hidden');
this.popperInstance?.update();
}
hide() {
this.isVisible = false;
this.isHidden = true;
this.targetEl.nativeElement.classList.add('hidden');
}
private handleClickOutside(event: MouseEvent) {
if (!this.isVisible) return;
const clickedElement = event.target as HTMLElement;
if (this.ignoreClickOutsideClass) {
const ignoredElements = document.querySelectorAll(`.${this.ignoreClickOutsideClass}`);
const arr = Array.from(ignoredElements);
for (const el of arr) {
if (el.contains(clickedElement)) return;
}
}
if (!this.targetEl.nativeElement.contains(clickedElement) && !this.triggerEl.contains(clickedElement)) {
this.hide();
}
}
private destroyPopper() {
if (this.popperInstance) {
this.popperInstance.destroy();
this.popperInstance = null;
}
}
}

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { initFlowbite } from 'flowbite';
@Component({
@ -15,7 +15,12 @@ export class FooterComponent {
privacyVisible = false;
termsVisible = false;
currentYear: number = new Date().getFullYear();
constructor(private router: Router) {}
ngOnInit() {
initFlowbite();
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
initFlowbite();
}
});
}
}

View File

@ -1,15 +1,114 @@
<!-- <div class="wrapper">
<div class="pl-3 flex align-items-center gap-2">
<a routerLink="/home"><img src="assets/images/header-logo.png" height="40" alt="bizmatch" /></a>
<p-tabMenu [model]="tabItems" ariaLabelledBy="label" styleClass="flex" [activeItem]="activeItem"> </p-tabMenu>
<p-menubar [model]="menuItems"></p-menubar>
<p-menubar [model]="loginItems"></p-menubar>
@if(user){
<div>Welcome, {{ user.firstName }}</div>
}
<ng-template #empty> </ng-template>
<!-- <nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a>
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<button
type="button"
class="flex text-sm bg-gray-200 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
aria-expanded="false"
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom"
>
<span class="sr-only">Open user menu</span>
@if(user){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
}
</button>
@if(user){
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-login">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">Welcome, {{ user.firstname }} </span>
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">{{ user.email }}</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
</li>
<li>
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Create Listing</a
>
</li>
<li>
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
</li>
<li>
<a routerLink="/emailUs" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">EMail Us</a>
</li>
<li>
<a routerLink="/logout" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Logout</a>
</li>
</ul>
</div>
} @else {
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-unknown">
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a (click)="login()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
</li>
<li>
<a (click)="register()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Register</a>
</li>
</ul>
</div>
}
<button
data-collapse-toggle="navbar-user"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-user"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul
class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700"
>
<li>
<a
routerLinkActive="active-link"
routerLink="/businessListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/businessListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
aria-current="page"
(click)="closeMenus()"
>Businesses</a
>
</li>
<li>
<a
routerLinkActive="active-link"
routerLink="/commercialPropertyListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/commercialPropertyListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
(click)="closeMenus()"
>Properties</a
>
</li>
<li>
<a
routerLinkActive="active-link"
routerLink="/brokerListings"
[ngClass]="{ 'bg-blue-700 text-white md:text-blue-700 md:bg-transparent md:dark:text-blue-500': isActive('/brokerListings') }"
class="block py-2 px-3 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
(click)="closeMenus()"
>Professionals</a
>
</li>
</ul>
</div>
</div>
</div> -->
</nav> -->
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
@ -17,6 +116,16 @@
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a>
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<!-- Filter button -->
<button
type="button"
#triggerButton
id="filterDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 md:me-2"
>
<i class="fas fa-filter mr-2"></i>Filter (1)
</button>
<button
type="button"
class="flex text-sm bg-gray-200 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
@ -122,4 +231,170 @@
</ul>
</div>
</div>
<!-- Mobile filter button -->
<!-- <div class="md:hidden flex justify-center pb-4">
<button
data-dropdown-toggle="filterDropdown"
type="button"
id="filterDropdownMobileButton"
class="w-full mx-4 px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<i class="fas fa-filter mr-2"></i>Filter (1)
</button>
</div> -->
</nav>
<!-- ############################### -->
<!-- Filter Dropdown -->
<!-- ############################### -->
<app-dropdown [triggerEl]="triggerButton" [triggerType]="'click'">
<div id="filterDropdown" class="z-10 w-80 p-3 bg-slate-200 rounded-lg shadow-lg dark:bg-gray-700">
<h3 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">Filter</h3>
<!-- Price Range -->
<div class="mb-4">
<div class="flex items-center space-x-4">
<input
type="text"
[ngModel]="prompt"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
value="300"
/>
</div>
<!-- <label for="price-range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Price Range</label>
<div class="flex items-center space-x-4">
<input
type="number"
id="price-from"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="From"
value="300"
/>
<input
type="number"
id="price-to"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="To"
value="3500"
/>
</div> -->
</div>
<!-- Sales -->
<!-- <div class="mb-4">
<label for="sales-range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sales</label>
<div class="flex items-center space-x-4">
<input
type="number"
id="sales-from"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="From"
value="1"
/>
<input
type="number"
id="sales-to"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="To"
value="100"
/>
</div>
</div> -->
<!-- Category -->
<!-- <div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Category</label>
<div class="flex flex-wrap gap-2">
<button
class="px-3 py-1 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
Gaming
</button>
<button
class="px-3 py-1 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
Electronics
</button>
<button
class="px-3 py-1 text-sm font-medium text-white bg-blue-700 border border-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Phone
</button>
<button
class="px-3 py-1 text-sm font-medium text-white bg-blue-700 border border-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
TV/Monitor
</button>
<button
class="px-3 py-1 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white"
>
Laptop
</button>
<button
class="px-3 py-1 text-sm font-medium text-white bg-blue-700 border border-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Watch
</button>
</div>
</div> -->
<!-- State -->
<!-- <div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">State</label>
<ul class="w-48 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<li class="w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600">
<div class="flex items-center ps-3">
<input
id="state-all"
type="radio"
value="all"
name="state"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
checked
/>
<label for="state-all" class="w-full py-3 ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">All</label>
</div>
</li>
<li class="w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600">
<div class="flex items-center ps-3">
<input
id="state-new"
type="radio"
value="new"
name="state"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label for="state-new" class="w-full py-3 ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">New</label>
</div>
</li>
<li class="w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600">
<div class="flex items-center ps-3">
<input
id="state-refurbished"
type="radio"
value="refurbished"
name="state"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
/>
<label for="state-refurbished" class="w-full py-3 ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Refurbished</label>
</div>
</li>
</ul>
</div> -->
<!-- Action Buttons -->
<div class="flex justify-between">
<button
type="button"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Show 32 Results
</button>
<button
type="button"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
Reset
</button>
</div>
</div>
</app-dropdown>

View File

@ -1,20 +1,23 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { KeycloakService } from 'keycloak-angular';
import { Observable } from 'rxjs';
import { Observable, Subject, takeUntil } from 'rxjs';
import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { emailToDirName, KeycloakUser } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
import { DropdownComponent } from '../dropdown/dropdown.component';
@Component({
selector: 'header',
standalone: true,
imports: [CommonModule, RouterModule],
imports: [CommonModule, RouterModule, DropdownComponent, FormsModule],
templateUrl: './header.component.html',
styleUrl: './header.component.scss',
})
@ -27,23 +30,50 @@ export class HeaderComponent {
faUserGear = faUserGear;
profileUrl: string;
env = environment;
constructor(public keycloakService: KeycloakService, private router: Router, private userService: UserService, private sharedService: SharedService) {}
private filterDropdown: Dropdown | null = null;
isMobile: boolean = false;
private destroy$ = new Subject<void>();
prompt: string;
constructor(public keycloakService: KeycloakService, private router: Router, private userService: UserService, private sharedService: SharedService, private breakpointObserver: BreakpointObserver) {}
async ngOnInit() {
const token = await this.keycloakService.getToken();
this.keycloakUser = map2User(token);
this.user = await this.userService.getByMail(this.keycloakUser.email);
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
setTimeout(() => {
initFlowbite();
if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser?.email);
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
}
// setTimeout(() => {
// initFlowbite();
// });
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
initFlowbite();
}
});
this.breakpointObserver
.observe([Breakpoints.Handset])
.pipe(takeUntil(this.destroy$))
.subscribe(result => {
this.isMobile = result.matches;
const targetEl = document.getElementById('filterDropdown');
const triggerEl = this.isMobile ? document.getElementById('filterDropdownMobileButton') : document.getElementById('filterDropdownButton');
if (targetEl && triggerEl) {
this.filterDropdown = new Dropdown(targetEl, triggerEl);
}
});
this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
if (photoUrl) {
this.profileUrl = photoUrl;
}
});
}
toggleFilterDropdown() {
if (this.filterDropdown) {
this.filterDropdown.toggle();
}
}
ngAfterViewInit() {}
navigateWithState(dest: string, state: any) {
@ -82,4 +112,9 @@ export class HeaderComponent {
this.closeDropdown();
this.closeMobileMenu();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -77,7 +77,7 @@
</ul>
</div>
<div class="flex items-center border border-gray-300 rounded-full p-2">
<input type="text" placeholder="AI Search" class="flex-grow px-4 py-2 outline-none rounded-full text-sm md:text-base" />
<input type="text" [(ngModel)]="prompt" placeholder="AI Search" class="flex-grow px-4 py-2 outline-none rounded-full text-sm md:text-base" />
<button class="bg-blue-600 text-white p-2 rounded-full" (click)="search()">
<svg class="h-5 w-5 md:h-6 md:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M10.5 18.5A7.5 7.5 0 1018 10.5 7.5 7.5 0 0010.5 18.5z"></path>

View File

@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { initFlowbite } from 'flowbite';
import { KeycloakService } from 'keycloak-angular';
import onChange from 'on-change';
import { KeycloakUser, ListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
@ -25,7 +24,7 @@ export class HomeComponent {
states = [];
isMenuOpen = false;
user: KeycloakUser;
prompt: string;
public constructor(private router: Router, private activatedRoute: ActivatedRoute, public selectOptions: SelectOptionsService, public keycloakService: KeycloakService, private listingsService: ListingsService) {
this.criteria = onChange(getCriteriaStateObject(), getSessionStorageHandler);
resetCriteria(this.criteria);
@ -33,9 +32,11 @@ export class HomeComponent {
async ngOnInit() {
const token = await this.keycloakService.getToken();
this.user = map2User(token);
setTimeout(() => {
initFlowbite();
});
// this.router.events.subscribe(event => {
// if (event instanceof NavigationEnd) {
// initFlowbite();
// }
// });
}
async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname;
@ -48,6 +49,7 @@ export class HomeComponent {
}
search() {
const data = { keep: true };
this.criteria.prompt = this.prompt;
this.router.navigate([`${this.activeTabAction}Listings`]);
}

View File

@ -85,11 +85,12 @@
</div> -->
<!-- business-listing.component.html -->
<!-- <div class="w-full bg-slate-100"> -->
<div class="container mx-auto p-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-4 flex flex-col h-full relative">
<div class="p-4 flex flex-col h-full relative z-[0]">
<div class="flex items-center mb-2">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-semibold">{{ selectOptions.getBusiness(listing.type) }}</span>
@ -111,4 +112,17 @@
}
</div>
</div>
<!-- <div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">{{ listing.title }}</h3>
</div>
</div>
}
</div>
</div> -->
<!-- </div> -->

View File

@ -70,9 +70,10 @@ export class BusinessListingsComponent {
this.search();
}
async search() {
const listingReponse = await this.listingsService.getListings(this.criteria, 'business');
this.listings = listingReponse.data;
this.totalRecords = listingReponse.total;
this.listings = await this.listingsService.getListingsByPrompt(this.criteria);
// const listingReponse = await this.listingsService.getListings(this.criteria, 'business');
// this.listings = listingReponse.data;
// this.totalRecords = listingReponse.total;
// this.cdRef.markForCheck();
// this.cdRef.detectChanges();
}

View File

@ -13,7 +13,11 @@ export class ListingsService {
constructor(private http: HttpClient) {}
async getListings(criteria: ListingCriteria, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/search`, criteria));
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, criteria));
return result;
}
async getListingsByPrompt(criteria: ListingCriteria): Promise<BusinessListing[]> {
const result = await lastValueFrom(this.http.post<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/business/search`, criteria));
return result;
}
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {

View File

@ -33,13 +33,4 @@ export class UserService {
async getAllStates(): Promise<any> {
return await lastValueFrom(this.http.get<StatesResult[]>(`${this.apiBaseUrl}/bizmatch/user/states/all`));
}
// async getId(email: string): Promise<string> {
// if (sessionStorage.getItem('USERID')) {
// return sessionStorage.getItem('USERID');
// } else {
// const user = await this.getByMail(email);
// sessionStorage.setItem('USERID', user.id);
// return user.id;
// }
// }
}

View File

@ -96,6 +96,7 @@ export function createDefaultListingCriteria(): ListingCriteria {
title: '',
category: 'broker',
name: '',
prompt: '',
};
}
export function createLogger(name: string, level: number = INFO, options: any = {}) {
@ -179,3 +180,58 @@ export function getDialogWidth(dimensions): string {
}
return dialogWidth;
}
import { initFlowbite } from 'flowbite';
import { Subject, concatMap, delay, of } from 'rxjs';
const flowbiteQueue = new Subject<() => void>();
flowbiteQueue.pipe(concatMap(item => of(item).pipe(delay(100)))).subscribe(x => {
x();
});
export function Flowbite() {
return function (target: any) {
const originalOnInit = target.prototype.ngOnInit;
target.prototype.ngOnInit = function () {
if (originalOnInit) {
originalOnInit.apply(this);
}
initFlowbiteFix();
};
};
}
export function initFlowbiteFix() {
flowbiteQueue.next(() => {
const elements = Array.from(document.querySelectorAll('*'));
const flowbiteElements: Element[] = [];
const initializedElements = Array.from(document.querySelectorAll('[flowbite-initialized]'));
for (const element of elements) {
const attributes = Array.from(element.attributes);
for (const attribute of attributes) {
if (attribute.name.startsWith('data-') && !initializedElements.includes(element)) {
flowbiteElements.push(element);
break;
}
}
}
for (const element of flowbiteElements) {
element.setAttribute('flowbite-initialized', '');
}
initFlowbite();
for (const element of flowbiteElements) {
const attributes: { name: string; value: string }[] = Array.from(element.attributes);
const dataAttributes = attributes.filter(attribute => attribute.name.startsWith('data-'));
for (const attribute of dataAttributes) {
element.setAttribute(attribute.name.replace('data-', 'fb-'), attribute.value);
element.removeAttribute(attribute.name);
}
}
});
}