feat(fullstack): implement observability and redesign frontend UI
Build & Push Docker Images / test-backend (push) Failing after 36s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / build-backend (push) Has been skipped
Build & Push Docker Images / build-frontend (push) Has been skipped
Build & Push Docker Images / deploy (push) Successful in 7s

This commit introduces comprehensive observability for both backend and frontend, alongside a major UI/UX overhaul to align with modern design standards (Material 3 inspired) and improve localization.

Backend changes:
- Integrated OpenTelemetry SDK for distributed tracing.
- Added Pino for structured JSON logging with OTel instrumentation for trace/span correlation.
- Configured OTLP exporters to route traces and logs to Grafana Alloy.
- Updated docker-compose to include necessary environment variables for observability.

Frontend changes:
- Integrated Grafana Faro for Real User Monitoring (RUM), capturing Web Vitals, JS errors, and user interactions.
- Redesigned the entire UI using a new color palette and Material 3 design principles.
- Refactored component architecture (App, Home, Search, Scanner, Profile, Alerts views) for better state management and navigation.
- Improved mobile UX with a redesigned Bottom Navigation bar and Top Bar.
- Localized the interface to Spanish (es).
- Updated assets, icons, and PWA configuration (manifest, icons).
- Refactored CSS to use a centralized design token system (CSS variables).

Observability enables better debugging and performance monitoring across the entire stack.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 11:48:27 +02:00
parent 9316f6c54f
commit db0935eb16
46 changed files with 5633 additions and 1867 deletions
+72 -38
View File
@@ -1,23 +1,22 @@
/* BottomNav — mobile-only fixed bottom tab bar. Desktop hides via display:none above 768px. */
.bottom-nav {
display: none;
}
@media (max-width: 768px) {
.bottom-nav {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
justify-content: space-around;
align-items: flex-end;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom));
background: var(--glass-bg);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-top: 1px solid var(--border);
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
background: var(--surface-container-lowest);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
height: 5.5rem;
}
.bottom-nav-item {
@@ -25,19 +24,21 @@
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.1rem;
gap: 0.15rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
color: var(--on-surface-variant);
font: inherit;
-webkit-tap-highlight-color: transparent;
transition: color 0.15s;
position: relative;
min-width: 3.5rem;
}
.bottom-nav-item.disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
@@ -45,45 +46,78 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-full);
color: inherit;
transition: background 0.2s, color 0.2s, transform 0.2s;
position: relative;
}
/* Elevated center tab (Scan) — sits above the bar like iOS center FAB */
.bottom-nav-item:nth-child(2) {
margin-top: -1.1rem;
.nav-fab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: var(--radius-full);
background: var(--tertiary-container);
color: var(--on-tertiary);
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
margin-top: -1.5rem;
transition: transform 0.15s;
}
.nav-icon-wrap--elevated {
background: var(--primary);
color: #fff;
width: 3.25rem;
height: 3.25rem;
box-shadow:
0 0 0 4px var(--glass-bg),
0 8px 18px rgba(15, 118, 110, 0.4),
0 2px 6px rgba(15, 118, 110, 0.25);
.nav-fab:active {
transform: scale(0.92);
}
.bottom-nav-item.active .nav-label {
.nav-badge {
position: absolute;
top: 0;
right: -2px;
width: 1.125rem;
height: 1.125rem;
background: var(--error);
color: var(--on-error);
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: 2px solid var(--surface-container-lowest);
line-height: 1;
}
.nav-label {
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
color: var(--on-surface-variant);
}
.nav-label--active {
color: var(--primary);
font-weight: 700;
}
.bottom-nav-item.active .nav-icon-wrap:not(.nav-icon-wrap--elevated) {
.nav-indicator {
width: 0.375rem;
height: 0.375rem;
background: var(--primary);
border-radius: var(--radius-full);
margin-top: 0.125rem;
}
.bottom-nav-item.active .nav-icon-wrap {
color: var(--primary);
}
.bottom-nav-item:active .nav-icon-wrap--elevated {
transform: scale(0.94);
.bottom-nav-item.active .nav-icon-wrap svg {
stroke-width: 2.5;
}
.nav-label {
font-size: 0.68rem;
line-height: 1;
letter-spacing: 0.01em;
.nav-elevated.active .nav-label {
color: var(--tertiary);
}
}
}
+29 -15
View File
@@ -1,19 +1,18 @@
import { IconSearch, IconScan, IconBell, IconUser } from './icons';
import { IconHome, IconSearch, IconScan, IconBell, IconUser } from './icons';
import './BottomNav.css';
// ponytail: 4 tabs. Scan is the 2nd item (center) and gets the elevated teal pill.
// ponytail: when logged out, "saved" slot morphs into Login. Profile is disabled until login.
function BottomNav({ activeTab, onChange, isLoggedIn }) {
function BottomNav({ activeTab, onChange, isLoggedIn, badgeCount }) {
const tabs = [
{ id: 'search', label: 'Search', Icon: IconSearch },
{ id: 'scan', label: 'Scan', Icon: IconScan, elevated: true },
{ id: 'saved', label: 'Saved', Icon: IconBell, requiresAuth: true },
{ id: 'profile', label: 'Profile', Icon: IconUser, requiresAuth: true },
{ id: 'home', label: 'Inicio', Icon: IconHome },
{ id: 'search', label: 'Buscar', Icon: IconSearch },
{ id: 'scan', label: 'Escanear', Icon: IconScan, elevated: true },
{ id: 'alerts', label: 'Avisos', Icon: IconBell, badge: badgeCount > 0, badgeCount },
{ id: 'profile', label: 'Usuario', Icon: IconUser },
];
return (
<nav className="bottom-nav" aria-label="Primary">
{tabs.map(({ id, label, Icon, elevated, requiresAuth }) => {
<nav className="bottom-nav" aria-label="Navegación principal">
{tabs.map(({ id, label, Icon, elevated, badge, badgeCount: count, requiresAuth }) => {
const disabled = requiresAuth && !isLoggedIn;
const isActive = activeTab === id;
return (
@@ -24,16 +23,31 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
'bottom-nav-item',
isActive ? 'active' : '',
disabled ? 'disabled' : '',
elevated ? 'nav-elevated' : '',
].filter(Boolean).join(' ')}
onClick={() => onChange(id)}
onClick={() => {
if (!disabled) onChange(id);
}}
disabled={disabled}
aria-current={isActive ? 'page' : undefined}
aria-label={label}
>
<span className={`nav-icon-wrap${elevated ? ' nav-icon-wrap--elevated' : ''}`}>
<Icon size={elevated ? 24 : 22} />
{elevated ? (
<div className="nav-fab">
<Icon size={28} />
</div>
) : (
<span className="nav-icon-wrap">
<Icon size={26} />
{badge && (
<span className="nav-badge">{count}</span>
)}
</span>
)}
<span className={`nav-label${isActive && !elevated ? ' nav-label--active' : ''}`}>
{label}
</span>
<span className="nav-label">{label}</span>
{isActive && !elevated && <span className="nav-indicator" />}
</button>
);
})}
@@ -41,4 +55,4 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
);
}
export default BottomNav;
export default BottomNav;
+38 -57
View File
@@ -1,34 +1,31 @@
.medicine-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
gap: 1rem;
margin-top: 0.5rem;
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.5rem 0.5rem 0.5rem 0;
animation: fadeInUp 0.8s ease-out 0.4s backwards;
animation: fadeInUp 0.5s ease-out;
}
.medicine-card {
background: var(--surface);
border-radius: var(--radius);
padding: 1.65rem;
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
padding: 1.25rem;
cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
border: 1px solid var(--border);
transition: transform 0.15s, box-shadow 0.15s;
border: 1px solid var(--outline-variant);
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 0 1px 3px rgba(28, 25, 23, 0.04);
box-shadow: var(--shadow-soft);
}
@media (hover: hover) {
.medicine-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08);
border-color: var(--primary);
}
.medicine-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: var(--primary);
}
.medicine-card-header {
@@ -40,19 +37,18 @@
}
.medicine-card h3 {
color: var(--text-main);
font-size: 1.2rem;
color: var(--on-surface);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.01em;
transition: color 0.2s;
flex: 1;
margin: 0;
}
.notify-bell {
background: var(--surface-muted, #f5f5f4);
border: 1px solid var(--border, #e7e5e4);
border-radius: 999px;
background: var(--surface-container-low);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-full);
width: 2.75rem;
height: 2.75rem;
cursor: pointer;
@@ -69,11 +65,9 @@
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
@media (hover: hover) {
.notify-bell:hover:not(:disabled) {
border-color: var(--primary, #2563eb);
transform: scale(1.05);
}
.notify-bell:hover:not(:disabled) {
border-color: var(--primary);
transform: scale(1.05);
}
.notify-bell:active:not(:disabled) {
@@ -86,56 +80,39 @@
}
.notify-bell--on {
background: var(--primary, #2563eb);
border-color: var(--primary, #2563eb);
background: var(--primary);
border-color: var(--primary);
}
.notify-bell--locked {
opacity: 0.55;
}
@media (hover: hover) {
.notify-bell--locked:hover:not(:disabled) {
border-color: var(--text-muted, #78716c);
transform: scale(1.05);
}
}
.notify-error {
margin-top: 0.5rem;
font-size: 0.78rem;
color: #b91c1c;
}
@media (hover: hover) {
.medicine-card:hover h3 {
color: var(--primary);
}
.medicine-card:hover .view-pharmacies::after {
transform: translateX(4px);
}
color: var(--error);
}
.medicine-card-body {
margin-bottom: 1.35rem;
margin-bottom: 1.15rem;
}
.medicine-card-body p {
font-size: 0.9rem;
color: var(--text-muted);
color: var(--on-surface-variant);
line-height: 1.55;
margin-bottom: 0.25rem;
}
.medicine-card-body strong {
color: var(--text-main);
color: var(--on-surface);
font-weight: 600;
}
.medicine-card-footer {
padding-top: 1.15rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
border-top: 1px solid var(--outline-variant);
}
.view-pharmacies {
@@ -152,11 +129,15 @@
transition: transform 0.2s;
}
.medicine-card:hover .view-pharmacies::after {
transform: translateX(4px);
}
.no-results {
text-align: center;
padding: 2.75rem 1.5rem;
background: var(--surface-muted);
border-radius: var(--radius);
color: var(--text-muted);
border: 1px dashed var(--border-strong);
padding: 2.5rem 1.5rem;
background: var(--surface-container-low);
border-radius: var(--radius-md);
color: var(--on-surface-variant);
border: 1px dashed var(--outline-variant);
}
+41 -108
View File
@@ -3,37 +3,34 @@
}
.pharmacy-list-title {
font-size: 1.4rem;
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1.35rem;
color: var(--text-main);
margin-bottom: 1rem;
color: var(--on-surface);
display: flex;
align-items: center;
gap: 0.65rem;
letter-spacing: -0.02em;
gap: 0.5rem;
}
.pharmacy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
gap: 1rem;
}
.pharmacy-card {
background: var(--surface);
border-radius: var(--radius);
padding: 1.65rem;
border: 1px solid var(--border);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
box-shadow: 0 1px 3px rgba(28, 25, 23, 0.04);
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
padding: 1.25rem;
border: 1px solid var(--outline-variant);
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: var(--shadow-soft);
}
@media (hover: hover) {
.pharmacy-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08);
border-color: var(--primary);
}
.pharmacy-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: var(--primary);
}
.pharmacy-header {
@@ -41,15 +38,14 @@
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.pharmacy-header h4 {
color: var(--text-main);
color: var(--on-surface);
font-weight: 700;
margin: 0;
font-size: 1.1rem;
transition: color 0.2s;
font-size: 1.05rem;
flex: 1;
}
@@ -60,80 +56,18 @@
flex-shrink: 0;
}
.notify-bell {
background: var(--surface-muted, #f5f5f4);
border: 1px solid var(--border, #e7e5e4);
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 1;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
@media (hover: hover) {
.notify-bell:hover:not(:disabled) {
border-color: var(--primary, #2563eb);
transform: scale(1.05);
}
}
.notify-bell:active:not(:disabled) {
transform: scale(0.95);
}
.notify-bell:disabled {
opacity: 0.55;
cursor: progress;
}
.notify-bell--on {
background: var(--primary, #2563eb);
border-color: var(--primary, #2563eb);
}
.notify-bell--locked {
opacity: 0.55;
}
@media (hover: hover) {
.notify-bell--locked:hover:not(:disabled) {
border-color: var(--text-muted, #78716c);
transform: scale(1.05);
}
}
.notify-error {
margin-top: 0.5rem;
font-size: 0.78rem;
color: #b91c1c;
}
.pharmacy-distance {
flex-shrink: 0;
background: rgba(37, 99, 235, 0.1);
background: rgba(0, 69, 13, 0.08);
color: var(--primary);
font-size: 0.78rem;
font-weight: 700;
padding: 0.3rem 0.7rem;
border-radius: 999px;
letter-spacing: 0.02em;
white-space: nowrap;
border-radius: var(--radius-full);
}
@media (hover: hover) {
.pharmacy-card:hover .pharmacy-header h4 {
color: var(--primary);
}
.pharmacy-card:hover .pharmacy-header h4 {
color: var(--primary);
}
.pharmacy-details {
@@ -144,7 +78,7 @@
.pharmacy-address,
.pharmacy-phone {
color: var(--text-muted);
color: var(--on-surface-variant);
font-size: 0.9rem;
margin: 0;
line-height: 1.45;
@@ -164,29 +98,29 @@
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
border-radius: var(--radius-full);
background: currentColor;
}
.pharmacy-hours--open {
color: var(--accent, #047857);
color: var(--primary);
}
.pharmacy-hours--closed {
color: var(--text-muted);
color: var(--on-surface-variant);
}
.pharmacy-pricing {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--outline-variant);
}
.price {
font-size: 1.2rem;
font-size: 1.15rem;
font-weight: 700;
color: var(--primary);
}
@@ -194,35 +128,34 @@
.stock {
font-size: 0.72rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border-radius: var(--radius-full);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stock.in-stock {
background: rgba(4, 120, 87, 0.12);
color: var(--accent);
background: rgba(0, 69, 13, 0.1);
color: var(--primary);
}
.stock.low-stock {
background: rgba(180, 83, 9, 0.12);
color: var(--accent-warm);
color: #b45309;
}
.stock.out-of-stock {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
background: rgba(186, 26, 26, 0.1);
color: var(--error);
}
.loading-pharmacies,
.no-pharmacies {
text-align: center;
padding: 2.75rem 1.5rem;
background: var(--surface-muted);
border-radius: var(--radius);
color: var(--text-muted);
border: 1px solid var(--border);
padding: 2.5rem 1.5rem;
background: var(--surface-container-low);
border-radius: var(--radius-md);
color: var(--on-surface-variant);
border: 1px solid var(--outline-variant);
margin-top: 1rem;
}
+26 -28
View File
@@ -1,30 +1,29 @@
.search-bar-container {
margin-bottom: 2.5rem;
width: 100%;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
margin-bottom: 1.5rem;
}
.search-bar {
display: flex;
align-items: center;
background: var(--surface);
border-radius: var(--radius);
padding: 0.45rem 1.25rem;
box-shadow: var(--glass-shadow);
border: 1px solid var(--border);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
padding: 0.25rem 1rem;
border: 2px solid var(--outline-variant);
transition: border-color 0.2s;
position: relative;
}
.search-bar:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring), var(--glass-shadow);
}
.search-icon {
font-size: 1.2rem;
margin-right: 0.85rem;
opacity: 0.45;
color: var(--primary);
font-size: 1.5rem;
margin-right: 0.75rem;
display: flex;
align-items: center;
flex-shrink: 0;
}
.search-input {
@@ -32,35 +31,34 @@
border: none;
background: transparent;
padding: 1rem 0;
font-size: 1.1rem;
font-family: inherit;
color: var(--text-main);
font-size: 1.125rem;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
color: var(--on-surface);
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
opacity: 0.65;
color: var(--on-surface-variant);
opacity: 0.6;
}
.clear-button {
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text-muted);
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--surface-container-low);
border: 1px solid var(--outline-variant);
color: var(--on-surface-variant);
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
transition: background 0.2s, color 0.2s, transform 0.2s;
transition: background 0.15s;
margin-left: 0.5rem;
flex-shrink: 0;
}
.clear-button:hover {
background: var(--surface-card);
color: var(--text-main);
transform: rotate(90deg);
background: var(--surface-container);
}
+7 -3
View File
@@ -5,7 +5,12 @@ function SearchBar({ value, onChange, placeholder }) {
return (
<div className="search-bar-container">
<div className="search-bar">
<span className="search-icon">🔍</span>
<span className="search-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
<input
type="text"
value={value}
@@ -18,7 +23,7 @@ function SearchBar({ value, onChange, placeholder }) {
<button
className="clear-button"
onClick={() => onChange('')}
aria-label="Clear search"
aria-label="Limpiar búsqueda"
>
</button>
@@ -29,4 +34,3 @@ function SearchBar({ value, onChange, placeholder }) {
}
export default SearchBar;
+35 -60
View File
@@ -1,88 +1,63 @@
/* TopBar — mobile-only sticky header. Desktop hides via display:none above 768px. */
.top-bar {
display: none;
}
@media (max-width: 768px) {
.top-bar {
display: block;
position: sticky;
top: 0;
z-index: 40;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
padding: calc(0.65rem + env(safe-area-inset-top)) 1rem 0.65rem;
background: var(--glass-bg);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
height: var(--touch-target-min);
padding: 0 var(--margin-main);
max-width: 48rem;
margin: 0 auto;
}
.topbar-brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-main);
}
.topbar-brand-icon {
color: var(--primary);
flex-shrink: 0;
}
.topbar-brand-text {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.topbar-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
}
.topbar-action {
.topbar-back {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
border: 1px solid transparent;
border-radius: 999px;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-full);
border: none;
background: transparent;
color: var(--text-main);
color: var(--primary);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: background 0.15s, color 0.15s, border-color 0.15s;
transition: background 0.15s;
}
.topbar-action:hover {
background: var(--surface-muted);
.topbar-back:hover {
background: var(--surface-container-high);
}
.topbar-action--admin {
.topbar-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--primary);
border-color: rgba(15, 118, 110, 0.25);
background: rgba(15, 118, 110, 0.08);
letter-spacing: -0.01em;
}
.topbar-action--admin:hover {
background: rgba(15, 118, 110, 0.15);
.topbar-logo-img {
height: 2rem;
width: auto;
}
.topbar-action--login {
color: var(--primary);
border-color: rgba(15, 118, 110, 0.25);
.topbar-spacer {
width: 2.5rem;
}
.topbar-action--placeholder {
width: 2.25rem;
.topbar-right {
display: flex;
align-items: center;
}
}
}
+19 -57
View File
@@ -1,66 +1,28 @@
import { IconBell, IconCog, IconUser, IconLogo } from './icons';
import './TopBar.css';
function TopBar({
currentUser,
isAdmin,
onShowSaved,
onLoginRequest,
onAdminClick,
authChecked,
}) {
// ponytail: action slot priority — admin pill > bell (logged-in) > login button (logged-out).
// ponytail: hidden on desktop via .top-bar { display: none } above 768px.
function renderAction() {
if (isAdmin) {
return (
<button
type="button"
className="topbar-action topbar-action--admin"
onClick={onAdminClick}
aria-label="Admin panel"
>
<IconCog size={18} />
<span>Admin</span>
</button>
);
}
if (currentUser) {
return (
<button
type="button"
className="topbar-action"
onClick={onShowSaved}
aria-label="Saved notifications"
>
<IconBell size={22} />
</button>
);
}
if (authChecked) {
return (
<button
type="button"
className="topbar-action topbar-action--login"
onClick={onLoginRequest}
>
<IconUser size={18} />
<span>Login</span>
</button>
);
}
return <span className="topbar-action topbar-action--placeholder" />;
}
function TopBar({ title, showBack, onBack, rightAction }) {
return (
<header className="top-bar">
<div className="topbar-brand">
<IconLogo size={22} className="topbar-brand-icon" />
<span className="topbar-brand-text">FarmaFinder</span>
<div className="topbar-inner">
{showBack ? (
<button className="topbar-back" onClick={onBack} aria-label="Volver">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<polyline points="12 19 5 12 12 5" />
</svg>
</button>
) : (
<div className="topbar-spacer" />
)}
{showBack ? (
<h1 className="topbar-title">{title}</h1>
) : (
<img src="/logo.png" alt="FarmaFinder" className="topbar-logo-img" />
)}
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
</div>
<div className="topbar-actions">{renderAction()}</div>
</header>
);
}
export default TopBar;
export default TopBar;
+15 -8
View File
@@ -1,10 +1,17 @@
// ponytail: stroke-based line icons, 24x24, currentColor. Emojis stay for in-content use.
// ponytail: add when a filled variant is needed for active states — swap stroke="currentColor" for fill.
export function IconHome({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconSearch({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
@@ -14,7 +21,7 @@ export function IconSearch({ size = 24, ...props }) {
export function IconScan({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
@@ -27,7 +34,7 @@ export function IconScan({ size = 24, ...props }) {
export function IconBell({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
@@ -37,7 +44,7 @@ export function IconBell({ size = 24, ...props }) {
export function IconUser({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
@@ -47,7 +54,7 @@ export function IconUser({ size = 24, ...props }) {
export function IconCog({ size = 24, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
@@ -60,4 +67,4 @@ export function IconLogo({ size = 24, ...props }) {
<path d="M19 8h-1V6a3 3 0 0 0-3-3H9a3 3 0 0 0-3 3v2H5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a3 3 0 0 0-3-3zM8 6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2H8V6zm4 11a3 3 0 1 1 3-3 3 3 0 0 1-3 3z" />
</svg>
);
}
}