Compare commits

...

5 Commits

Author SHA1 Message Date
0efe318db3 feat: Mejoras en formulario hero, estilos globales y OneSignal
- Formulario hero: validación por campo con estado touched/submitted,
  mensajes de error inline, máscara de moneda en cuota, autocomplete
  flotante para dirección y categorías con clearInput, chips para
  categorías y palabras clave, campos banco/CLABE/RFC
- Estilos globales: border-radius y sombra en ion-button, borde para
  botones light sobre fondo claro, sin borde en toolbars de color
- OneSignal: reescritura del servicio con login/logout, addTag y
  routing por título de notificación usando NgZone
- FAQ: color primary en acordeón activo/desplegado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:23:13 -06:00
CarlosTorres
9f2a0d62e0 Fix formulario de postulación y configuración de producción
- Actualizar API_URL a jobhero-api.consultoria-as.com
- Arreglar autocomplete de dirección para sincronizar con GPS
- Corregir número interior para que sea editable y no se autollene incorrectamente
- Cambiar ionChange a ionInput para lista de sugerencias
- Agregar configuración de firma para APK release

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:59:38 +00:00
CarlosTorres
22e4585bb9 feat: Implementar ion-chips, bypass de pago y mejoras UI
- Implementar ion-chips para categorías y keywords en hero page
- Agregar bypass de pago en hire page para pruebas
- Mover progress bar en dashboard
- Corregir shared-components module (remover AccordionComponent eliminado)
- Corregir eventos Ionic (ionInput/ionChange)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:48:16 +00:00
4afaf69d10 Merge pull request 'WIP - Agregar barra de progreso para los contratos pendientes y encabezados' (#2) from dev_alex into main 2026-01-27 15:26:33 -08:00
68673baf99 Merge pull request 'WIP - Actualizacion de acordeon en FAQ' (#1) from dev_alex into main 2026-01-27 14:36:16 -08:00
14 changed files with 496 additions and 197 deletions

View File

@@ -16,8 +16,17 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
storeFile file('../../jobhero-release.keystore')
storePassword 'JobHero2024!'
keyAlias 'jobhero'
keyPassword 'JobHero2024!'
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}

View File

@@ -2,15 +2,12 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
@NgModule({
declarations: [AccordionComponent],
declarations: [],
imports: [
CommonModule,
IonicModule
],
exports: [
]
exports: []
})
export class SharedComponentsModule { }

View File

@@ -25,16 +25,16 @@
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.address' | translate}}</ion-label>
<ion-input [(ngModel)]="addressAutocomplete" (ionChange)="autocomplete($event)" value="{{ myAddress }}" (ionFocus)="showlist()"></ion-input>
<ion-input [(ngModel)]="addressAutocomplete" (ionInput)="autocomplete($event)" (ionFocus)="showlist()"></ion-input>
</ion-item>
<ion-list *ngIf="showif">
<ion-item button=true (click)="geoloc(places.place_id, places.description, places.terms[1].value)" *ngFor="let places of placesSearch" class="place">
<ion-list *ngIf="showif && placesSearch && placesSearch.length > 0">
<ion-item button=true (click)="geoloc(places.place_id, places.description)" *ngFor="let places of placesSearch" class="place">
{{ places.description }}
</ion-item>
</ion-list>
<ion-item hidden>
<ion-item>
<ion-label position="fixed">{{'categories.int_number' | translate}}</ion-label>
<ion-input value="{{ myIntnumber }}" disabled></ion-input>
<ion-input [(ngModel)]="myIntnumber" placeholder="Opcional"></ion-input>
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.references' | translate}}</ion-label>

View File

@@ -102,6 +102,7 @@ export class CategoryPage implements OnInit {
const address = results[0].formatted_address;
console.log("data_: ", address);
this.myAddress = address;
this.addressAutocomplete = address; // Sincronizar con el input
}
});
});
@@ -142,9 +143,9 @@ export class CategoryPage implements OnInit {
});
}
geoloc(place_id: string, place_description: string, place_intnumber: string) {
geoloc(place_id: string, place_description: string) {
this.myAddress = place_description;
this.myIntnumber = place_intnumber;
this.addressAutocomplete = place_description; // Sincronizar con el input
console.log(place_id);
this.hidelist();
new google.maps.Geocoder().geocode({ placeId: place_id }, coordinates => {

View File

@@ -41,7 +41,7 @@
<ion-button style="height: 3em" item-end (click)="checkCoupon()" expand="full" color="primary">{{'contracts.validate' | translate}}</ion-button>
</ion-item>
</ion-list>
<ion-item>
<ion-item *ngIf="!paymentBypass">
<ion-label>{{'cards.card' | translate}}</ion-label>
<ion-select style="max-width:90%" interface="action-sheet" [(ngModel)]="cards_menu" (ionChange)="selected_card($event)">
<ion-select-option>Agregar tarjeta</ion-select-option>
@@ -49,13 +49,18 @@
</ion-select>
</ion-item>
<form #form="ngForm" id="card_form" (ngSubmit)="createContract(form)" method="post">
<ion-item [class.ng-invalid]="!code_check">
<ion-item *ngIf="!paymentBypass" [class.ng-invalid]="!code_check">
<ion-label position="floating">CVV</ion-label>
<ion-input type="password" ngModel name="code" (ionChange)="code_checker($event)" [class.ng-invalid]="!code_check" size="3" maxlength="4"></ion-input>
</ion-item><br><br>
</ion-item>
<ion-note *ngIf="paymentBypass" color="warning" style="display: block; text-align: center; margin: 1em 0;">
<b>MODO DE PRUEBA:</b> El pago será simulado
</ion-note>
<br><br>
<ion-button type="submit" expand="full" color="secondary">{{'contracts.hire_confirm' | translate}}</ion-button>
</form>
<br>
<div *ngIf="!paymentBypass">
<ion-row class="ion-align-items-center">
<ion-col size="4">
<img src="/assets/openpay/openpay_color.png" style="padding:0.25em 0.5em"/>
@@ -106,4 +111,5 @@
<img src="/assets/openpay/bbva.png"/>
</ion-col>
</ion-row>
</div>
</ion-content>

View File

@@ -33,6 +33,7 @@ export class HirePage implements OnInit {
final_amount = null;
coupon = null;
cards_menu = null;
paymentBypass = false;
constructor(
private modalController: ModalController,
@@ -59,11 +60,14 @@ export class HirePage implements OnInit {
}
ngOnInit() {
OpenPay.setId(this.env.MERCHANT_ID);
OpenPay.setApiKey(this.env.PUBLIC_API_KEY);
console.log(OpenPay.getSandboxMode());
var deviceDataId = OpenPay.deviceData.setup("card_form");
this.getcards();
this.paymentBypass = this.env.PAYMENT_BYPASS;
if (!this.paymentBypass) {
OpenPay.setId(this.env.MERCHANT_ID);
OpenPay.setApiKey(this.env.PUBLIC_API_KEY);
console.log(OpenPay.getSandboxMode());
var deviceDataId = OpenPay.deviceData.setup("card_form");
this.getcards();
}
}
newcard() {
@@ -141,6 +145,36 @@ export class HirePage implements OnInit {
}
createContract(form: NgForm) {
// Si el bypass está activo, enviar con valores dummy
if (this.paymentBypass) {
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.createContract(this.postulation_id, this.supplier_id, 'BYPASS', this.coupon, '000', 'BYPASS').subscribe(
data => {
if (data['type'] == "error") {
this.loading.dismiss();
this.alertService.presentToast(data['message']);
} else if (data['name'] == "used") {
this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_used'));
} else if (data['name'] == "expired") {
this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_expired'));
} else {
this.loading.dismiss();
this.events.publish('refreshpcontracts', 'data');
this.navCtrl.navigateRoot('/contracts/contracted');
this.alertService.presentToast(this.translateService.instant('alerts.hire'));
}
}, error => {
this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
return;
}
if (this.card_id != "Agregar tarjeta") {
if (this.code_check) {
let code = form.value.code;

View File

@@ -56,9 +56,9 @@ ion-item:active:after {
</ng-container>
<ion-card *ngFor="let pcontract of pcontracts; let i = index">
<ion-item>
<ion-progress-bar type="indeterminate"></ion-progress-bar>
<ion-label>
<h2 text-capitalize>{{ pcontract.category }}</h2>
<ion-progress-bar type="indeterminate"></ion-progress-bar>
<p text-wrap><ion-icon name="search"></ion-icon> {{'contracts.postulating' | translate}}</p>
<p text-wrap>{{pcontract.address}}</p>
<p text-wrap text-capitalize>{{pcontracts_dates[i]}}</p>

View File

@@ -1,5 +1,5 @@
ion-accordion.accordion-expanding ion-item[slot='header'],
ion-accordion.accordion-expanded ion-item[slot='header'] {
--background: var(--ion-color-primary);
--color: var(--ion-color-primary-contrast);
--ion-color-base: var(--ion-color-primary) !important;
--ion-color-contrast: var(--ion-color-primary-contrast) !important;
}

View File

@@ -9,60 +9,124 @@
</ion-toolbar>
</ion-header>
<ion-content padding>
<ion-item>
<ion-content class="ion-padding">
<ion-item [class.ion-invalid]="(submitted || touched['name']) && !name" [class.ion-touched]="submitted || touched['name']">
<ion-label position="floating">{{'hero.name' | translate}}</ion-label>
<ion-input [(ngModel)]="name"></ion-input>
<ion-input [(ngModel)]="name" (ionBlur)="markTouched('name')"></ion-input>
</ion-item>
<br>
<span class="error-msg" *ngIf="(submitted || touched['name']) && !name">{{'hero.required' | translate}}</span>
<ion-item>
<ion-label>{{'hero.categories' | translate}}</ion-label>
<ion-select [(ngModel)]="categories_input" multiple="true" okText="Aceptar" cancelText="Cancelar" placeholder="{{'hero.categories_placeholder' | translate}}">
<ion-select-option *ngFor="let category of categories" [value]="category">{{category}}</ion-select-option>
</ion-select>
<ion-item [class.ion-invalid]="(submitted || touched['rfc']) && !rfc" [class.ion-touched]="submitted || touched['rfc']">
<ion-label position="floating">RFC</ion-label>
<ion-input [(ngModel)]="rfc" (ionBlur)="markTouched('rfc')"></ion-input>
</ion-item>
<br>
<span class="error-msg" *ngIf="(submitted || touched['rfc']) && !rfc">{{'hero.required' | translate}}</span>
<ion-item>
<ion-label position="floating">{{'hero.keywords' | translate}}</ion-label>
<ion-input [(ngModel)]="keywords_text" placeholder="{{'hero.keywords_placeholder' | translate}}"></ion-input>
</ion-item>
<ion-note padding>{{'hero.keywords_hint' | translate}}</ion-note>
<ion-label color="light">_</ion-label>
<ion-item>
<ion-label position="floating">Dirección</ion-label>
<ion-input [(ngModel)]="addressAutocomplete" (ionChange)="autocomplete($event)" value="{{ myAddress }}" (ionFocus)="showlist()"></ion-input>
</ion-item>
<ion-list *ngIf="showif">
<ion-item button=true (click)="geoloc(places.place_id, places.description, places.terms[1].value)" *ngFor="let places of placesSearch" class="place">
{{ places.description }}
<!-- CATEGORÍAS CON CHIPS -->
<div class="dropdown-container">
<ion-item class="tags-item" [class.ion-invalid]="(submitted || touched['categories']) && categories_input.length === 0" [class.ion-touched]="submitted || touched['categories']">
<div class="tags-row">
<span class="chip-section-label">{{'hero.categories' | translate}}</span>
<ion-chip *ngFor="let cat of categories_input" color="primary">
<ion-label>{{cat}}</ion-label>
<ion-icon name="close-circle" (click)="removeCategory(cat)"></ion-icon>
</ion-chip>
<ion-input
[(ngModel)]="categorySearchText"
(ionInput)="filterCategories($event)"
(ionBlur)="hideCategoryList()"
[clearInput]="true"
placeholder="{{'hero.categories_placeholder' | translate}}">
</ion-input>
</div>
</ion-item>
</ion-list>
<ion-item hidden>
<span class="error-msg" *ngIf="(submitted || touched['categories']) && categories_input.length === 0">Selecciona al menos una categoría</span>
<ion-list *ngIf="showCategoryDropdown && filteredCategories.length > 0" class="dropdown-list">
<ion-item *ngFor="let category of filteredCategories" button (click)="selectCategory(category)">
{{category}}
</ion-item>
</ion-list>
</div>
<!-- PALABRAS CLAVE CON CHIPS -->
<ion-item class="tags-item">
<div class="tags-row">
<span class="chip-section-label">{{'hero.keywords' | translate}}</span>
<ion-chip *ngFor="let keyword of keywords" color="primary">
<ion-label>{{keyword}}</ion-label>
<ion-icon name="close-circle" (click)="removeKeyword(keyword)"></ion-icon>
</ion-chip>
<ion-input
[(ngModel)]="keywordInput"
(keydown)="onKeywordKeydown($event)"
(ionBlur)="addKeyword($event)"
placeholder="{{'hero.keywords_placeholder' | translate}}">
</ion-input>
</div>
</ion-item>
<ion-note class="ion-padding">{{'hero.keywords_hint' | translate}}</ion-note>
<!-- DIRECCIÓN CON AUTOCOMPLETE FLOTANTE -->
<div class="dropdown-container">
<ion-item [class.ion-invalid]="(submitted || touched['address']) && !myAddress" [class.ion-touched]="submitted || touched['address']">
<ion-label position="floating">{{'categories.address' | translate}}</ion-label>
<ion-input [(ngModel)]="addressAutocomplete" (ionInput)="autocomplete($event)" (ionFocus)="showlist()" (ionBlur)="blurAddressList()" [clearInput]="true"></ion-input>
</ion-item>
<span class="error-msg" *ngIf="(submitted || touched['address']) && !myAddress">{{'alerts.valid_address' | translate}}</span>
<ion-list *ngIf="showif && placesSearch?.length" class="dropdown-list">
<ion-item button=true (click)="geoloc(places.place_id, places.description, places.terms[1].value)" *ngFor="let places of placesSearch" class="place">
{{ places.description }}
</ion-item>
</ion-list>
</div>
<ion-item class="ion-hide">
<ion-label position="fixed">Latitud</ion-label>
<ion-input value="{{ myPosition.latitude }}" disabled></ion-input>
</ion-item>
<ion-item hidden>
<ion-item class="ion-hide">
<ion-label position="fixed">Longitud</ion-label>
<ion-input value="{{ myPosition.longitude }}" disabled></ion-input>
</ion-item>
<ion-item>
<ion-label>{{'hero.discover' | translate}}</ion-label>
<ion-select value={selectedReference} okText="Aceptar" cancelText="Cancelar" (ionChange)="selected_reference($event)">
<ion-select-option value="1">{{'hero.radio' | translate}}</ion-select-option>
<ion-select-option value="2">{{'hero.tv' | translate}}</ion-select-option>
<ion-select-option value="3">{{'hero.social' | translate}}</ion-select-option>
<ion-select-option value="4">{{'hero.friends' | translate}}</ion-select-option>
<ion-select-option value="5">{{'hero.other' | translate}}</ion-select-option>
<ion-item [class.ion-invalid]="(submitted || touched['bank']) && selectedBank === null" [class.ion-touched]="submitted || touched['bank']">
<ion-label>{{'hero.bank' | translate}}</ion-label>
<ion-select [(ngModel)]="selectedBank" placeholder="{{'hero.select' | translate}}" okText="Aceptar" cancelText="Cancelar" (ionBlur)="markTouched('bank')">
<ion-select-option *ngFor="let bank of banks" [value]="bank.id">{{bank.name}}</ion-select-option>
</ion-select>
</ion-item>
<ion-item *ngIf="showinput">
<ion-label position="floating">{{'hero.discover_details' | translate}}</ion-label>
<ion-input [(ngModel)]="reference"></ion-input>
<span class="error-msg" *ngIf="(submitted || touched['bank']) && selectedBank === null">{{'hero.required' | translate}}</span>
<ion-item [class.ion-invalid]="(submitted || touched['bankAccount']) && !bankAccount" [class.ion-touched]="submitted || touched['bankAccount']">
<ion-label position="floating">{{'hero.bank_account' | translate}}</ion-label>
<ion-input [(ngModel)]="bankAccount" type="number" (ionBlur)="markTouched('bankAccount')"></ion-input>
</ion-item>
<span class="error-msg" *ngIf="(submitted || touched['bankAccount']) && !bankAccount">{{'hero.required' | translate}}</span>
<ion-item [class.ion-invalid]="(submitted || touched['fee']) && fee === null" [class.ion-touched]="submitted || touched['fee']">
<ion-label position="floating">{{'hero.fee' | translate}}</ion-label>
<ion-input [(ngModel)]="feeDisplay" (ionInput)="onFeeInput($event)" (ionBlur)="markTouched('fee')" type="text" inputmode="decimal"></ion-input>
</ion-item>
<span class="error-msg" *ngIf="(submitted || touched['fee']) && fee === null">{{'hero.required' | translate}}</span>
<ion-item [class.ion-invalid]="(submitted || touched['reference']) && !selectedReference" [class.ion-touched]="submitted || touched['reference']">
<ion-label>{{'hero.discover' | translate}}</ion-label>
<ion-select [(ngModel)]="selectedReference" placeholder="{{'hero.select' | translate}}" okText="Aceptar" cancelText="Cancelar" (ionChange)="selected_reference($event)" (ionBlur)="markTouched('reference')">
<ion-select-option [value]="1">{{'hero.radio' | translate}}</ion-select-option>
<ion-select-option [value]="2">{{'hero.tv' | translate}}</ion-select-option>
<ion-select-option [value]="3">{{'hero.social' | translate}}</ion-select-option>
<ion-select-option [value]="4">{{'hero.friends' | translate}}</ion-select-option>
<ion-select-option [value]="5">{{'hero.other' | translate}}</ion-select-option>
</ion-select>
</ion-item>
<span class="error-msg" *ngIf="(submitted || touched['reference']) && !selectedReference">{{'hero.required' | translate}}</span>
<ion-item *ngIf="showinput" [class.ion-invalid]="(submitted || touched['referenceDetail']) && !reference" [class.ion-touched]="submitted || touched['referenceDetail']">
<ion-label position="floating">{{'hero.discover_details' | translate}}</ion-label>
<ion-input [(ngModel)]="reference" (ionBlur)="markTouched('referenceDetail')"></ion-input>
</ion-item>
<span class="error-msg" *ngIf="(submitted || touched['referenceDetail']) && showinput && !reference">{{'hero.required' | translate}}</span>
<br>
<br>
<ion-button type="submit" expand="full" color="secondary" (click)="addHero()">{{'hero.signup' | translate}}</ion-button>

View File

@@ -0,0 +1,95 @@
.tags-item {
--inner-padding-end: 8px;
--padding-top: 4px;
--padding-bottom: 4px;
}
.chip-section-label {
flex-basis: 100%;
font-size: 12px;
padding-top: 8px;
padding-bottom: 2px;
}
.tags-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 0 0 6px;
width: 100%;
ion-chip {
margin: 0;
height: 30px;
flex-shrink: 0;
ion-label {
font-size: 13px;
}
ion-icon {
cursor: pointer;
font-size: 18px;
}
}
ion-input {
min-width: 120px;
flex: 1;
}
}
.dropdown-container {
position: relative;
}
.dropdown-list {
position: absolute;
top: 100%;
left: 16px;
right: 16px;
z-index: 100;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--ion-color-light-shade);
border-radius: 8px;
background: var(--ion-background-color, #fff);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
ion-item {
--min-height: 40px;
font-size: 14px;
cursor: pointer;
&:hover {
--background: var(--ion-color-light);
}
}
}
ion-item.ion-invalid.ion-touched {
--border-color: var(--ion-color-danger);
--highlight-color-invalid: var(--ion-color-danger);
ion-label {
color: var(--ion-color-danger) !important;
}
}
.error-msg {
display: block;
font-size: 12px;
color: var(--ion-color-danger);
padding: 2px 16px 4px;
}
ion-chip[color="primary"] {
--background: var(--ion-color-primary);
--color: var(--ion-color-primary-contrast);
}
ion-chip[color="secondary"] {
--background: var(--ion-color-secondary);
--color: var(--ion-color-secondary-contrast);
}

View File

@@ -1,6 +1,8 @@
import { Component, OnInit, NgZone } from '@angular/core';
import { NavController, LoadingController } from '@ionic/angular';
import { AuthService } from 'src/app/services/auth.service';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from 'src/app/services/language.service';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AlertService } from 'src/app/services/alert.service';
@@ -10,6 +12,7 @@ declare var google: any;
selector: 'app-hero',
templateUrl: './hero.page.html',
styleUrls: ['./hero.page.scss'],
standalone: false
})
export class HeroPage implements OnInit {
@@ -17,139 +20,224 @@ export class HeroPage implements OnInit {
categories: any[] = [];
categories_input: any[] = [];
categories_rearranged: any[] = [];
keywords: any[] = [];
keywords_string: any[] = [];
keywords_text: string = '';
aux_categories: any[] = [];
filteredCategories: any[] = [];
categorySearchText: string = '';
showCategoryDropdown: boolean = false;
keywords: string[] = [];
keywordInput: string = '';
myPosition: any = {};
myAddress: string | null = null;
myIntnumber: string | null = null;
banks: any[] = [];
reference: string | null = null;
name: string | null = null;
rfc: string | null = null;
selectedBank: number | null = null;
bankAccount: number | null = null;
fee: number | null = null;
feeDisplay: string = '';
selectedReference: number = 0;
addressAutocomplete: string = '';
placesSearch: any = '';
showinput: boolean = false;
showif = true;
submitted: boolean = false;
touched: { [key: string]: boolean } = {};
constructor(
private navCtrl: NavController,
private loadingCtrl: LoadingController,
private authService: AuthService,
private alertService: AlertService,
private translateService: TranslateService,
private languageService: LanguageService,
private ichambaService: IchambaService,
private ngZone: NgZone,
) { }
ngOnInit() {
/*this.categories = ["carpintero","jardinero","abogado","administrador","agente inmobiliario","agente de seguros","albañil",
"arquitecto","asistente personal","becario","cerrajero","chef","chofer","contratista","contador","diseñador de interiores",
"cantante","diseñador grafico","edecan","electricista","estilista","servicios financieros","fontanero","fotografo","produccion de videos",
"hostess","lavado de tapicerias","aire acondicionado","almacenista","ayudante","azulejero","cerrajero automotriz","cortinas metalicas",
"electrico automotriz","entretenimiento","fisioterapeuta","grua","herrero","ingeniero civil","laminado automotriz","limpieza","limpieza del hogar",
"mantenimiento","mariachi","masajista","marmolero","mecanico","mercadotecnia","mesero","modelo","musico","niñera","pintor","pintura automotriz",
"plomero","programador","publicista","recepcionista","remodelacion","repartidor","reparacion de celulares","reparacion de electronicos","soldador",
"tabla roquero","tapicero","tecnico en gas","tuneame la nave","traductor","tutor","vendedor","veterinario","vidrio y aluminio","reparacion de computadoras",
"mantenimiento de camiones","consultor","capacitacion","maestro","barbero","agencia de colocacion","agencia de viajes","paseador de perros","banquete",
"almacenaje","impermeabilizacion","redes de internet"];*/
this.ichambaService.getCategories()
.subscribe( categories => {
this.categories = categories;
this.aux_categories = categories;
this.filteredCategories = categories;
})
this.ichambaService.getBanks()
.subscribe( banks => {
this.banks = banks;
})
}
// ========== CATEGORÍAS CON CHIPS ==========
filterCategories(event: any) {
const searchTerm = (event.detail?.value || event.target?.value || '').toLowerCase();
this.categorySearchText = searchTerm;
if (searchTerm.length > 0) {
this.filteredCategories = this.categories.filter(cat =>
cat.toLowerCase().includes(searchTerm) && !this.categories_input.includes(cat)
);
this.showCategoryDropdown = true;
} else {
this.filteredCategories = this.categories.filter(cat => !this.categories_input.includes(cat));
this.showCategoryDropdown = false;
}
}
selectCategory(category: string) {
if (!this.categories_input.includes(category)) {
this.categories_input.push(category);
}
this.categorySearchText = '';
this.showCategoryDropdown = false;
this.filteredCategories = this.categories.filter(cat => !this.categories_input.includes(cat));
}
removeCategory(category: string) {
const index = this.categories_input.indexOf(category);
if (index > -1) {
this.categories_input.splice(index, 1);
}
this.filteredCategories = this.categories.filter(cat => !this.categories_input.includes(cat));
}
hideCategoryList() {
setTimeout(() => {
this.showCategoryDropdown = false;
this.touched['categories'] = true;
}, 200);
}
// ========== PALABRAS CLAVE CON CHIPS ==========
addKeyword(event?: any) {
this.touched['keywords'] = true;
const value = this.keywordInput?.trim();
if (value && value.length > 0) {
// Separar por comas si hay varias palabras
const newKeywords = value.split(',').map((k: string) => k.trim()).filter((k: string) => k.length > 0);
newKeywords.forEach((keyword: string) => {
if (!this.keywords.includes(keyword)) {
this.keywords.push(keyword);
}
});
this.keywordInput = '';
}
}
onKeywordKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
this.addKeyword();
} else if (event.key === 'Backspace' && !this.keywordInput && this.keywords.length > 0) {
this.keywords.pop();
}
}
removeKeyword(keyword: string) {
const index = this.keywords.indexOf(keyword);
if (index > -1) {
this.keywords.splice(index, 1);
}
}
dismissHero() {
this.navCtrl.navigateRoot('/landing');
this.navCtrl.navigateRoot('/dashboard');
}
autocomplete(ev: any) {
if (!this.addressAutocomplete.trim().length) {
const value = (ev.detail?.value ?? this.addressAutocomplete).trim();
this.myAddress = null;
if (!value.length) {
this.placesSearch = null;
return;
}
console.log(this.addressAutocomplete)
new google.maps.places.AutocompleteService().getPredictions({ input: this.addressAutocomplete }, predictions => {
new google.maps.places.AutocompleteService().getPredictions({ input: value }, (predictions: any) => {
this.ngZone.run(() => {
this.placesSearch = predictions;
console.log(predictions);
});
});
}
geoloc(place_id: string, place_description: string, place_intnumber: string) {
this.myAddress = place_description;
this.addressAutocomplete = place_description;
this.myIntnumber = place_intnumber;
console.log(place_id);
this.placesSearch = null;
this.hidelist();
new google.maps.Geocoder().geocode({ placeId: place_id }, coordinates => {
new google.maps.Geocoder().geocode({ placeId: place_id }, (coordinates: any) => {
this.ngZone.run(() => {
console.log(coordinates[0].geometry.location.lat() + ", " + coordinates[0].geometry.location.lng());
const result = coordinates[0];
this.myPosition = {
latitude: coordinates[0].geometry.location.lat(),
longitude: coordinates[0].geometry.location.lng()
}
latitude: result.geometry.location.lat(),
longitude: result.geometry.location.lng()
};
});
});
}
selected_reference(ev: any) {
console.log(ev.target.value);
this.selectedReference = ev.target.value;
if (this.selectedReference == 5) {
this.showinput = true;
} else {
this.showinput = false;
}
this.showinput = ev.detail.value === 5;
}
onFeeInput(ev: any) {
const raw = (ev.detail?.value ?? '').replace(/[^0-9.]/g, '');
const parts = raw.split('.');
const integer = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const decimal = parts.length > 1 ? '.' + parts[1].slice(0, 2) : '';
this.feeDisplay = raw ? `$${integer}${decimal}` : '';
this.fee = raw ? parseFloat(raw) : null;
}
addHero(){
// categories_input is now an array of values directly from ion-select
this.categories_rearranged = this.categories_input.slice();
this.submitted = true;
const categoriesString = this.categories_input.join(',');
const keywordsString = this.keywords.length > 0 ? this.keywords.join(', ') : '';
// keywords_text is now a string, split by comma
if (this.keywords_text && this.keywords_text.trim().length > 0) {
this.keywords_string = this.keywords_text.split(',').map(k => k.trim()).filter(k => k.length > 0);
}
if (this.name && this.categories_input && this.myAddress && this.myPosition.latitude && this.myPosition.longitude && this.selectedReference){
if (this.selectedReference == 5 && this.reference) {
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.addHero(this.name, this.categories_rearranged.join(','), (this.keywords_string.join(', ') == "" ? null : this.keywords_string.join(', ')), this.myAddress, this.myPosition.latitude, this.myPosition.longitude, this.selectedReference, this.reference).subscribe(
data => {
this.loading.dismiss();
this.alertService.presentToast(data['message']);
this.navCtrl.navigateRoot('/dashboard');
}, error => {
this.loading.dismiss();
this.alertService.presentToast("Por favor contacte a soporte técnico, Estatus:" + error['status']);
});
} else if (this.selectedReference !== 5) {
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.addHero(this.name, this.categories_rearranged.join(','), (this.keywords_string.join(', ') == "" ? null : this.keywords_string.join(', ')), this.myAddress, this.myPosition.latitude, this.myPosition.longitude, this.selectedReference, this.reference).subscribe(
data => {
this.loading.dismiss();
this.alertService.presentToast(data['message']);
this.navCtrl.navigateRoot('/dashboard');
}, error => {
this.loading.dismiss();
this.alertService.presentToast("Por favor contacte a soporte técnico, Estatus:" + error['status']);
});
} else {
if (
this.name &&
this.rfc &&
this.categories_input.length > 0 &&
this.myAddress &&
this.myPosition.latitude &&
this.myPosition.longitude &&
this.selectedBank !== null &&
this.bankAccount &&
this.fee !== null &&
this.selectedReference
) {
if (this.selectedReference === 5 && !this.reference) {
this.alertService.presentToast("Por favor, específique cómo supo de nosotros");
return;
}
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.addHero(this.name!, this.rfc!, categoriesString, keywordsString, this.myAddress!, this.myPosition.latitude, this.myPosition.longitude, this.selectedBank ?? 0, this.bankAccount ?? 0, this.fee ?? 0, this.selectedReference, this.reference ?? '').subscribe(
(data: any) => {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(data['message']);
this.navCtrl.navigateRoot('/dashboard');
}, (error: any) => {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
} else {
this.alertService.presentToast("Llene todos los datos solicitados");
}
}
markTouched(field: string) {
this.touched[field] = true;
}
blurAddressList() {
setTimeout(() => {
this.hidelist();
this.touched['address'] = true;
}, 200);
}
showlist() {
this.showif = true;
}

View File

@@ -4,11 +4,14 @@ import { Injectable } from '@angular/core';
providedIn: 'root'
})
export class EnvService {
API_URL = 'http://192.168.10.207:8080/api/';
API_URL = 'https://jobhero-api.consultoria-as.com/api/';
SECRET = 'wBIIKuDbrxNKzQhAUGiZLoaoQ4MichAN3wP2AP7B';
MERCHANT_ID = 'm9k4beuso5az0wjqztvt';
PUBLIC_API_KEY = 'pk_9465179493384689a8d2da9adc825411';
ONESIGNAL_APP_ID = 'c854ae89-7ff7-4216-a70e-5fdff0cd8e10';
// Bypass de pago para pruebas (cambiar a false para producción)
PAYMENT_BYPASS = true;
constructor() { }
}

View File

@@ -1,7 +1,9 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import { NavController } from '@ionic/angular';
import { EnvService } from './env.service';
import { Router } from '@angular/router';
import { AlertService } from './alert.service';
import { EventService } from './event.service';
declare var OneSignalPlugin: any;
@@ -13,78 +15,78 @@ export class OneSignalService {
constructor(
private env: EnvService,
private router: Router
private navCtrl: NavController,
private alertService: AlertService,
private events: EventService,
private ngZone: NgZone,
) { }
async init(): Promise<void> {
if (this.initialized || !Capacitor.isNativePlatform()) {
return;
}
if (this.initialized || !Capacitor.isNativePlatform()) return;
try {
// Initialize OneSignal
OneSignalPlugin.initialize(this.env.ONESIGNAL_APP_ID);
// Request notification permission
OneSignalPlugin.Notifications.requestPermission(true).then((accepted: boolean) => {
console.log('OneSignal notification permission:', accepted ? 'accepted' : 'denied');
console.log('OneSignal permission:', accepted ? 'accepted' : 'denied');
});
// Handle notification clicks
OneSignalPlugin.Notifications.addEventListener('click', (event: any) => {
console.log('OneSignal notification clicked:', event);
this.handleNotificationClick(event);
});
// Handle foreground notifications
// Foreground: show alert + navigate
OneSignalPlugin.Notifications.addEventListener('foregroundWillDisplay', (event: any) => {
console.log('OneSignal notification received in foreground:', event);
// Display the notification
event.getNotification().display();
const notification = event.getNotification();
notification.display();
this.ngZone.run(() => {
this.alertService.presentAlert(notification.title, notification.body, ['OK']);
this.navigateByTitle(notification.title);
});
});
// Tap on notification: navigate only
OneSignalPlugin.Notifications.addEventListener('click', (event: any) => {
const title = event.notification?.title;
this.ngZone.run(() => this.navigateByTitle(title));
});
this.initialized = true;
console.log('OneSignal initialized successfully');
} catch (error) {
console.error('Error initializing OneSignal:', error);
}
}
async setUserId(userId: number | string): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
private navigateByTitle(title: string): void {
if (title === 'Proveedor: hay nueva postulación' || title === 'Hero: there is a new postulation') {
this.navCtrl.navigateRoot('/postulations');
this.events.publish('refreshpostulations', 'data');
} else if (title === 'Búsqueda Finalizada' || title === 'Search finished') {
this.navCtrl.navigateRoot('/contracts');
} else if (title === 'Usuario: el proveedor ha iniciado el servicio' || title === 'User: the Hero has started the service') {
this.navCtrl.navigateRoot('/contracts');
}
}
async setUserId(userId: number | string): Promise<void> {
if (!Capacitor.isNativePlatform()) return;
try {
// Set user tag for targeting
OneSignalPlugin.login(String(userId));
OneSignalPlugin.User.addTag('iChamba_ID', String(userId));
console.log('OneSignal user tag set:', userId);
} catch (error) {
console.error('Error setting OneSignal user tag:', error);
console.error('Error setting OneSignal user:', error);
}
}
async setUserRole(roleId: number | string): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
if (!Capacitor.isNativePlatform()) return;
try {
OneSignalPlugin.User.addTag('iChamba_Role', String(roleId));
console.log('OneSignal role tag set:', roleId);
} catch (error) {
console.error('Error setting OneSignal role tag:', error);
}
}
async clearTags(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
if (!Capacitor.isNativePlatform()) return;
try {
OneSignalPlugin.User.removeTags(['iChamba_ID', 'iChamba_Role']);
console.log('OneSignal tags cleared');
} catch (error) {
console.error('Error clearing OneSignal tags:', error);
}
@@ -92,38 +94,20 @@ export class OneSignalService {
async logout(): Promise<void> {
await this.clearTags();
}
private handleNotificationClick(event: any): void {
const data = event?.notification?.additionalData;
if (data) {
// Handle navigation based on notification data
if (data.route) {
this.router.navigate([data.route]);
} else if (data.type) {
switch (data.type) {
case 'contract':
this.router.navigate(['/contracts']);
break;
case 'postulation':
this.router.navigate(['/postulations']);
break;
default:
this.router.navigate(['/dashboard']);
}
if (Capacitor.isNativePlatform()) {
try {
OneSignalPlugin.logout();
} catch (error) {
console.error('Error logging out OneSignal:', error);
}
}
}
async getPlayerId(): Promise<string | null> {
if (!Capacitor.isNativePlatform()) {
return null;
}
if (!Capacitor.isNativePlatform()) return null;
try {
const deviceState = await OneSignalPlugin.User.pushSubscription.getId();
return deviceState || null;
const id = await OneSignalPlugin.User.pushSubscription.getId();
return id || null;
} catch (error) {
console.error('Error getting OneSignal player ID:', error);
return null;

View File

@@ -74,6 +74,24 @@ ion-app {
}
}
// Consistent button styling to match social login buttons
ion-button::part(native) {
border-radius: 6px;
font-weight: 500;
letter-spacing: 0.06em;
box-shadow: none;
}
// Border for light-colored buttons so they don't blend into white backgrounds
ion-button[color="light"]::part(native) {
border: 1px solid #DADCE0;
}
// No border when light button is inside a colored toolbar
ion-toolbar:not([color="light"]):not([color=""]) ion-button[color="light"]::part(native) {
border: none;
}
.rate_div {
rating {