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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user