feat(auth-firebase): implement Phase 2 integration - Firebase Auth, Session Sync, Favorites Grid, and Firestore Newsletter
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# FlixCooks Firebase Configuration
|
||||||
|
# Replace these placeholder values with your actual Firebase project settings.
|
||||||
|
|
||||||
|
FIREBASE_API_KEY="AIzaSyYourApiKeyHere"
|
||||||
|
FIREBASE_AUTH_DOMAIN="flixcooks-your-project-id.firebaseapp.com"
|
||||||
|
FIREBASE_PROJECT_ID="flixcooks-your-project-id"
|
||||||
|
FIREBASE_STORAGE_BUCKET="flixcooks-your-project-id.appspot.com"
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID="123456789012"
|
||||||
|
FIREBASE_APP_ID="1:123456789012:web:abcdef123456"
|
||||||
@@ -1 +1,2 @@
|
|||||||
.idx/*
|
.idx/*
|
||||||
|
.env
|
||||||
@@ -12,7 +12,7 @@ Dieses Dokument enthält den aktuellen Entwicklungsstand und detaillierte Aufgab
|
|||||||
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
|
- Fullscreen Overlay-Navigation (`LiquidOverlayMenu` & `SlideUpTextHover`)
|
||||||
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
|
- Geschmeidiges Scrollverhalten (`LenisSmoothScroll` & `IndexSectionIndicator`)
|
||||||
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
|
- Layout-Raster (`FloemaLayoutGrid`) & Premium-Buttons
|
||||||
- [ ] **Phase 2: Authentifizierung & Firebase Integration**
|
- [x] **Phase 2: Authentifizierung & Firebase Integration**
|
||||||
- Firebase-Projekt Setup & Anbindung
|
- Firebase-Projekt Setup & Anbindung
|
||||||
- Newsletter-System (Firebase Firestore)
|
- Newsletter-System (Firebase Firestore)
|
||||||
- Benutzerprofile (Registrierung, Login, Favoriten)
|
- Benutzerprofile (Registrierung, Login, Favoriten)
|
||||||
@@ -63,23 +63,23 @@ Das visuelle Fundament der Website. Die Etablierung des Premium-Designs stellt s
|
|||||||
### 🔒 Phase 2: Authentifizierung & Firebase Integration
|
### 🔒 Phase 2: Authentifizierung & Firebase Integration
|
||||||
Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore).
|
Das Fundament für Personalisierung und Newsletter-Abos über eine sichere Firebase-Anbindung (Auth & Firestore).
|
||||||
|
|
||||||
- [ ] **Firebase Setup & Initialisierung**
|
- [x] **Firebase Setup & Initialisierung**
|
||||||
- [ ] Firebase SDK Client-Side Einbindung in `partials/head.php` oder separaten Helper
|
- [x] Firebase SDK Client-Side Einbindung in `partials/head.php` or separaten Helper
|
||||||
- [ ] Firebase Config in umweltabhängigen Settings/Variablen auslagern
|
- [x] Firebase Config in umweltabhängigen Settings/Variablen auslagern
|
||||||
- [ ] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections)
|
- [x] Firestore-Datenbank initialisieren (`subscribers` und `users` Collections)
|
||||||
- [ ] **Premium Login- & Registrierungsseite**
|
- [x] **Premium Login- & Registrierungsseite**
|
||||||
- [ ] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil
|
- [x] Erstellung der Seite `login.php` (und optional `signup.php`) im neuen Design-System-Stil
|
||||||
- [ ] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren
|
- [x] Firebase Authentication (Email/Passwort Login & Registrierung) implementieren
|
||||||
- [ ] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look
|
- [x] Responsive UI mit sanften Error- & Success-Meldungen und Eingabefeldern im Glasmorphismus-Look
|
||||||
- [ ] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden)
|
- [x] Client-seitige und Server-seitige Session-Synchronisation (z.B. Firebase Session Tokens an PHP-Session via Ajax senden)
|
||||||
- [ ] **Favoriten-Funktion (Rezept-Bookmarks)**
|
- [x] **Favoriten-Funktion (Rezept-Bookmarks)**
|
||||||
- [ ] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`)
|
- [x] Firestore-Collection `user_favorites` anlegen (Format: `userId` -> Liste von `recipeSlugs`)
|
||||||
- [ ] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check
|
- [x] "Rezept speichern" Button (Herz-Icon) auf den Rezeptkarten und Rezept-Details mit dynamischem Login-Check
|
||||||
- [ ] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`)
|
- [x] Mikro-Animationen für das Herz-Icon (Scale up/down, Fülleffekte mit `var(--ease-overshoot)`)
|
||||||
- [ ] **Firebase-gestütztes Newsletter-Abo**
|
- [x] **Firebase-gestütztes Newsletter-Abo**
|
||||||
- [ ] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren
|
- [x] Newsletter-Eingabefeld im Footer (`partials/footer.php`) implementieren
|
||||||
- [ ] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern
|
- [x] AJAX-Submit-Handler: E-Mail-Adresse prüfen und in Firestore Collection `subscribers` speichern
|
||||||
- [ ] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload)
|
- [x] Elegantes Feedback-UI (Erfolgsmeldung ohne Page-Reload)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
$comingSoon = isset($_POST['coming_soon']) && $_POST['coming_soon'] === '1';
|
$comingSoon = isset($_POST['coming_soon']) && $_POST['coming_soon'] === '1';
|
||||||
$slugOriginal = trim($_POST['slug_original'] ?? '');
|
$slugOriginal = trim($_POST['slug_original'] ?? '');
|
||||||
|
|
||||||
|
$nutriCal = (int) ($_POST['nutrition_calories'] ?? 0);
|
||||||
|
$nutriProt = (int) ($_POST['nutrition_protein'] ?? 0);
|
||||||
|
$nutriCarb = (int) ($_POST['nutrition_carbs'] ?? 0);
|
||||||
|
$nutriFat = (int) ($_POST['nutrition_fat'] ?? 0);
|
||||||
|
|
||||||
// keep previous language data if fields left blank while editing
|
// keep previous language data if fields left blank while editing
|
||||||
$prevEn = $editing['i18n']['en'] ?? [];
|
$prevEn = $editing['i18n']['en'] ?? [];
|
||||||
$prevDe = $editing['i18n']['de'] ?? [];
|
$prevDe = $editing['i18n']['de'] ?? [];
|
||||||
@@ -261,6 +266,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
'servings' => $servings,
|
'servings' => $servings,
|
||||||
'featured' => $featured,
|
'featured' => $featured,
|
||||||
'coming_soon' => $comingSoon,
|
'coming_soon' => $comingSoon,
|
||||||
|
'nutrition' => [
|
||||||
|
'calories' => $nutriCal,
|
||||||
|
'protein' => $nutriProt,
|
||||||
|
'carbs' => $nutriCarb,
|
||||||
|
'fat' => $nutriFat
|
||||||
|
],
|
||||||
'i18n' => [
|
'i18n' => [
|
||||||
'en' => [
|
'en' => [
|
||||||
'title' => $titleEn,
|
'title' => $titleEn,
|
||||||
@@ -517,6 +528,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delet
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label>Calories (kcal)</label>
|
||||||
|
<input type="number" name="nutrition_calories" min="0" value="<?php echo e($editing['nutrition']['calories'] ?? 0); ?>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Protein (g)</label>
|
||||||
|
<input type="number" name="nutrition_protein" min="0" value="<?php echo e($editing['nutrition']['protein'] ?? 0); ?>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Carbs (g)</label>
|
||||||
|
<input type="number" name="nutrition_carbs" min="0" value="<?php echo e($editing['nutrition']['carbs'] ?? 0); ?>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Fat (g)</label>
|
||||||
|
<input type="number" name="nutrition_fat" min="0" value="<?php echo e($editing['nutrition']['fat'] ?? 0); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<h3>English</h3>
|
<h3>English</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// PHP session synchronizer endpoint for Firebase Auth
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'login') {
|
||||||
|
$uid = $_POST['uid'] ?? '';
|
||||||
|
$email = $_POST['email'] ?? '';
|
||||||
|
$token = $_POST['token'] ?? '';
|
||||||
|
|
||||||
|
if ($uid !== '') {
|
||||||
|
$_SESSION['fc_user'] = [
|
||||||
|
'uid' => $uid,
|
||||||
|
'email' => $email,
|
||||||
|
'token' => $token
|
||||||
|
];
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Logged in']);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Missing UID']);
|
||||||
|
}
|
||||||
|
} elseif ($action === 'logout') {
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get("session.use_cookies")) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$params["path"], $params["domain"],
|
||||||
|
$params["secure"], $params["httponly"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
echo json_encode(['status' => 'success', 'message' => 'Logged out']);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid action']);
|
||||||
|
}
|
||||||
@@ -1735,3 +1735,339 @@ body.nav-open { overflow: hidden; }
|
|||||||
.feature-grid { grid-template-columns: 1fr; }
|
.feature-grid { grid-template-columns: 1fr; }
|
||||||
.coming-features { margin-bottom: 40px; }
|
.coming-features { margin-bottom: 40px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ PHASE 2: AUTHENTICATION, FAVORITES & NEWSLETTER STYLES ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝ */
|
||||||
|
|
||||||
|
/* ── Premium Heart Buttons ── */
|
||||||
|
.card__image {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.btn-favorite {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
transition: background 0.3s ease, border-color 0.3s ease, transform 0.3s var(--ease-overshoot), color 0.3s ease;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .btn-favorite {
|
||||||
|
background: rgba(36, 31, 33, 0.7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
.btn-favorite:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .btn-favorite:hover {
|
||||||
|
background: rgba(36, 31, 33, 0.95);
|
||||||
|
}
|
||||||
|
.btn-favorite svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transition: stroke 0.3s ease, fill 0.3s ease;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ink);
|
||||||
|
}
|
||||||
|
.btn-favorite.active {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: rgba(195, 166, 119, 0.4);
|
||||||
|
}
|
||||||
|
.btn-favorite.active svg {
|
||||||
|
fill: var(--color-accent-gold);
|
||||||
|
stroke: var(--color-accent-gold);
|
||||||
|
animation: heartPop 0.45s var(--ease-overshoot) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For larger recipe layout title heart button */
|
||||||
|
.recipe__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn-favorite--large {
|
||||||
|
position: static;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-favorite--large svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
@keyframes heartPop {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.35); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Guest Favoriting Prompt Modal ── */
|
||||||
|
.guest-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(11, 9, 10, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.4s var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
.guest-modal-backdrop.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.guest-modal-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateY(30px);
|
||||||
|
transition: transform 0.5s var(--ease-overshoot);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
.guest-modal-backdrop.open .guest-modal-card {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.guest-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1.2rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.guest-modal-close:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.guest-modal-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.guest-modal-card h3 {
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.guest-modal-card p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.guest-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.guest-modal-actions .btn-reveal {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.guest-modal-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-sans-clean);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer Newsletter ── */
|
||||||
|
.footer-newsletter {
|
||||||
|
padding-bottom: 2.5rem;
|
||||||
|
border-bottom: 1px solid var(--stroke);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.footer-newsletter {
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.newsletter-content h3 {
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.newsletter-content p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.newsletter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.newsletter-input-group {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid var(--stroke);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.newsletter-input-group:focus-within {
|
||||||
|
border-color: var(--color-accent-gold);
|
||||||
|
}
|
||||||
|
.newsletter-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-sans-clean);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.newsletter-input-group input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.newsletter-input-group button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.3s var(--ease-overshoot), color 0.3s ease;
|
||||||
|
}
|
||||||
|
.newsletter-input-group button:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
.newsletter-input-group button .btn-success-check {
|
||||||
|
display: none;
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.newsletter-form.success .newsletter-input-group button .btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.newsletter-form.success .newsletter-input-group button .btn-success-check {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.newsletter-form.success .newsletter-input-group button {
|
||||||
|
transform: none !important;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.newsletter-form.success .newsletter-input-group {
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
.newsletter-feedback {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.newsletter-feedback.error {
|
||||||
|
color: #e02424;
|
||||||
|
}
|
||||||
|
.newsletter-feedback.success {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 22. PHASE 3: NUTRITION WIDGET ───────────────────────────── */
|
||||||
|
.nutrition-widget {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.nutrition-item {
|
||||||
|
flex: 1 1 calc(25% - 1.5rem);
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.nutrition-item__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-family: var(--font-sans-clean);
|
||||||
|
}
|
||||||
|
.nutrition-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.nutrition-value {
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.nutrition-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--ghost);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nutrition-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 1.5s var(--ease-out-expo) 0.2s;
|
||||||
|
will-change: width;
|
||||||
|
}
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
// Environment and Configuration Loader for FlixCooks
|
||||||
|
|
||||||
|
function load_env() {
|
||||||
|
$path = __DIR__ . '/.env';
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
// Skip comments
|
||||||
|
if (strpos(trim($line), '#') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Name=Value
|
||||||
|
if (strpos($line, '=') !== false) {
|
||||||
|
list($name, $value) = explode('=', $line, 2);
|
||||||
|
$name = trim($name);
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
// Remove wrapping quotes if present
|
||||||
|
$value = trim($value, '"\'');
|
||||||
|
|
||||||
|
// Populate getenv(), $_ENV, $_SERVER
|
||||||
|
putenv("{$name}={$value}");
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
$_SERVER[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically load on include
|
||||||
|
load_env();
|
||||||
|
|
||||||
|
function get_firebase_config(): array {
|
||||||
|
return [
|
||||||
|
'apiKey' => getenv('FIREBASE_API_KEY') ?: '',
|
||||||
|
'authDomain' => getenv('FIREBASE_AUTH_DOMAIN') ?: '',
|
||||||
|
'projectId' => getenv('FIREBASE_PROJECT_ID') ?: '',
|
||||||
|
'storageBucket' => getenv('FIREBASE_STORAGE_BUCKET') ?: '',
|
||||||
|
'messagingSenderId' => getenv('FIREBASE_MESSAGING_SENDER_ID') ?: '',
|
||||||
|
'appId' => getenv('FIREBASE_APP_ID') ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
+42
-18
@@ -8,6 +8,12 @@
|
|||||||
"servings": 2,
|
"servings": 2,
|
||||||
"featured": false,
|
"featured": false,
|
||||||
"coming_soon": false,
|
"coming_soon": false,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": 0,
|
||||||
|
"protein": 0,
|
||||||
|
"carbs": 0,
|
||||||
|
"fat": 0
|
||||||
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"en": {
|
"en": {
|
||||||
"title": "Tagliatelle with tomato sauce",
|
"title": "Tagliatelle with tomato sauce",
|
||||||
@@ -36,16 +42,16 @@
|
|||||||
"Optional: Blender"
|
"Optional: Blender"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 5\u201310 minutes until smooth.",
|
"Pile the flour on your counter, press a well in the center, crack in the eggs. Gradually pull flour into the eggs, then knead 5–10 minutes until smooth.",
|
||||||
"Roll the dough to 1\u20132 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.",
|
"Roll the dough to 1–2 mm thickness (rolling pin or pasta machine) and cut into tagliatelle.",
|
||||||
"Bring salted water to a boil. Meanwhile, halve tomatoes and sear in olive oil with a pinch of salt until lightly charred.",
|
"Bring salted water to a boil. Meanwhile, halve tomatoes and sear in olive oil with a pinch of salt until lightly charred.",
|
||||||
"Toast the garlic briefly, then blend tomatoes with cream (or crush in the pan) and simmer to thicken slightly.",
|
"Toast the garlic briefly, then blend tomatoes with cream (or crush in the pan) and simmer to thicken slightly.",
|
||||||
"Boil tagliatelle for 2\u20133 minutes, drain, toss with the sauce, and finish with basil and Parmesan."
|
"Boil tagliatelle for 2–3 minutes, drain, toss with the sauce, and finish with basil and Parmesan."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Tagliatelle mit Tomatensauce",
|
"title": "Tagliatelle mit Tomatensauce",
|
||||||
"description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht f\u00fcr einen gem\u00fctlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zus\u00e4tze. Nudeln mit Tomatensauce \u2013 herzhafter geht es kaum.",
|
"description": "Einfach, aber richtig lecker; kurze Kochzeit und trotzdem hausgemacht. Das Basisgericht für einen gemütlichen Abend, stillt den Kohlenhydrat-Hunger ohne versteckte Zusätze. Nudeln mit Tomatensauce – herzhafter geht es kaum.",
|
||||||
"category": "Pasta",
|
"category": "Pasta",
|
||||||
"difficulty": "Einfach",
|
"difficulty": "Einfach",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -58,7 +64,7 @@
|
|||||||
"3 Eier",
|
"3 Eier",
|
||||||
"10 Cherry-Tomaten",
|
"10 Cherry-Tomaten",
|
||||||
"1/2 Knoblauchzehe",
|
"1/2 Knoblauchzehe",
|
||||||
"1 EL Oliven\u00f6l",
|
"1 EL Olivenöl",
|
||||||
"80 ml Sahne",
|
"80 ml Sahne",
|
||||||
"Frischer Basilikum",
|
"Frischer Basilikum",
|
||||||
"Parmesan",
|
"Parmesan",
|
||||||
@@ -70,11 +76,11 @@
|
|||||||
"Optional: Mixer"
|
"Optional: Mixer"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Mehl auf der Arbeitsfl\u00e4che anh\u00e4ufen, eine Kuhle dr\u00fccken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 5\u201310 Minuten kneten, bis der Teig glatt ist.",
|
"Mehl auf der Arbeitsfläche anhäufen, eine Kuhle drücken, Eier hineingeben. Mehl nach und nach einarbeiten, dann 5–10 Minuten kneten, bis der Teig glatt ist.",
|
||||||
"Teig auf 1\u20132 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.",
|
"Teig auf 1–2 mm ausrollen (mit Nudelholz oder Nudelmaschine) und in Tagliatelle schneiden.",
|
||||||
"Gesalzenes Wasser aufsetzen; w\u00e4hrenddessen Tomaten halbieren und mit Oliven\u00f6l und einer Prise Salz in der Pfanne anr\u00f6sten, bis R\u00f6stnoten entstehen.",
|
"Gesalzenes Wasser aufsetzen; währenddessen Tomaten halbieren und mit Olivenöl und einer Prise Salz in der Pfanne anrösten, bis Röstnoten entstehen.",
|
||||||
"Knoblauch kurz mitr\u00f6sten, dann Tomaten mit Sahne p\u00fcrieren (Mixer) oder in der Pfanne zerdr\u00fccken und kurz einkochen lassen.",
|
"Knoblauch kurz mitrösten, dann Tomaten mit Sahne pürieren (Mixer) oder in der Pfanne zerdrücken und kurz einkochen lassen.",
|
||||||
"Tagliatelle 2\u20133 Minuten kochen, abgie\u00dfen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten."
|
"Tagliatelle 2–3 Minuten kochen, abgießen und mit der Sauce vermengen. Mit Basilikum und Parmesan anrichten."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,12 @@
|
|||||||
"servings": 2,
|
"servings": 2,
|
||||||
"featured": false,
|
"featured": false,
|
||||||
"coming_soon": true,
|
"coming_soon": true,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": 0,
|
||||||
|
"protein": 0,
|
||||||
|
"carbs": 0,
|
||||||
|
"fat": 0
|
||||||
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"en": {
|
"en": {
|
||||||
"title": "Oat-Pancakes",
|
"title": "Oat-Pancakes",
|
||||||
@@ -120,10 +132,16 @@
|
|||||||
"servings": 2,
|
"servings": 2,
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"coming_soon": false,
|
"coming_soon": false,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": 0,
|
||||||
|
"protein": 0,
|
||||||
|
"carbs": 0,
|
||||||
|
"fat": 0
|
||||||
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"en": {
|
"en": {
|
||||||
"title": "Steak with onion jam",
|
"title": "Steak with onion jam",
|
||||||
"description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good company\u2014because the onion jam still isn\u2019t satisfied on its own.",
|
"description": "A hearty meal after a long day, best enjoyed with a glass of red wine and in good company—because the onion jam still isn’t satisfied on its own.",
|
||||||
"category": "",
|
"category": "",
|
||||||
"difficulty": "Easy",
|
"difficulty": "Easy",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -157,14 +175,14 @@
|
|||||||
"Dice the onion",
|
"Dice the onion",
|
||||||
"Cook the potatoes and carrots until al dente",
|
"Cook the potatoes and carrots until al dente",
|
||||||
"Sear the steak with butter",
|
"Sear the steak with butter",
|
||||||
"Once both sides are golden brown, bake for 6 minutes at 160\u00b0C (medium); for medium rare, let it rest after searing both sides for 3 minutes",
|
"Once both sides are golden brown, bake for 6 minutes at 160°C (medium); for medium rare, let it rest after searing both sides for 3 minutes",
|
||||||
"Saut\u00e9 the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated",
|
"Sauté the onion in the steak butter, deglaze with red wine, and add cream once the alcohol has evaporated",
|
||||||
"Plate and serve!"
|
"Plate and serve!"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Steak mit Zwiebelmarmelade",
|
"title": "Steak mit Zwiebelmarmelade",
|
||||||
"description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genie\u00dfen, da die Zwiebelmarmelade noch nicht genug davon hat.",
|
"description": "Eine schwere Mahlzeit nach einem langen Tag, am besten zu einem Schluck Rotwein und in Gesellschaft genießen, da die Zwiebelmarmelade noch nicht genug davon hat.",
|
||||||
"category": "",
|
"category": "",
|
||||||
"difficulty": "Einfach",
|
"difficulty": "Einfach",
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -174,7 +192,7 @@
|
|||||||
"ingredients": [
|
"ingredients": [
|
||||||
"2 x 250g Rumpsteak",
|
"2 x 250g Rumpsteak",
|
||||||
"300gr Kartoffeln",
|
"300gr Kartoffeln",
|
||||||
"1 Gro\u00dfe Rote Zwiebel",
|
"1 Große Rote Zwiebel",
|
||||||
"1/2 Knoblauch-Zehe",
|
"1/2 Knoblauch-Zehe",
|
||||||
"50ml Sahne",
|
"50ml Sahne",
|
||||||
"50ml Rotwein",
|
"50ml Rotwein",
|
||||||
@@ -195,11 +213,11 @@
|
|||||||
"Steak 30 Minuten vor dem anbraten raus legen",
|
"Steak 30 Minuten vor dem anbraten raus legen",
|
||||||
"Kartoffeln halbieren",
|
"Kartoffeln halbieren",
|
||||||
"Karotten halbieren",
|
"Karotten halbieren",
|
||||||
"Zwiebel w\u00fcrfeln",
|
"Zwiebel würfeln",
|
||||||
"Kartoffeln und Karotten al dente kochen",
|
"Kartoffeln und Karotten al dente kochen",
|
||||||
"Steak anbraten mit Butter",
|
"Steak anbraten mit Butter",
|
||||||
"sobald beide seiten goldbraun sind f\u00fcr 6 Minuten bei 160grad backen (Medium) f\u00fcr Medium Rare nur ruhen lassen nachdem es von beiden Seiten f\u00fcr 3 Minuten angebraten wurde",
|
"sobald beide seiten goldbraun sind für 6 Minuten bei 160grad backen (Medium) für Medium Rare nur ruhen lassen nachdem es von beiden Seiten für 3 Minuten angebraten wurde",
|
||||||
"Zwiebel in Steak-Butter anschwitzen, mit Rotwein abl\u00f6schen und Sahne dazu geben nachdem er Alkohol verd\u00fcnstet ist",
|
"Zwiebel in Steak-Butter anschwitzen, mit Rotwein ablöschen und Sahne dazu geben nachdem er Alkohol verdünstet ist",
|
||||||
"Anrichten!"
|
"Anrichten!"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -214,6 +232,12 @@
|
|||||||
"servings": 2,
|
"servings": 2,
|
||||||
"featured": false,
|
"featured": false,
|
||||||
"coming_soon": true,
|
"coming_soon": true,
|
||||||
|
"nutrition": {
|
||||||
|
"calories": 0,
|
||||||
|
"protein": 0,
|
||||||
|
"carbs": 0,
|
||||||
|
"fat": 0
|
||||||
|
},
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"en": {
|
"en": {
|
||||||
"title": "Oat cake",
|
"title": "Oat cake",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
// Shared helper functions for the FlixCooks food blog.
|
// Shared helper functions for the FlixCooks food blog.
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
function load_recipes(): array {
|
function load_recipes(): array {
|
||||||
$path = __DIR__ . '/data/recipes.json';
|
$path = __DIR__ . '/data/recipes.json';
|
||||||
if (!file_exists($path)) {
|
if (!file_exists($path)) {
|
||||||
|
|||||||
@@ -398,7 +398,14 @@ include __DIR__ . '/partials/head.php';
|
|||||||
<img src="<?php echo e($featured['hero']); ?>" alt="<?php echo e($featured['title']); ?>">
|
<img src="<?php echo e($featured['hero']); ?>" alt="<?php echo e($featured['title']); ?>">
|
||||||
<div class="hero-card__body">
|
<div class="hero-card__body">
|
||||||
<p class="pill pill--gold"><?php echo e($t['featured']); ?></p>
|
<p class="pill pill--gold"><?php echo e($t['featured']); ?></p>
|
||||||
<h3><?php echo e($featured['title']); ?></h3>
|
<div class="recipe__title-row">
|
||||||
|
<h3><?php echo e($featured['title']); ?></h3>
|
||||||
|
<button class="btn-favorite" data-slug="<?php echo e($featured['slug']); ?>" aria-label="Save to favorites">
|
||||||
|
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p><?php echo e($featured['description']); ?></p>
|
<p><?php echo e($featured['description']); ?></p>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>⏱ <?php echo format_minutes((int)($featured['total_time'] ?? 0)); ?></span>
|
<span>⏱ <?php echo format_minutes((int)($featured['total_time'] ?? 0)); ?></span>
|
||||||
@@ -430,6 +437,11 @@ include __DIR__ . '/partials/head.php';
|
|||||||
<?php if (!empty($recipe['category'])): ?>
|
<?php if (!empty($recipe['category'])): ?>
|
||||||
<span class="pill pill--ghost"><?php echo e($recipe['category']); ?></span>
|
<span class="pill pill--ghost"><?php echo e($recipe['category']); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<button class="btn-favorite" data-slug="<?php echo e($recipe['slug']); ?>" aria-label="Save to favorites" onclick="event.preventDefault(); event.stopPropagation();">
|
||||||
|
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<h3><a href="<?php echo e($recipeUrl($recipe)); ?>"><?php echo e($recipe['title']); ?></a></h3>
|
<h3><a href="<?php echo e($recipeUrl($recipe)); ?>"><?php echo e($recipe['title']); ?></a></h3>
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/helpers.php';
|
||||||
|
|
||||||
|
$lang = (isset($_GET['lang']) && strtolower($_GET['lang']) === 'de') ? 'de' : 'en';
|
||||||
|
|
||||||
|
$copy = [
|
||||||
|
'en' => [
|
||||||
|
'page_title' => 'My Profile & Favorites | FlixCooks',
|
||||||
|
'meta_desc' => 'Manage your profile, dietary goals, and saved seasonal recipes on FlixCooks.',
|
||||||
|
'login' => 'Login',
|
||||||
|
'signup' => 'Register',
|
||||||
|
'email' => 'Email Address',
|
||||||
|
'password' => 'Password',
|
||||||
|
'confirm_pass' => 'Confirm Password',
|
||||||
|
'goal' => 'Dietary Goal',
|
||||||
|
'goal_placeholder' => 'Select your dietary goal',
|
||||||
|
'goal_loose' => 'Weight Loss / Low-Carb',
|
||||||
|
'goal_gain' => 'Muscle Gain / High-Protein',
|
||||||
|
'goal_healthy' => 'Healthy & Balanced',
|
||||||
|
'no_account' => "Don't have an account yet?",
|
||||||
|
'have_account' => 'Already have an account?',
|
||||||
|
'welcome' => 'Welcome back,',
|
||||||
|
'your_goal' => 'Your dietary goal',
|
||||||
|
'saved_recipes' => 'Your Saved Favorites',
|
||||||
|
'no_favorites' => "You haven't saved any recipes yet. Explore our collection and tap the heart icon!",
|
||||||
|
'logout' => 'Log Out',
|
||||||
|
'err_pass_match' => 'Passwords do not match.',
|
||||||
|
'err_fill_fields'=> 'Please fill in all fields.',
|
||||||
|
],
|
||||||
|
'de' => [
|
||||||
|
'page_title' => 'Mein Profil & Favoriten | FlixCooks',
|
||||||
|
'meta_desc' => 'Verwalte dein Profil, deine Ernährungsziele und gespeicherten Rezepte auf FlixCooks.',
|
||||||
|
'login' => 'Anmelden',
|
||||||
|
'signup' => 'Registrieren',
|
||||||
|
'email' => 'E-Mail-Adresse',
|
||||||
|
'password' => 'Passwort',
|
||||||
|
'confirm_pass' => 'Passwort bestätigen',
|
||||||
|
'goal' => 'Ernährungsziel',
|
||||||
|
'goal_placeholder' => 'Wähle dein Ernährungsziel',
|
||||||
|
'goal_loose' => 'Abnehmen / Low-Carb',
|
||||||
|
'goal_gain' => 'Muskelaufbau / High-Protein',
|
||||||
|
'goal_healthy' => 'Gesund & Ausgewogen',
|
||||||
|
'no_account' => "Noch kein Konto?",
|
||||||
|
'have_account' => 'Bereits registriert?',
|
||||||
|
'welcome' => 'Willkommen zurück,',
|
||||||
|
'your_goal' => 'Dein Ernährungsziel',
|
||||||
|
'saved_recipes' => 'Deine gespeicherten Favoriten',
|
||||||
|
'no_favorites' => "Du hast noch keine Rezepte gespeichert. Entdecke unsere Küche und klicke auf das Herz-Symbol!",
|
||||||
|
'logout' => 'Abmelden',
|
||||||
|
'err_pass_match' => 'Die Passwörter stimmen nicht überein.',
|
||||||
|
'err_fill_fields'=> 'Bitte fülle alle Felder aus.',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$t = $copy[$lang];
|
||||||
|
$pageTitle = $t['page_title'];
|
||||||
|
$description = $t['meta_desc'];
|
||||||
|
|
||||||
|
// Check PHP session state
|
||||||
|
$user = $_SESSION['fc_user'] ?? null;
|
||||||
|
|
||||||
|
$allRecipes = load_recipes();
|
||||||
|
$allRecipesLocal = localize_recipes($allRecipes, $lang);
|
||||||
|
|
||||||
|
// Formatter helper for JS usage
|
||||||
|
$recipesJson = json_encode(array_values(array_map(function($r) use ($lang) {
|
||||||
|
return [
|
||||||
|
'slug' => $r['slug'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'hero' => $r['hero'],
|
||||||
|
'difficulty' => $r['difficulty'],
|
||||||
|
'total_time' => (int)($r['total_time'] ?? 0),
|
||||||
|
'url' => '/recipe.php?slug=' . urlencode($r['slug']) . '&lang=' . $lang
|
||||||
|
];
|
||||||
|
}, $allRecipesLocal)));
|
||||||
|
|
||||||
|
include __DIR__ . '/partials/head.php';
|
||||||
|
include __DIR__ . '/partials/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Premium Authentication Page Styling ── */
|
||||||
|
.auth-page {
|
||||||
|
padding: clamp(6rem, 10vh, 10rem) var(--grid-margin) clamp(4rem, 8vh, 6rem);
|
||||||
|
min-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: clamp(2rem, 5vw, 3.5rem);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.4s var(--ease-out-expo), opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
border-bottom: 1px solid var(--stroke);
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -0.9rem;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--color-accent-gold);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 0.4s var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form.active {
|
||||||
|
display: flex;
|
||||||
|
animation: authFadeIn 0.5s var(--ease-out-expo) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes authFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field label {
|
||||||
|
font-family: var(--font-sans-clean);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field input, .auth-field select {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans-clean);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field input:focus, .auth-field select:focus {
|
||||||
|
border-color: var(--color-accent-gold);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn-row {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn-row button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-switch-text button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile & Dashboard Styling ── */
|
||||||
|
.profile-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 850px) {
|
||||||
|
.profile-shell {
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-sidebar {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
height: fit-content;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--color-accent-gold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
border: 1px solid rgba(195,166,119,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
border-top: 1px solid var(--stroke);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat-val {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-favorites-title {
|
||||||
|
font-family: var(--font-serif-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
border-bottom: 1px solid var(--stroke);
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px dashed var(--stroke);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert styling */
|
||||||
|
.auth-alert {
|
||||||
|
background: #fdf2f2;
|
||||||
|
border: 1px solid #fbd5d5;
|
||||||
|
color: #9b1c1c;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: none;
|
||||||
|
animation: authFadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .auth-alert {
|
||||||
|
background: #2b1515;
|
||||||
|
border-color: #5a1818;
|
||||||
|
color: #ff9b9b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reveal:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reveal:disabled .spinner {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reveal:disabled .btn-reveal-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<main class="auth-page">
|
||||||
|
<div class="auth-alert" id="authAlert"></div>
|
||||||
|
|
||||||
|
<!-- ── 1. UNAUTHENTICATED STATE (Login/Register Card) ── -->
|
||||||
|
<div class="auth-card" id="authCard" style="display: <?php echo $user ? 'none' : 'block'; ?>;">
|
||||||
|
<div class="auth-tabs">
|
||||||
|
<button class="auth-tab active" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
|
||||||
|
<button class="auth-tab" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form class="auth-form active" id="loginForm" onsubmit="handleLogin(event)">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="loginEmail"><?php echo $t['email']; ?></label>
|
||||||
|
<input type="email" id="loginEmail" placeholder="you@example.com" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="loginPassword"><?php echo $t['password']; ?></label>
|
||||||
|
<input type="password" id="loginPassword" placeholder="••••••••" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="auth-btn-row">
|
||||||
|
<button class="btn-reveal" type="submit">
|
||||||
|
<span class="btn-reveal-text"><?php echo $t['login']; ?></span>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="btn-reveal-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-switch-text">
|
||||||
|
<?php echo $t['no_account']; ?> <button type="button" onclick="switchTab('signup')"><?php echo $t['signup']; ?></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sign-up Form -->
|
||||||
|
<form class="auth-form" id="signupForm" onsubmit="handleSignup(event)">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="signupEmail"><?php echo $t['email']; ?></label>
|
||||||
|
<input type="email" id="signupEmail" placeholder="you@example.com" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="signupPassword"><?php echo $t['password']; ?></label>
|
||||||
|
<input type="password" id="signupPassword" placeholder="••••••••" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="signupConfirm"><?php echo $t['confirm_pass']; ?></label>
|
||||||
|
<input type="password" id="signupConfirm" placeholder="••••••••" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="signupGoal"><?php echo $t['goal']; ?></label>
|
||||||
|
<select id="signupGoal" required>
|
||||||
|
<option value="" disabled selected><?php echo $t['goal_placeholder']; ?></option>
|
||||||
|
<option value="weight_loss"><?php echo $t['goal_loose']; ?></option>
|
||||||
|
<option value="muscle_gain"><?php echo $t['goal_gain']; ?></option>
|
||||||
|
<option value="healthy"><?php echo $t['goal_healthy']; ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="auth-btn-row">
|
||||||
|
<button class="btn-reveal" type="submit">
|
||||||
|
<span class="btn-reveal-text"><?php echo $t['signup']; ?></span>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="btn-reveal-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-switch-text">
|
||||||
|
<?php echo $t['have_account']; ?> <button type="button" onclick="switchTab('login')"><?php echo $t['login']; ?></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 2. AUTHENTICATED STATE (Dashboard & Favorites) ── -->
|
||||||
|
<div class="profile-shell" id="profileShell" style="display: <?php echo $user ? 'grid' : 'none'; ?>;">
|
||||||
|
<aside class="profile-sidebar">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1.5rem;">
|
||||||
|
<div class="profile-avatar" id="avatarChar">
|
||||||
|
<?php echo $user ? strtoupper(substr($user['email'], 0, 1)) : ''; ?>
|
||||||
|
</div>
|
||||||
|
<div class="profile-meta">
|
||||||
|
<p><?php echo $t['welcome']; ?></p>
|
||||||
|
<h2 id="profileEmailVal"><?php echo $user ? e($user['email']) : ''; ?></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-details">
|
||||||
|
<div class="profile-stat-box">
|
||||||
|
<span class="profile-stat-label"><?php echo $t['your_goal']; ?></span>
|
||||||
|
<span class="profile-stat-val" id="profileGoalVal">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-reveal" onclick="handleLogout()" style="margin-top: 1rem; border-color: rgba(0,0,0,0.15);">
|
||||||
|
<span class="btn-reveal-text"><?php echo $t['logout']; ?></span>
|
||||||
|
<span class="btn-reveal-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="profile-main">
|
||||||
|
<h3 class="profile-favorites-title"><?php echo $t['saved_recipes']; ?></h3>
|
||||||
|
|
||||||
|
<div id="favoritesGrid" class="favorites-grid">
|
||||||
|
<!-- JS inserts favorited recipe cards here -->
|
||||||
|
<div class="spinner" style="display: block; margin: 4rem auto; border-top-color: var(--color-accent-gold);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="favoritesEmpty" class="favorites-empty" style="display: none;">
|
||||||
|
<p><?php echo $t['no_favorites']; ?></p>
|
||||||
|
<a class="btn-reveal" href="/index.php?lang=<?php echo $lang; ?>#recipes-start" style="margin-top: 1.5rem;">
|
||||||
|
<span class="btn-reveal-text"><?php echo $lang === 'de' ? 'Entdecken' : 'Explore Recipes'; ?></span>
|
||||||
|
<span class="btn-reveal-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Expose recipe bank to JavaScript
|
||||||
|
const recipeBank = <?php echo $recipesJson; ?>;
|
||||||
|
const langText = <?php echo json_encode($t); ?>;
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
const tabs = document.querySelectorAll('.auth-tab');
|
||||||
|
const forms = document.querySelectorAll('.auth-form');
|
||||||
|
const alertEl = document.getElementById('authAlert');
|
||||||
|
alertEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (tab === 'login') {
|
||||||
|
tabs[0].classList.add('active');
|
||||||
|
tabs[1].classList.remove('active');
|
||||||
|
forms[0].classList.add('active');
|
||||||
|
forms[1].classList.remove('active');
|
||||||
|
} else {
|
||||||
|
tabs[0].classList.remove('active');
|
||||||
|
tabs[1].classList.add('active');
|
||||||
|
forms[0].classList.remove('active');
|
||||||
|
forms[1].classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(message) {
|
||||||
|
const alertEl = document.getElementById('authAlert');
|
||||||
|
alertEl.textContent = message;
|
||||||
|
alertEl.style.display = 'block';
|
||||||
|
// Scroll to alert smoothly
|
||||||
|
window.scrollTo({ top: alertEl.offsetTop - 120, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('loginEmail').value;
|
||||||
|
const pass = document.getElementById('loginPassword').value;
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
const alertEl = document.getElementById('authAlert');
|
||||||
|
|
||||||
|
alertEl.style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
window.auth.signInWithEmailAndPassword(email, pass)
|
||||||
|
.then(userCredential => {
|
||||||
|
// Sync happens automatically via onAuthStateChanged observer in head.php!
|
||||||
|
// The observer will reload the page when it receives the new state
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
btn.disabled = false;
|
||||||
|
let errMsg = error.message;
|
||||||
|
if (error.code === 'auth/wrong-password' || error.code === 'auth/user-not-found') {
|
||||||
|
errMsg = "Invalid email or password.";
|
||||||
|
}
|
||||||
|
showAlert(errMsg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSignup(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('signupEmail').value;
|
||||||
|
const pass = document.getElementById('signupPassword').value;
|
||||||
|
const confirm = document.getElementById('signupConfirm').value;
|
||||||
|
const goal = document.getElementById('signupGoal').value;
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
const alertEl = document.getElementById('authAlert');
|
||||||
|
|
||||||
|
alertEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (pass !== confirm) {
|
||||||
|
showAlert(langText.err_pass_match);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
window.auth.createUserWithEmailAndPassword(email, pass)
|
||||||
|
.then(userCredential => {
|
||||||
|
const user = userCredential.user;
|
||||||
|
// Save profile details to Firestore
|
||||||
|
return window.db.collection('users').doc(user.uid).set({
|
||||||
|
email: email,
|
||||||
|
goal: goal,
|
||||||
|
createdAt: firebase.firestore.FieldValue.serverTimestamp()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Sync is handled by observer in head.php!
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
btn.disabled = false;
|
||||||
|
showAlert(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
window.auth.signOut()
|
||||||
|
.then(() => {
|
||||||
|
// Sync observer takes care of PHP session termination and reloads page
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profile and Favorites dashboard renderer ──
|
||||||
|
function initDashboard(user) {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const goalVal = document.getElementById('profileGoalVal');
|
||||||
|
const grid = document.getElementById('favoritesGrid');
|
||||||
|
const emptyState = document.getElementById('favoritesEmpty');
|
||||||
|
|
||||||
|
// Fetch user profile from Firestore
|
||||||
|
window.db.collection('users').doc(user.uid).get()
|
||||||
|
.then(doc => {
|
||||||
|
if (doc.exists) {
|
||||||
|
const data = doc.data();
|
||||||
|
let goalString = '-';
|
||||||
|
if (data.goal === 'weight_loss') goalString = langText.goal_loose;
|
||||||
|
else if (data.goal === 'muscle_gain') goalString = langText.goal_gain;
|
||||||
|
else if (data.goal === 'healthy') goalString = langText.goal_healthy;
|
||||||
|
|
||||||
|
goalVal.textContent = goalString;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Error loading profile: ", err));
|
||||||
|
|
||||||
|
// Load Saved Favorites
|
||||||
|
window.db.collection('user_favorites').where('userId', '==', user.uid).get()
|
||||||
|
.then(snapshot => {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
if (snapshot.empty) {
|
||||||
|
grid.style.display = 'none';
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
grid.style.display = 'grid';
|
||||||
|
|
||||||
|
snapshot.forEach(doc => {
|
||||||
|
const fav = doc.data();
|
||||||
|
const recipe = recipeBank.find(r => r.slug === fav.recipeSlug);
|
||||||
|
|
||||||
|
if (recipe) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'card card--bare reveal-target revealed';
|
||||||
|
card.innerHTML = `
|
||||||
|
<a href="${recipe.url}" class="card__image">
|
||||||
|
<img src="${recipe.hero}" alt="${recipe.title}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<div class="card__body">
|
||||||
|
<h4><a href="${recipe.url}">${recipe.title}</a></h4>
|
||||||
|
<div class="meta">
|
||||||
|
<span>⏱ ${recipe.total_time} min</span>
|
||||||
|
<span>🙂 ${recipe.difficulty}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If none of the favorite slugs matched recipes in current language:
|
||||||
|
if (grid.children.length === 0) {
|
||||||
|
grid.style.display = 'none';
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error fetching favorites: ", err);
|
||||||
|
grid.innerHTML = '<p>Error loading favorites. Please try again.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe auth state for dashboard renderer
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.auth.onAuthStateChanged(user => {
|
||||||
|
const authCard = document.getElementById('authCard');
|
||||||
|
const profileShell = document.getElementById('profileShell');
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('profileEmailVal').textContent = user.email;
|
||||||
|
document.getElementById('avatarChar').textContent = user.email.substr(0,1).toUpperCase();
|
||||||
|
authCard.style.display = 'none';
|
||||||
|
profileShell.style.display = 'grid';
|
||||||
|
initDashboard(user);
|
||||||
|
} else {
|
||||||
|
authCard.style.display = 'block';
|
||||||
|
profileShell.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/partials/footer.php'; ?>
|
||||||
@@ -5,6 +5,23 @@
|
|||||||
$contactEmail = $settings['imprint'][$langCode]['email'] ?? 'contact@flixcooks.at';
|
$contactEmail = $settings['imprint'][$langCode]['email'] ?? 'contact@flixcooks.at';
|
||||||
?>
|
?>
|
||||||
<footer class="site-footer" role="contentinfo">
|
<footer class="site-footer" role="contentinfo">
|
||||||
|
<section class="footer-newsletter">
|
||||||
|
<div class="newsletter-content">
|
||||||
|
<h3><?php echo $langCode === 'de' ? 'Bleib inspiriert' : 'Stay Inspired'; ?></h3>
|
||||||
|
<p><?php echo $langCode === 'de' ? 'Melde dich für unseren Newsletter an und erhalte saisonale Rezepte direkt in dein Postfach.' : 'Sign up for our newsletter and get seasonal recipes delivered straight to your inbox.'; ?></p>
|
||||||
|
</div>
|
||||||
|
<form class="newsletter-form" id="newsletterForm" onsubmit="handleSubscribe(event)">
|
||||||
|
<div class="newsletter-input-group">
|
||||||
|
<input type="email" id="newsletterEmail" placeholder="<?php echo $langCode === 'de' ? 'Deine E-Mail-Adresse...' : 'Your email address...'; ?>" required>
|
||||||
|
<button type="submit" aria-label="Subscribe">
|
||||||
|
<span class="btn-text">→</span>
|
||||||
|
<span class="btn-success-check">✓</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="newsletter-feedback" id="newsletterFeedback"></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="footer-meta">
|
<div class="footer-meta">
|
||||||
<p><?php echo $langCode === 'de' ? 'Designed & entwickelt von Felix Brockers' : 'Designed & built by Felix Brockers'; ?></p>
|
<p><?php echo $langCode === 'de' ? 'Designed & entwickelt von Felix Brockers' : 'Designed & built by Felix Brockers'; ?></p>
|
||||||
<a class="contact-link underline-link" href="mailto:<?php echo e($contactEmail); ?>">
|
<a class="contact-link underline-link" href="mailto:<?php echo e($contactEmail); ?>">
|
||||||
@@ -44,6 +61,183 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// ── Newsletter subscription logic ──────────────────────────
|
||||||
|
function handleSubscribe(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var emailInput = document.getElementById('newsletterEmail');
|
||||||
|
var feedback = document.getElementById('newsletterFeedback');
|
||||||
|
var form = document.getElementById('newsletterForm');
|
||||||
|
var btn = form.querySelector('button');
|
||||||
|
var email = emailInput.value.trim();
|
||||||
|
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
feedback.className = 'newsletter-feedback';
|
||||||
|
feedback.textContent = '';
|
||||||
|
|
||||||
|
window.db.collection('subscribers').doc(email.toLowerCase()).set({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
subscribedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
form.classList.add('success');
|
||||||
|
emailInput.disabled = true;
|
||||||
|
feedback.className = 'newsletter-feedback success';
|
||||||
|
feedback.textContent = '<?php echo $langCode === "de" ? "Erfolgreich abonniert!" : "Successfully subscribed!"; ?>';
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
btn.disabled = false;
|
||||||
|
feedback.className = 'newsletter-feedback error';
|
||||||
|
feedback.textContent = '<?php echo $langCode === "de" ? "Fehler: " : "Error: "; ?>' + error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Guest Modal Logic ──────────────────────────────────────
|
||||||
|
function openGuestModal() {
|
||||||
|
var modal = document.getElementById('guestFavModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('open');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGuestModal() {
|
||||||
|
var modal = document.getElementById('guestFavModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('open');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close guest modal if clicking backdrop
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var modal = document.getElementById('guestFavModal');
|
||||||
|
if (modal && e.target === modal) {
|
||||||
|
closeGuestModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Favorites Toggle & Management ───────────────────────────
|
||||||
|
var currentUser = null;
|
||||||
|
var userFavSlugs = new Set();
|
||||||
|
|
||||||
|
function syncFavorites(uid) {
|
||||||
|
window.db.collection('user_favorites').where('userId', '==', uid).get()
|
||||||
|
.then(function(snapshot) {
|
||||||
|
userFavSlugs.clear();
|
||||||
|
snapshot.forEach(function(doc) {
|
||||||
|
userFavSlugs.add(doc.data().recipeSlug);
|
||||||
|
});
|
||||||
|
updateHeartStates();
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error("Error syncing favorites: ", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFavorites() {
|
||||||
|
userFavSlugs.clear();
|
||||||
|
updateHeartStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeartStates() {
|
||||||
|
document.querySelectorAll('.btn-favorite').forEach(function(btn) {
|
||||||
|
var slug = btn.getAttribute('data-slug');
|
||||||
|
if (userFavSlugs.has(slug)) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(slug, btn) {
|
||||||
|
if (!currentUser) {
|
||||||
|
openGuestModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var docId = currentUser.uid + '_' + slug;
|
||||||
|
var docRef = window.db.collection('user_favorites').doc(docId);
|
||||||
|
|
||||||
|
if (userFavSlugs.has(slug)) {
|
||||||
|
// Optimistic UI update
|
||||||
|
userFavSlugs.delete(slug);
|
||||||
|
btn.classList.remove('active');
|
||||||
|
|
||||||
|
docRef.delete()
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error("Error removing favorite: ", err);
|
||||||
|
// Rollback
|
||||||
|
userFavSlugs.add(slug);
|
||||||
|
btn.classList.add('active');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Optimistic UI update
|
||||||
|
userFavSlugs.add(slug);
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
docRef.set({
|
||||||
|
userId: currentUser.uid,
|
||||||
|
recipeSlug: slug,
|
||||||
|
createdAt: firebase.firestore.FieldValue.serverTimestamp()
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error("Error adding favorite: ", err);
|
||||||
|
// Rollback
|
||||||
|
userFavSlugs.delete(slug);
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook up event listeners to all favorite buttons dynamically
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.btn-favorite');
|
||||||
|
if (btn) {
|
||||||
|
var slug = btn.getAttribute('data-slug');
|
||||||
|
if (slug) {
|
||||||
|
toggleFavorite(slug, btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup Auth state observer for favorites
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (window.auth) {
|
||||||
|
window.auth.onAuthStateChanged(function(user) {
|
||||||
|
currentUser = user;
|
||||||
|
if (user) {
|
||||||
|
syncFavorites(user.uid);
|
||||||
|
} else {
|
||||||
|
clearFavorites();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Guest Favoriting Prompt Modal -->
|
||||||
|
<div class="guest-modal-backdrop" id="guestFavModal" aria-hidden="true" role="dialog">
|
||||||
|
<div class="guest-modal-card">
|
||||||
|
<button class="guest-modal-close" onclick="closeGuestModal()" aria-label="Close modal">×</button>
|
||||||
|
<div class="guest-modal-icon">❤️</div>
|
||||||
|
<h3><?php echo $langCode === 'de' ? 'Lieblingsrezept speichern' : 'Save your favorite recipe'; ?></h3>
|
||||||
|
<p><?php echo $langCode === 'de' ? 'Erstelle ein kostenloses Konto oder melde dich an, um Rezepte in deinen Favoriten zu speichern und deine Ernährungsziele zu verwalten.' : 'Create a free account or login to save recipes to your favorites and manage your dietary goals.'; ?></p>
|
||||||
|
<div class="guest-modal-actions">
|
||||||
|
<a class="btn-reveal" href="/login.php<?php echo $langSuffix; ?>">
|
||||||
|
<span class="btn-reveal-text"><?php echo $langCode === 'de' ? 'Jetzt Anmelden' : 'Login / Register'; ?></span>
|
||||||
|
<span class="btn-reveal-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
<button class="underline-link" onclick="closeGuestModal()"><?php echo $langCode === 'de' ? 'Später' : 'Maybe later'; ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,6 +20,49 @@ $description = $description ?? 'Seasonal recipes, tested tips, and approachable
|
|||||||
<!-- Main stylesheet -->
|
<!-- Main stylesheet -->
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
|
||||||
|
<!-- Firebase v10 compat SDKs -->
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-app-compat.js"></script>
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-auth-compat.js"></script>
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/10.8.0/firebase-firestore-compat.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var config = <?php echo json_encode(get_firebase_config()); ?>;
|
||||||
|
firebase.initializeApp(config);
|
||||||
|
window.auth = firebase.auth();
|
||||||
|
window.db = firebase.firestore();
|
||||||
|
|
||||||
|
// Auto-sync PHP Session with Firebase Auth State
|
||||||
|
window.auth.onAuthStateChanged(function(user) {
|
||||||
|
var phpUser = <?php echo isset($_SESSION['fc_user']) ? json_encode($_SESSION['fc_user']) : 'null'; ?>;
|
||||||
|
if (user) {
|
||||||
|
if (!phpUser || phpUser.uid !== user.uid) {
|
||||||
|
user.getIdToken().then(function(token) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/session.php', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
xhr.send('action=login&uid=' + encodeURIComponent(user.uid) + '&email=' + encodeURIComponent(user.email) + '&token=' + encodeURIComponent(token));
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
if (!phpUser) { window.location.reload(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (phpUser) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/session.php', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||||
|
xhr.send('action=logout');
|
||||||
|
xhr.onload = function() {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- GSAP + ScrollTrigger (CDN) -->
|
<!-- GSAP + ScrollTrigger (CDN) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ $langParamAmp = isset($lang) && $lang === 'de' ? '&lang=de' : '';
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/login.php<?php echo $langParam; ?>" class="slide-hover-link" <?php echo $currentPage === 'login.php' ? 'aria-current="page"' : ''; ?>>
|
||||||
|
<span class="slide-hover-wrapper">
|
||||||
|
<span class="slide-text-main"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span>
|
||||||
|
<span class="slide-text-clone" aria-hidden="true"><?php echo isset($_SESSION['fc_user']) ? (isset($lang) && $lang === 'de' ? 'Profil' : 'Profile') : (isset($lang) && $lang === 'de' ? 'Anmelden' : 'Login'); ?></span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
+63
-2
@@ -7,6 +7,11 @@ $copy = [
|
|||||||
'not_found' => 'Recipe not found',
|
'not_found' => 'Recipe not found',
|
||||||
'not_found_sub' => 'Try searching for something else.',
|
'not_found_sub' => 'Try searching for something else.',
|
||||||
'back_home' => '← Back to all recipes',
|
'back_home' => '← Back to all recipes',
|
||||||
|
'nutrition' => 'Nutrition',
|
||||||
|
'calories' => 'Calories',
|
||||||
|
'protein' => 'Protein',
|
||||||
|
'carbs' => 'Carbs',
|
||||||
|
'fat' => 'Fat',
|
||||||
'ingredients' => 'Ingredients',
|
'ingredients' => 'Ingredients',
|
||||||
'utensils' => 'Utensils',
|
'utensils' => 'Utensils',
|
||||||
'steps' => 'Steps',
|
'steps' => 'Steps',
|
||||||
@@ -22,6 +27,11 @@ $copy = [
|
|||||||
'not_found' => 'Rezept nicht gefunden',
|
'not_found' => 'Rezept nicht gefunden',
|
||||||
'not_found_sub' => 'Versuche eine andere Suche.',
|
'not_found_sub' => 'Versuche eine andere Suche.',
|
||||||
'back_home' => '← Zurück zu allen Rezepten',
|
'back_home' => '← Zurück zu allen Rezepten',
|
||||||
|
'nutrition' => 'Nährwerte',
|
||||||
|
'calories' => 'Kalorien',
|
||||||
|
'protein' => 'Protein',
|
||||||
|
'carbs' => 'Kohlenhydrate',
|
||||||
|
'fat' => 'Fett',
|
||||||
'ingredients' => 'Zutaten',
|
'ingredients' => 'Zutaten',
|
||||||
'utensils' => 'Küchenwerkzeug',
|
'utensils' => 'Küchenwerkzeug',
|
||||||
'steps' => 'Schritte',
|
'steps' => 'Schritte',
|
||||||
@@ -78,7 +88,14 @@ include __DIR__ . '/partials/header.php';
|
|||||||
<?php if (!empty($recipe['category'])): ?>
|
<?php if (!empty($recipe['category'])): ?>
|
||||||
<p class="pill pill--gold"><?php echo e($recipe['category']); ?></p>
|
<p class="pill pill--gold"><?php echo e($recipe['category']); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<h1><?php echo e($recipe['title']); ?></h1>
|
<div class="recipe__title-row">
|
||||||
|
<h1><?php echo e($recipe['title']); ?></h1>
|
||||||
|
<button class="btn-favorite btn-favorite--large" data-slug="<?php echo e($recipe['slug']); ?>" aria-label="Save to favorites">
|
||||||
|
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="lede"><?php echo e($recipe['description']); ?></p>
|
<p class="lede"><?php echo e($recipe['description']); ?></p>
|
||||||
<div class="meta meta--row">
|
<div class="meta meta--row">
|
||||||
<span><?php echo e($t['prep']); ?>: <?php echo format_minutes((int)$recipe['prep_time']); ?></span>
|
<span><?php echo e($t['prep']); ?>: <?php echo format_minutes((int)$recipe['prep_time']); ?></span>
|
||||||
@@ -95,6 +112,35 @@ include __DIR__ . '/partials/header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="recipe__body">
|
<div class="recipe__body">
|
||||||
|
|
||||||
|
<?php if (isset($recipe['nutrition']) && ($recipe['nutrition']['calories'] > 0 || $recipe['nutrition']['protein'] > 0 || $recipe['nutrition']['carbs'] > 0 || $recipe['nutrition']['fat'] > 0)): ?>
|
||||||
|
<section class="panel reveal-target" id="nutrition-panel">
|
||||||
|
<h2><?php echo e($t['nutrition']); ?></h2>
|
||||||
|
<div class="g-row nutrition-widget">
|
||||||
|
<?php
|
||||||
|
$nutri = $recipe['nutrition'];
|
||||||
|
$macros = [
|
||||||
|
['label' => $t['calories'], 'value' => $nutri['calories'], 'unit' => 'kcal', 'pct' => min(100, round(($nutri['calories']/2000)*100))],
|
||||||
|
['label' => $t['protein'], 'value' => $nutri['protein'], 'unit' => 'g', 'pct' => min(100, round(($nutri['protein']/50)*100))],
|
||||||
|
['label' => $t['carbs'], 'value' => $nutri['carbs'], 'unit' => 'g', 'pct' => min(100, round(($nutri['carbs']/260)*100))],
|
||||||
|
['label' => $t['fat'], 'value' => $nutri['fat'], 'unit' => 'g', 'pct' => min(100, round(($nutri['fat']/70)*100))]
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<?php foreach ($macros as $m): ?>
|
||||||
|
<div class="g-col nutrition-item">
|
||||||
|
<div class="nutrition-item__header">
|
||||||
|
<span class="nutrition-label"><?php echo e($m['label']); ?></span>
|
||||||
|
<span class="nutrition-value"><?php echo e($m['value']); ?><?php echo $m['unit']; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="nutrition-bar">
|
||||||
|
<div class="nutrition-fill" data-width="<?php echo $m['pct']; ?>%" style="width: 0;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2><?php echo e($t['ingredients']); ?></h2>
|
<h2><?php echo e($t['ingredients']); ?></h2>
|
||||||
<ul class="ingredients">
|
<ul class="ingredients">
|
||||||
@@ -136,6 +182,11 @@ include __DIR__ . '/partials/header.php';
|
|||||||
<article class="card card--bare reveal-target">
|
<article class="card card--bare reveal-target">
|
||||||
<a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>" class="card__image">
|
<a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>" class="card__image">
|
||||||
<img src="<?php echo e($other['hero']); ?>" alt="<?php echo e($other['title']); ?>" loading="lazy">
|
<img src="<?php echo e($other['hero']); ?>" alt="<?php echo e($other['title']); ?>" loading="lazy">
|
||||||
|
<button class="btn-favorite" data-slug="<?php echo e($other['slug']); ?>" aria-label="Save to favorites" onclick="event.preventDefault(); event.stopPropagation();">
|
||||||
|
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<h4><a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>"><?php echo e($other['title']); ?></a></h4>
|
<h4><a href="/recipe.php?<?php echo http_build_query(['slug' => $other['slug'], 'lang' => $lang]); ?>"><?php echo e($other['title']); ?></a></h4>
|
||||||
@@ -158,7 +209,17 @@ include __DIR__ . '/partials/header.php';
|
|||||||
if ('IntersectionObserver' in window) {
|
if ('IntersectionObserver' in window) {
|
||||||
var obs = new IntersectionObserver(function(entries) {
|
var obs = new IntersectionObserver(function(entries) {
|
||||||
entries.forEach(function(e) {
|
entries.forEach(function(e) {
|
||||||
if (e.isIntersecting) { e.target.classList.add('revealed'); obs.unobserve(e.target); }
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add('revealed');
|
||||||
|
obs.unobserve(e.target);
|
||||||
|
|
||||||
|
// Trigger nutrition bar animations if this is the nutrition panel
|
||||||
|
if (e.target.id === 'nutrition-panel') {
|
||||||
|
e.target.querySelectorAll('.nutrition-fill').forEach(function(fill) {
|
||||||
|
fill.style.width = fill.getAttribute('data-width');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, { threshold: 0.08 });
|
}, { threshold: 0.08 });
|
||||||
document.querySelectorAll('.reveal-target').forEach(function(el) { obs.observe(el); });
|
document.querySelectorAll('.reveal-target').forEach(function(el) { obs.observe(el); });
|
||||||
|
|||||||
Reference in New Issue
Block a user