Frontend Components - luckydeva03/barbershop_app GitHub Wiki
Panduan lengkap komponen frontend, UI/UX, dan teknologi yang digunakan dalam barbershop management system.
- CSS Framework: Bootstrap 5.3+ & Tailwind CSS
- JavaScript: Vanilla JavaScript ES6+
- Build Tool: Vite dengan Hot Module Replacement (HMR)
- Icons: Feather Icons, Bootstrap Icons, Font Awesome
- UI Libraries: Choices.js, DataTables, Perfect Scrollbar
- Charts: Chart.js untuk analytics
- Maps: Google Maps API
- Notifications: Toastify.js
- File Upload: FilePond
- Rich Text: TinyMCE
- Rating: Rater.js
:root {
/* Primary Colors */
--primary-50: #eff6ff;
--primary-100: #dbeafe;
--primary-500: #3b82f6;
--primary-600: #2563eb;
--primary-700: #1d4ed8;
/* Secondary Colors */
--secondary-50: #f8fafc;
--secondary-500: #64748b;
--secondary-600: #475569;
/* Success Colors */
--success-50: #f0fdf4;
--success-500: #22c55e;
--success-600: #16a34a;
/* Warning Colors */
--warning-50: #fffbeb;
--warning-500: #f59e0b;
--warning-600: #d97706;
/* Danger Colors */
--danger-50: #fef2f2;
--danger-500: #ef4444;
--danger-600: #dc2626;
}
/* Font Family */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Heading Styles */
.h1, h1 { font-size: 2.5rem; font-weight: 700; }
.h2, h2 { font-size: 2rem; font-weight: 600; }
.h3, h3 { font-size: 1.75rem; font-weight: 600; }
.h4, h4 { font-size: 1.5rem; font-weight: 500; }
.h5, h5 { font-size: 1.25rem; font-weight: 500; }
.h6, h6 { font-size: 1rem; font-weight: 500; }
/* Text Utilities */
.text-muted { color: var(--secondary-500); }
.text-small { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Barbershop Management') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700" rel="stylesheet" />
<!-- Styles -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('styles')
</head>
<body class="font-sans antialiased">
<div id="app">
<!-- Navigation -->
@include('components.navigation.main-nav')
<!-- Main Content -->
<main class="py-4">
@yield('content')
</main>
<!-- Footer -->
@include('components.footer')
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
@stack('scripts')
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title') - Admin Dashboard</title>
@vite(['resources/css/admin.css', 'resources/js/admin.js'])
@stack('styles')
</head>
<body>
<div class="wrapper">
<!-- Sidebar -->
@include('components.admin.sidebar')
<!-- Main Content -->
<div class="main-content">
<!-- Top Navigation -->
@include('components.admin.topbar')
<!-- Page Content -->
<div class="content-wrapper">
@if(session('success'))
@include('components.alerts.success', ['message' => session('success')])
@endif
@if(session('error'))
@include('components.alerts.error', ['message' => session('error')])
@endif
@yield('content')
</div>
</div>
</div>
@stack('scripts')
</body>
</html>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<!-- Brand -->
<a class="navbar-brand" href="{{ route('home') }}">
<img src="{{ asset('images/logo.png') }}" alt="Logo" height="32">
{{ config('app.name') }}
</a>
<!-- Mobile Toggle -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<!-- Navigation Links -->
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('home') ? 'active' : '' }}"
href="{{ route('home') }}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('stores.*') ? 'active' : '' }}"
href="{{ route('stores.index') }}">Stores</a>
</li>
</ul>
<!-- User Menu -->
<ul class="navbar-nav">
@auth
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button"
data-bs-toggle="dropdown">
@if(auth()->user()->profile_photo)
<img src="{{ auth()->user()->profile_photo }}"
alt="Profile" class="rounded-circle me-1" width="24" height="24">
@endif
{{ auth()->user()->name }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ route('dashboard') }}">Dashboard</a></li>
<li><a class="dropdown-item" href="{{ route('profile.edit') }}">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="dropdown-item">Logout</button>
</form>
</li>
</ul>
</li>
@else
<li class="nav-item">
<a class="nav-link" href="{{ route('login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">Register</a>
</li>
@endauth
</ul>
</div>
</div>
</nav>
<div class="sidebar">
<!-- Logo -->
<div class="sidebar-brand">
<img src="{{ asset('images/logo.png') }}" alt="Logo" height="32">
<span>Admin Panel</span>
</div>
<!-- Navigation -->
<nav class="sidebar-nav">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}"
href="{{ route('admin.dashboard') }}">
<i data-feather="home"></i>
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('admin.users.*') ? 'active' : '' }}"
href="{{ route('admin.users.index') }}">
<i data-feather="users"></i>
Users
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('admin.codes.*') ? 'active' : '' }}"
href="{{ route('admin.codes.index') }}">
<i data-feather="tag"></i>
Redeem Codes
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('admin.reviews.*') ? 'active' : '' }}"
href="{{ route('admin.reviews.index') }}">
<i data-feather="star"></i>
Reviews
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ request()->routeIs('admin.analytics.*') ? 'active' : '' }}"
href="{{ route('admin.analytics.index') }}">
<i data-feather="bar-chart-2"></i>
Analytics
</a>
</li>
</ul>
</nav>
</div>
<div class="card stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title h5 mb-0">{{ $title }}</h3>
<p class="card-subtitle text-muted mb-0">{{ $subtitle ?? '' }}</p>
</div>
<div class="stats-icon">
<i data-feather="{{ $icon }}" class="text-{{ $color ?? 'primary' }}"></i>
</div>
</div>
<div class="mt-3">
<h2 class="display-6 fw-bold text-{{ $color ?? 'primary' }}">{{ $value }}</h2>
@if(isset($change))
<small class="text-{{ $change > 0 ? 'success' : 'danger' }}">
<i data-feather="{{ $change > 0 ? 'trending-up' : 'trending-down' }}"></i>
{{ abs($change) }}% from last month
</small>
@endif
</div>
</div>
</div>
{{-- Usage Example:
@include('components.cards.stats-card', [
'title' => 'Total Users',
'value' => '1,234',
'icon' => 'users',
'color' => 'primary',
'change' => 12.5
])
--}}
<div class="card store-card h-100">
@if($store->image_url)
<img src="{{ $store->image_url }}" class="card-img-top" alt="{{ $store->name }}">
@endif
<div class="card-body">
<h5 class="card-title">{{ $store->name }}</h5>
<p class="card-text text-muted">{{ Str::limit($store->description, 100) }}</p>
<!-- Rating -->
<div class="d-flex align-items-center mb-2">
<div class="rating-stars me-2">
@for($i = 1; $i <= 5; $i++)
<i class="fas fa-star {{ $i <= $store->average_rating ? 'text-warning' : 'text-muted' }}"></i>
@endfor
</div>
<small class="text-muted">
{{ number_format($store->average_rating, 1) }}
({{ $store->reviews_count }} reviews)
</small>
</div>
<!-- Address -->
<p class="card-text">
<i data-feather="map-pin" class="me-1"></i>
{{ $store->address }}
</p>
<!-- Phone -->
@if($store->phone)
<p class="card-text">
<i data-feather="phone" class="me-1"></i>
{{ $store->phone }}
</p>
@endif
</div>
<div class="card-footer">
<div class="d-grid gap-2">
<a href="{{ route('stores.show', $store) }}" class="btn btn-outline-primary">
View Details
</a>
@if($store->phone)
<a href="https://wa.me/{{ preg_replace('/[^0-9]/', '', $store->phone) }}?text={{ urlencode('Halo! Saya ingin booking di ' . $store->name) }}"
class="btn btn-success" target="_blank">
<i class="fab fa-whatsapp me-1"></i>
Book via WhatsApp
</a>
@endif
</div>
</div>
</div>
<div class="mb-3">
@if($label ?? false)
<label for="{{ $id ?? $name }}" class="form-label">
{{ $label }}
@if($required ?? false)
<span class="text-danger">*</span>
@endif
</label>
@endif
<input
type="{{ $type ?? 'text' }}"
class="form-control @error($name) is-invalid @enderror"
id="{{ $id ?? $name }}"
name="{{ $name }}"
value="{{ old($name, $value ?? '') }}"
placeholder="{{ $placeholder ?? '' }}"
{{ ($required ?? false) ? 'required' : '' }}
{{ $attributes ?? '' }}
>
@error($name)
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
@if($help ?? false)
<div class="form-text">{{ $help }}</div>
@endif
</div>
{{-- Usage Example:
@include('components.forms.input', [
'name' => 'email',
'label' => 'Email Address',
'type' => 'email',
'required' => true,
'help' => 'We will never share your email with anyone.'
])
--}}
<div class="mb-3">
@if($label ?? false)
<label for="{{ $id ?? $name }}" class="form-label">
{{ $label }}
@if($required ?? false)
<span class="text-danger">*</span>
@endif
</label>
@endif
<select
class="form-select @error($name) is-invalid @enderror"
id="{{ $id ?? $name }}"
name="{{ $name }}"
{{ ($required ?? false) ? 'required' : '' }}
{{ $attributes ?? '' }}
>
@if($placeholder ?? false)
<option value="">{{ $placeholder }}</option>
@endif
@foreach($options as $value => $text)
<option value="{{ $value }}" {{ old($name, $selected ?? '') == $value ? 'selected' : '' }}>
{{ $text }}
</option>
@endforeach
</select>
@error($name)
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
@if($help ?? false)
<div class="form-text">{{ $help }}</div>
@endif
</div>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i data-feather="check-circle" class="me-2"></i>
{{ $message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i data-feather="alert-circle" class="me-2"></i>
{{ $message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<div class="modal fade" id="{{ $id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $title }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{{ $message }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-{{ $type ?? 'danger' }}" id="{{ $id }}-confirm">
{{ $confirmText ?? 'Confirm' }}
</button>
</div>
</div>
</div>
</div>
<script>
document.getElementById('{{ $id }}-confirm').addEventListener('click', function() {
@if($action ?? false)
{{ $action }}
@endif
});
</script>
class ToastManager {
constructor() {
this.container = document.getElementById('toast-container') || this.createContainer();
}
createContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
show(message, type = 'success', duration = 5000) {
const toast = document.createElement('div');
toast.className = `toast align-items-center text-bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast"></button>
</div>
`;
this.container.appendChild(toast);
const bsToast = new bootstrap.Toast(toast, { delay: duration });
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => {
toast.remove();
});
}
success(message) { this.show(message, 'success'); }
error(message) { this.show(message, 'danger'); }
warning(message) { this.show(message, 'warning'); }
info(message) { this.show(message, 'info'); }
}
// Global toast instance
window.toast = new ToastManager();
class FormValidator {
constructor(form) {
this.form = form;
this.rules = {};
this.init();
}
init() {
this.form.addEventListener('submit', (e) => {
if (!this.validate()) {
e.preventDefault();
e.stopPropagation();
}
this.form.classList.add('was-validated');
});
// Real-time validation
this.form.querySelectorAll('input, select, textarea').forEach(field => {
field.addEventListener('blur', () => this.validateField(field));
});
}
addRule(fieldName, validator, message) {
if (!this.rules[fieldName]) {
this.rules[fieldName] = [];
}
this.rules[fieldName].push({ validator, message });
}
validateField(field) {
const fieldName = field.name;
const rules = this.rules[fieldName] || [];
for (const rule of rules) {
if (!rule.validator(field.value)) {
this.showFieldError(field, rule.message);
return false;
}
}
this.clearFieldError(field);
return true;
}
validate() {
let isValid = true;
this.form.querySelectorAll('input, select, textarea').forEach(field => {
if (!this.validateField(field)) {
isValid = false;
}
});
return isValid;
}
showFieldError(field, message) {
field.classList.add('is-invalid');
let feedback = field.parentNode.querySelector('.invalid-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
field.parentNode.appendChild(feedback);
}
feedback.textContent = message;
}
clearFieldError(field) {
field.classList.remove('is-invalid');
const feedback = field.parentNode.querySelector('.invalid-feedback');
if (feedback) {
feedback.remove();
}
}
}
// Usage example
document.addEventListener('DOMContentLoaded', function() {
const redeemForm = document.getElementById('redeem-form');
if (redeemForm) {
const validator = new FormValidator(redeemForm);
validator.addRule('code',
value => value.length >= 4,
'Code must be at least 4 characters'
);
validator.addRule('code',
value => /^[A-Z0-9]+$/.test(value),
'Code can only contain letters and numbers'
);
}
});
class DataTableWrapper {
constructor(tableSelector, options = {}) {
this.table = document.querySelector(tableSelector);
this.options = {
pageLength: 25,
responsive: true,
order: [[0, 'desc']],
language: {
search: "Search:",
lengthMenu: "Show _MENU_ entries per page",
info: "Showing _START_ to _END_ of _TOTAL_ entries",
paginate: {
first: "First",
last: "Last",
next: "Next",
previous: "Previous"
}
},
...options
};
this.init();
}
init() {
if (this.table && typeof DataTable !== 'undefined') {
this.dataTable = new DataTable(this.table, this.options);
this.addCustomFilters();
}
}
addCustomFilters() {
// Add date range filter
const dateFilter = document.querySelector('[data-filter="date"]');
if (dateFilter) {
dateFilter.addEventListener('change', (e) => {
this.dataTable.draw();
});
}
// Add status filter
const statusFilter = document.querySelector('[data-filter="status"]');
if (statusFilter) {
statusFilter.addEventListener('change', (e) => {
this.dataTable.column(3).search(e.target.value).draw();
});
}
}
refresh() {
if (this.dataTable) {
this.dataTable.ajax.reload();
}
}
}
// Initialize data tables
document.addEventListener('DOMContentLoaded', function() {
// Users table
if (document.querySelector('#users-table')) {
new DataTableWrapper('#users-table', {
ajax: '/admin/users/data',
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'email' },
{ data: 'total_points' },
{ data: 'created_at' },
{ data: 'actions', orderable: false }
]
});
}
// Codes table
if (document.querySelector('#codes-table')) {
new DataTableWrapper('#codes-table');
}
});
/* resources/css/components/cards.css */
.stats-card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: box-shadow 0.15s ease-in-out;
}
.stats-card:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.stats-icon {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: var(--bs-light);
}
.store-card {
transition: transform 0.2s ease-in-out;
}
.store-card:hover {
transform: translateY(-2px);
}
.rating-stars .fa-star {
font-size: 0.875rem;
}
/* resources/css/components/navigation.css */
.navbar-brand img {
margin-right: 0.5rem;
}
.sidebar {
width: 250px;
height: 100vh;
background-color: var(--bs-dark);
position: fixed;
left: 0;
top: 0;
z-index: 1000;
}
.sidebar-brand {
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
color: white;
}
.sidebar-nav {
padding: 1rem 0;
}
.sidebar-nav .nav-link {
color: rgba(255, 255, 255, 0.75);
padding: 0.75rem 1rem;
display: flex;
align-items: center;
text-decoration: none;
transition: all 0.2s ease;
}
.sidebar-nav .nav-link:hover,
.sidebar-nav .nav-link.active {
color: white;
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar-nav .nav-link i {
margin-right: 0.5rem;
width: 1.25rem;
}
/* resources/css/responsive.css */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.stats-card {
margin-bottom: 1rem;
}
.store-card {
margin-bottom: 1.5rem;
}
}
@media (max-width: 576px) {
.navbar-brand {
font-size: 1rem;
}
.display-6 {
font-size: 1.5rem;
}
.card-body {
padding: 1rem;
}
}
// resources/js/utils/lazy-loading.js
class LazyLoader {
constructor() {
this.imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
this.imageObserver.unobserve(img);
}
});
});
this.init();
}
init() {
const lazyImages = document.querySelectorAll('img[data-src]');
lazyImages.forEach(img => {
this.imageObserver.observe(img);
});
}
}
document.addEventListener('DOMContentLoaded', () => {
new LazyLoader();
});
// vite.config.js
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['bootstrap'],
charts: ['chart.js'],
tables: ['datatables.net'],
maps: ['google-maps-api'],
}
}
},
cssCodeSplit: true,
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
}
}
}
});
Next: Customer Portal Guide untuk panduan lengkap fitur customer.