npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@cbm-common/tip-modal

v0.0.1

Published

Componente modal de Angular para el cálculo y gestión de propinas en sistemas de punto de venta y restaurantes. Implementa un modal interactivo con validaciones avanzadas, cálculos automáticos y integración completa con formularios reactivos.

Readme

Tip Modal

Componente modal de Angular para el cálculo y gestión de propinas en sistemas de punto de venta y restaurantes. Implementa un modal interactivo con validaciones avanzadas, cálculos automáticos y integración completa con formularios reactivos.

📦 Instalación

npm install @cbm-common/tip-modal

⚙️ Configuración

Importación del Componente

El componente es standalone, por lo que se puede importar directamente:

import { CbmTipModalComponent } from '@cbm-common/tip-modal';

@Component({
  selector: 'app-tip-calculator',
  standalone: true,
  imports: [CbmTipModalComponent],
  template: `
    <cbm-tip-modal
      [isOpen]="showTipModal"
      [tip]="currentTip"
      [tipControl]="tipFormControl"
      [subtotal]="orderSubtotal"
      (close)="showTipModal = false"
      (open)="onModalOpen()"
    />
  `
})
export class TipCalculatorComponent {
  showTipModal = false;
  currentTip = '0';
  orderSubtotal = 25.50;

  tipFormControl = new FormControl<string | null>('0', [
    Validators.required,
    Validators.min(0)
  ]);

  onModalOpen() {
    console.log('Modal de propina abierto');
  }
}

🎯 Propiedades de Entrada (Inputs)

isOpen: boolean (Requerido)

Controla la visibilidad del modal.

// Abrir modal
this.showTipModal = true;

// Cerrar modal
this.showTipModal = false;

tip: string | null (Requerido)

Valor actual de la propina como string.

currentTip = '2.50'; // $2.50 de propina

tipControl: FormControl<string | null> (Requerido)

Control de formulario reactivo para la propina.

tipFormControl = new FormControl<string | null>('0', [
  Validators.required,
  Validators.min(0)
]);

subtotal: number (Requerido)

Subtotal de la orden para calcular porcentajes.

orderSubtotal = 25.50; // Subtotal de $25.50

📤 Eventos de Salida (Outputs)

open: void

Se emite cuando el modal se abre.

(open)="onModalOpen()"

close: void

Se emite cuando el modal se cierra.

(close)="onModalClose()"

🏗️ Arquitectura del Componente

Patrón de Diseño

El componente sigue el patrón Reactive Forms integrado con Signals de Angular:

CbmTipModalComponent
├── FormGroup con validaciones personalizadas
├── Cálculos automáticos entre porcentaje/valor
├── Efectos para sincronización reactiva
├── Animaciones personalizadas
└── Integración con directivas CBM

Formulario Interno

tipForm = new FormGroup({
  percentage: new FormControl<string | null>(null, [
    Validators.required,
    Validators.min(0),
    Validators.max(10)
  ]),
  value: new FormControl<string | null>(null, [
    Validators.required,
    Validators.min(0),
    this.valueValidator()
  ])
});

Validaciones Personalizadas

  • Porcentaje: 0-10% del subtotal
  • Valor: Máximo 10% del subtotal
  • Sincronización: Cálculo automático entre campos

🧮 Funcionalidades de Cálculo

Cálculo Automático

El componente calcula automáticamente entre porcentaje y valor:

// Si cambias el porcentaje:
percentage = 5; // 5%
// Se calcula automáticamente:
value = (5/100) * subtotal; // 5% de $25.50 = $1.275

// Si cambias el valor:
value = 2.50; // $2.50
// Se calcula automáticamente:
percentage = (2.50 / subtotal) * 100; // ($2.50 / $25.50) * 100 = 9.8%

Validaciones de Negocio

  • Límite máximo: La propina no puede exceder el 10% del subtotal
  • Valores positivos: No se permiten valores negativos
  • Precisión decimal: Hasta 2 decimales

🎨 Características Visuales

Diseño Responsive

  • Mobile-first: Optimizado para dispositivos móviles
  • Grid adaptable: 1 columna en móvil, 2 en desktop
  • Inputs numéricos: Con formato monospace y alineación derecha

Animaciones

  • Entrada/Salida: Transiciones suaves con opacidad
  • Validaciones: Animaciones en mensajes de error
  • Botones: Efectos hover y estados disabled

Tema y Estilos

  • Tailwind CSS: Framework de utilidades integrado
  • Shadow DOM: Encapsulación completa de estilos
  • Colores temáticos: Paleta consistente con el sistema CBM

🚀 Ejemplos de Uso

Ejemplo Básico

@Component({
  selector: 'app-order-tip',
  standalone: true,
  imports: [CbmTipModalComponent],
  template: `
    <div class="order-summary">
      <div class="subtotal">Subtotal: {{ subtotal | currency }}</div>
      <div class="tip">Propina: {{ tip || 0 | currency }}</div>
      <div class="total">Total: {{ (subtotal + (tip || 0)) | currency }}</div>

      <button (click)="openTipModal()" class="btn-primary">
        Agregar Propina
      </button>

      <cbm-tip-modal
        [isOpen]="showTipModal"
        [tip]="tip?.toString()"
        [tipControl]="tipControl"
        [subtotal]="subtotal"
        (close)="showTipModal = false"
      />
    </div>
  `
})
export class OrderTipComponent {
  subtotal = 25.50;
  tip: number | null = null;
  showTipModal = false;

  tipControl = new FormControl<string | null>('0', [
    Validators.required,
    Validators.min(0)
  ]);

  constructor() {
    // Escuchar cambios en el control de propina
    this.tipControl.valueChanges.subscribe(value => {
      this.tip = value ? parseFloat(value) : null;
    });
  }

  openTipModal() {
    this.showTipModal = true;
  }
}

Ejemplo con Formulario Reactivo Completo

@Component({
  selector: 'app-restaurant-order',
  standalone: true,
  imports: [CbmTipModalComponent, ReactiveFormsModule],
  template: `
    <form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
      <div class="order-items">
        <!-- Items del pedido -->
      </div>

      <div class="order-summary">
        <div>Subtotal: {{ orderForm.get('subtotal')?.value | currency }}</div>
        <div>Propina: {{ orderForm.get('tip')?.value || 0 | currency }}</div>
        <div>Total: {{ getTotal() | currency }}</div>
      </div>

      <div class="actions">
        <button type="button" (click)="openTipModal()" class="btn-secondary">
          Modificar Propina
        </button>

        <button type="submit" class="btn-primary">
          Procesar Pedido
        </button>
      </div>

      <cbm-tip-modal
        [isOpen]="showTipModal"
        [tip]="orderForm.get('tip')?.value?.toString()"
        [tipControl]="orderForm.get('tip')"
        [subtotal]="orderForm.get('subtotal')?.value"
        (close)="showTipModal = false"
      />
    </form>
  `
})
export class RestaurantOrderComponent {
  showTipModal = false;

  orderForm = this.fb.group({
    items: this.fb.array([]),
    subtotal: [25.50],
    tip: ['0', [Validators.required, Validators.min(0)]],
    total: [25.50]
  });

  constructor(private fb: FormBuilder) {
    // Calcular total cuando cambie subtotal o propina
    this.orderForm.valueChanges.subscribe(() => {
      this.calculateTotal();
    });
  }

  getTotal(): number {
    const subtotal = this.orderForm.get('subtotal')?.value || 0;
    const tip = this.orderForm.get('tip')?.value || 0;
    return subtotal + parseFloat(tip);
  }

  calculateTotal() {
    this.orderForm.patchValue({
      total: this.getTotal()
    }, { emitEvent: false });
  }

  openTipModal() {
    this.showTipModal = true;
  }

  onSubmit() {
    if (this.orderForm.valid) {
      console.log('Procesar pedido:', this.orderForm.value);
    }
  }
}

Ejemplo con Integración de API

@Component({
  selector: 'app-pos-system',
  standalone: true,
  imports: [CbmTipModalComponent],
  template: `
    <div class="pos-interface">
      <div class="order-display">
        <h3>Pedido Actual</h3>
        <div class="order-details">
          <div>Subtotal: {{ currentOrder.subtotal | currency }}</div>
          <div>Propina: {{ currentOrder.tip | currency }}</div>
          <div class="total-line">
            <strong>Total: {{ getTotal() | currency }}</strong>
          </div>
        </div>
      </div>

      <div class="action-buttons">
        <button (click)="addTip()" class="btn-tip">
          💰 Agregar Propina
        </button>

        <button (click)="processPayment()" class="btn-pay">
          💳 Procesar Pago
        </button>
      </div>

      <cbm-tip-modal
        [isOpen]="showTipModal"
        [tip]="currentOrder.tip.toString()"
        [tipControl]="tipControl"
        [subtotal]="currentOrder.subtotal"
        (close)="onTipModalClose()"
      />
    </div>
  `
})
export class PosSystemComponent {
  showTipModal = false;

  currentOrder = {
    items: [],
    subtotal: 25.50,
    tip: 0
  };

  tipControl = new FormControl<string | null>('0', [
    Validators.required,
    Validators.min(0)
  ]);

  constructor(private orderService: OrderService) {}

  getTotal(): number {
    return this.currentOrder.subtotal + this.currentOrder.tip;
  }

  addTip() {
    this.showTipModal = true;
  }

  onTipModalClose() {
    this.showTipModal = false;
    // Actualizar el pedido con la nueva propina
    const newTip = parseFloat(this.tipControl.value || '0');
    this.currentOrder.tip = newTip;

    // Opcional: guardar en el servidor
    this.orderService.updateOrderTip(this.currentOrder.id, newTip)
      .subscribe(() => {
        console.log('Propina actualizada en el servidor');
      });
  }

  processPayment() {
    const paymentData = {
      ...this.currentOrder,
      total: this.getTotal()
    };

    this.orderService.processPayment(paymentData)
      .subscribe(response => {
        console.log('Pago procesado:', response);
      });
  }
}

Ejemplo con Múltiples Pedidos

@Component({
  selector: 'app-multi-order-tip',
  standalone: true,
  imports: [CbmTipModalComponent],
  template: `
    <div class="orders-container">
      <div *ngFor="let order of orders; trackBy: trackByOrderId" class="order-card">
        <div class="order-header">
          <h4>Mesa {{ order.tableNumber }}</h4>
          <span class="status" [class]="order.status">{{ order.status }}</span>
        </div>

        <div class="order-summary">
          <div>Subtotal: {{ order.subtotal | currency }}</div>
          <div>Propina: {{ order.tip | currency }}</div>
          <div class="total">Total: {{ (order.subtotal + order.tip) | currency }}</div>
        </div>

        <button (click)="openTipModal(order)" class="btn-tip">
          Modificar Propina
        </button>
      </div>

      <cbm-tip-modal
        [isOpen]="showTipModal"
        [tip]="selectedOrder?.tip.toString()"
        [tipControl]="tipControl"
        [subtotal]="selectedOrder?.subtotal"
        (close)="onTipModalClose()"
      />
    </div>
  `
})
export class MultiOrderTipComponent {
  showTipModal = false;
  selectedOrder: Order | null = null;

  orders: Order[] = [
    { id: 1, tableNumber: 5, subtotal: 25.50, tip: 0, status: 'active' },
    { id: 2, tableNumber: 8, subtotal: 45.75, tip: 2.50, status: 'paid' },
    { id: 3, tableNumber: 12, subtotal: 18.25, tip: 0, status: 'active' }
  ];

  tipControl = new FormControl<string | null>('0', [
    Validators.required,
    Validators.min(0)
  ]);

  trackByOrderId(index: number, order: Order): number {
    return order.id;
  }

  openTipModal(order: Order) {
    this.selectedOrder = order;
    this.tipControl.setValue(order.tip.toString());
    this.showTipModal = true;
  }

  onTipModalClose() {
    if (this.selectedOrder) {
      const newTip = parseFloat(this.tipControl.value || '0');
      this.selectedOrder.tip = newTip;

      // Actualizar en el servidor
      this.orderService.updateOrder(this.selectedOrder.id, {
        tip: newTip
      }).subscribe();
    }

    this.showTipModal = false;
    this.selectedOrder = null;
  }
}

interface Order {
  id: number;
  tableNumber: number;
  subtotal: number;
  tip: number;
  status: 'active' | 'paid' | 'cancelled';
}

⚠️ Manejo de Errores

Validaciones del Formulario

// Errores de porcentaje
percentageControl.errors = {
  required: true,        // Campo requerido
  min: { min: 0 },       // Valor mínimo
  max: { max: 10 }       // Valor máximo (10%)
}

// Errores de valor
valueControl.errors = {
  required: true,        // Campo requerido
  min: { min: 0 },       // Valor mínimo
  invalid: 'La propina no puede ser mayor al 10% del subtotal'
}

Estados de Validación

  • Campos requeridos: Ambos campos son obligatorios
  • Rango de porcentaje: 0% - 10% del subtotal
  • Límite de valor: Máximo 10% del subtotal
  • Precisión decimal: Hasta 2 decimales

Mensajes de Error

Los errores se muestran automáticamente usando CbmErrorTranslatePipe:

  • Mensajes traducidos al español
  • Animaciones de entrada/salida
  • Estilos visuales consistentes

🔧 Configuración Avanzada

Personalización de Validaciones

// Crear un validador personalizado
customTipValidator(maxPercentage: number = 15): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;
    const subtotal = this.subtotal();

    if (value == null) return { required: true };

    const maxTip = (maxPercentage / 100) * subtotal;
    if (Number(value) > maxTip) {
      return {
        invalid: `La propina no puede ser mayor al ${maxPercentage}% del subtotal`
      };
    }

    return null;
  };
}

// Usar el validador personalizado
this.valueControl.setValidators([
  Validators.required,
  Validators.min(0),
  this.customTipValidator(15) // Máximo 15%
]);

Integración con Directivas Personalizadas

// El componente incluye integración automática con:
- CbmNumberInputDirective: Formato numérico y validaciones
- CbmErrorTranslatePipe: Traducción de mensajes de error

Configuración de Animaciones

// Animación personalizada para errores
export const errorAnimation = trigger('errorAnimation', [
  transition(':enter', [
    style({ opacity: 0, transform: 'translateY(-10px)' }),
    animate('200ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
  ]),
  transition(':leave', [
    animate('150ms ease-in', style({ opacity: 0, transform: 'translateY(-10px)' }))
  ])
]);

📋 Dependencias

Peer Dependencies (Requeridas)

{
  "@angular/common": ">=20.1.5",
  "@angular/core": ">=20.1.5",
  "@cbm-common/number-input-directive": "0.0.x",
  "@cbm-common/error-translate-pipe": "0.0.x"
}

Dependencias Internas

{
  "tslib": "^2.3.0"
}

Dependencias de Desarrollo

{
  "tailwindcss": "^4.1.11",
  "postcss": "^8.5.6",
  "@tailwindcss/postcss": "^4.1.11"
}

🛠️ Desarrollo

Estructura del Proyecto

tip-modal/
├── src/
│   ├── lib/
│   │   ├── tip-modal.component.ts       # Componente principal
│   │   ├── tip-modal.component.html     # Template del modal
│   │   ├── tip-modal.component.css      # Estilos específicos
│   │   ├── styles.css                   # Estilos Tailwind
│   │   ├── animations.ts                # Animaciones Angular
│   │   └── index.ts                     # Exportaciones
│   └── public-api.ts                    # API pública
├── ng-package.json                      # Configuración empaquetado
├── package.json                         # Dependencias
└── README.md                            # Esta documentación

Construcción

# Construir la librería
ng build tip-modal

# Construir en modo watch
ng build tip-modal --watch

# Construir para producción
ng build tip-modal --configuration production

Pruebas

# Ejecutar pruebas unitarias
ng test tip-modal

# Ejecutar pruebas con coverage
ng test tip-modal --code-coverage

Desarrollo con Tailwind

# Compilar estilos Tailwind
npm run tailwind

# Modo watch para desarrollo
npm run tailwind --watch

🎯 Mejores Prácticas

1. Gestión de Estado

// Usar un servicio para gestionar el estado de propinas
@Injectable({ providedIn: 'root' })
export class TipService {
  private tipSubject = new BehaviorSubject<number>(0);
  tip$ = this.tipSubject.asObservable();

  updateTip(tip: number) {
    this.tipSubject.next(tip);
    // Persistir en localStorage o servidor
    localStorage.setItem('lastTip', tip.toString());
  }

  getLastTip(): number {
    return parseFloat(localStorage.getItem('lastTip') || '0');
  }
}

2. Cálculos Complejos

// Para cálculos más complejos de propina
export class TipCalculator {
  static calculateSuggestedTips(subtotal: number): TipSuggestion[] {
    return [
      { percentage: 10, value: subtotal * 0.1, label: 'Excelente (10%)' },
      { percentage: 8, value: subtotal * 0.08, label: 'Muy bueno (8%)' },
      { percentage: 5, value: subtotal * 0.05, label: 'Bueno (5%)' }
    ];
  }

  static roundToNearestQuarter(amount: number): number {
    return Math.round(amount * 4) / 4;
  }
}

interface TipSuggestion {
  percentage: number;
  value: number;
  label: string;
}

3. Integración con Analytics

// Rastrear uso de propinas
export class TipAnalyticsService {
  trackTipChange(orderId: string, oldTip: number, newTip: number) {
    // Enviar evento a analytics
    this.analytics.event('tip_changed', {
      orderId,
      oldTip,
      newTip,
      difference: newTip - oldTip
    });
  }

  trackTipDistribution() {
    // Analizar distribución de propinas
    return this.http.get('/api/analytics/tips-distribution');
  }
}

🤝 Contribución

  1. Fork el repositorio
  2. Crea una rama para tu feature (git checkout -b feature/nueva-funcionalidad)
  3. Commit tus cambios (git commit -am 'Agrega nueva funcionalidad')
  4. Push a la rama (git push origin feature/nueva-funcionalidad)
  5. Abre un PullRequest

📄 Licencia

Este proyecto está bajo la Licencia MIT - ver el archivo LICENSE para más detalles.

📞 Soporte

Para soporte técnico o reportes de bugs, contacta al equipo de desarrollo de CBM:

🔄 Changelog

v0.0.1

  • ✅ Componente modal standalone para cálculo de propinas
  • ✅ Formulario reactivo con validaciones personalizadas
  • ✅ Cálculos automáticos entre porcentaje y valor
  • ✅ Integración con directivas CBM (number-input, error-translate)
  • ✅ Diseño responsive con Tailwind CSS
  • ✅ Animaciones personalizadas para errores
  • ✅ Encapsulación Shadow DOM
  • ✅ Documentación completa en español

Nota: Este componente está optimizado para sistemas de punto de venta y restaurantes. Las validaciones aseguran que las propinas se mantengan dentro de límites razonables (máximo 10% del subtotal).