Compare commits

...

76 Commits

Author SHA1 Message Date
boban 3cb0c729b2 fix(admin): Webhook-URL auf Docker-internen Hostnamen umstellen
App-Container kann Host-IP nicht erreichen; deployer-Hostname funktioniert
da beide im selben Docker-Netzwerk (nexxo) laufen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 02:56:17 +02:00
boban 22a7328a62 fix(deployer): sed statt envsubst für Secret-Substitution
envsubst ist im almir/webhook-Image nicht vorhanden, sed ist immer verfügbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 02:34:36 +02:00
boban 2dd3903a4e feat(api): API-Token-Verwaltung & GET /events/{id}
Pro-User können in den Einstellungen (Tab „API") benannte Tokens erstellen
und widerrufen. Feature api_access im FeatureSeeder als Pro-Feature ergänzt.
Neuer GET /events/{id} Endpunkt im EventController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 02:13:15 +02:00
boban 6b92544611 fix(deployer): env_file statt leerem env-Passthrough für Webhook-Secret
${DEPLOY_WEBHOOK_SECRET} war leer (keine Root-.env), deployer-Container
hat secret nicht erhalten → HTTP 403. Nun direkt aus src/.env gelesen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:38:52 +02:00
boban db0c092a06 feat(admin): Webhook-Sektion auf Versionen-Seite
Zeigt letzten Git-Commit (Hash, Message, Datum), letzten Deploy-Zeitstempel
und ermöglicht manuelles Auslösen des Deploy-Webhooks per Button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:34:40 +02:00
boban db82718ea0 fix: Webhook auf Port 9000 + /deploy URL-Prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:27:51 +02:00
boban 3c0676acd9 feat: Gitea Webhook Auto-Deploy Service für Staging
deployer-Container (almir/webhook) lauscht auf Port 9001.
Bei Push → git pull, npm build, migrate, cache clear, workers restart.
Secret via DEPLOY_WEBHOOK_SECRET in .env konfigurieren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:23:30 +02:00
boban 2cf56a3caf fix: Kalender Hover-Highlight Style-Binding korrigiert
:style als Objekt statt String damit height/background erhalten bleiben,
x-show &&-Operator durch ternary ersetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:56:20 +02:00
boban 3aa9eb1633 feat: Kalender Hover-Highlight (Web) + manuelle Event-Erstellung (App)
Web: Hover über Zeitslots zeigt leichten Indigo-Highlight an
App: + Button im Kalender-Header öffnet neuen Termin Modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 00:53:38 +02:00
boban fa52148021 refactor: Aria-Charakter — herzlich, humorvoll, locker statt steif/kühl
Persönlichkeit komplett überarbeitet:
- Warmherzig und freundlich statt souverän-distanziert
- Humor der sich natürlich ergibt (Ironie, mitlachen, sich selbst nicht zu ernst nehmen)
- Stimmung des Users aktiv aufnehmen und spiegeln
- Locker und direkt statt förmlich
- Konkrete Beispiele wie Aria klingt vs. wie sie NICHT klingt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:42:09 +02:00
boban 25519dcdb6 refactor: Aria Charakter als echter Jarvis, Timezone-Handling im Backend
AgentAIService — Prompt:
- Identität komplett neu: Aria ist eine Präsenz, nicht ein Assistent-Bot
- Konversation massiv ausgebaut: Jarvis-Stil mit echter Persönlichkeit,
  Empathie, Meinung, trockenem Humor, echter Reaktion — kein performter Charme
- Zeitzone-Vereinfachung: Aria gibt Wiener Ortszeit aus (YYYY-MM-DD HH:mm),
  das Backend übernimmt UTC-Konvertierung — keine Carbon-UTC-Berechnungen mehr
- Reminder-Beispiele vereinfacht und korrekt (Wien-Zeit statt UTC)
- Relative Zeiten als Wien-Zeit-Beispiele statt UTC

AgentActionService — Backend:
- Task reminder_at + due_at jetzt mit User-Timezone geparst (war vorher UTC-Annahme)
- Konsistent mit Event-Handling (datetime wurde schon mit $tz geparst)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:35:32 +02:00
boban 5e89c83b46 refactor: Aria-Prompt vollständig überarbeitet — Jarvis-Charakter, stabiles JSON, Lösch-Aktionen
Hauptänderungen:
- Widerspruch entfernt: "Gespräch → KEIN JSON" vs. chat-JSON war direkter Konflikt
- Persönlichkeit: "gute Freundin" → klarer Jarvis-Stil (ruhig, präzise, souverän, trocken)
- TONANPASSUNG neu: Stimmung des Users wird gespiegelt
- INTELLIGENZVERHALTEN neu: nur fragen wenn wirklich nötig
- Abschlussfrage optional statt nach jeder Antwort erzwungen
- Lösch-Aktionen ergänzt: event_delete, task_delete, note_delete
- note_update: title-Feld ergänzt
- Zeitberechnung: mehr konkrete UTC-Beispiele
- Gesamtstruktur klarer gegliedert (12 logische Blöcke)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:17:02 +02:00
boban 00961be675 feat: 3 neue E-Mail Templates + Logo im Layout
Neue Templates im einheitlichen Stil:
- auth/welcome      → nach erster E-Mail-Verifizierung, Feature-Übersicht + CTA
- subscription/confirmed → Abo-Bestätigung mit Plan/Betrag/Verlängerungsdatum
- subscription/cancelled → Kündigung mit Zugang-bis-Datum + Reaktivierungs-CTA

Layout: Textwordmark durch logo-text.png ersetzt (height:32px)
Preview: alle 3 Templates in /admin/mail-preview ergänzt (11 Templates gesamt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 20:32:14 +02:00
boban cd2e466f09 feat: E-Mail Templates neu gestaltet + Mail-Preview unter /admin/mail-preview
Einheitliches Design für alle Templates:
- Neues Layout: weißes Card, 3px Indigo-Gradient-Akzentbalken, aziros-Wordmark
- Icon-Badge pro Template (52px, border-radius:14px, thematische Farbe)
- Konsistente Typografie, Info-Boxen, Button-Stil
- reset-password, smtp-test, aria-composed auf @extends migriert

Preview-Route: /admin/mail-preview (Index) + /admin/mail-preview/{template}
→ Alle 8 Templates mit Fake-Daten in einer skalierten Vorschau-Grid-Ansicht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 20:20:29 +02:00
boban b8afe0cb70 fix: Checkout-Button sofort via Alpine $wire freischalten, Features-Sort stabilisieren
- wire:model.live + :disabled="!$wire.rightAcknowledged || !$wire.waiverConfirmed"
  → Button reagiert clientseitig ohne Server-Roundtrip
- orderBy('sort')->orderBy('id') → stabile Feature-Reihenfolge beim Billing-Wechsel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:51:46 +02:00
boban f76ea32076 fix: SMTP-Auth via info@nimu.li, FROM-Adresse je Template auf aziros.com setzen
Die aziros.com-Postfächer (hello/noreply) können sich nicht direkt am SMTP
authentifizieren. Lösung: smtp-Mailer (info@nimu.li) für Auth verwenden,
FROM-Header per ->from() auf die korrekte aziros.com-Adresse setzen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:40:49 +02:00
boban a023ed19f6 fix: Verifizierungs-Mail via hello@aziros.com statt Fallback-Mailer senden
ProcessMailQueue verwendete Mail::html() ohne Mailer-Angabe, was den Default-
Mailer (smtp = info@nimu.li) nutzte. Jetzt: auth.* → hello-Mailer, Rest → system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:34:11 +02:00
boban d880ee4d7d fix: Pro-Mailer (system/reminder/aria/hello) verwenden eigene SMTP-Credentials
Registrierungs-E-Mails wurden bisher über info@nimu.li gesendet, weil alle
benannten Mailer auf MAIL_USERNAME/MAIL_PASSWORD zurückfielen. Jetzt nutzt
jeder Mailer seine eigenen MAIL_*_USERNAME und MAIL_*_PASSWORD aus .env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:25:04 +02:00
boban b863b67979 feat: Widerrufsrecht-Bestätigung beim Upgrade (Free → Pro)
- Migration: withdrawal_waivers (user, plan, billing, amount, IP,
  user_agent, confirmed_at, pdf_path für spätere PDF-Generierung)
- Model: WithdrawalWaiver mit User/Plan-Relation
- Checkout/Index: rightAcknowledged + waiverConfirmed Properties;
  Validierung vor Checkout; Waiver-Record wird vor Zahlung gespeichert
- View: Amber-Box mit Hinweistext + 2 Checkboxen; CTA-Button disabled
  solange nicht beide bestätigt; nur bei paid Plänen sichtbar
- Übersetzungen: waiver_info, waiver_right_acknowledged,
  waiver_confirmed, waiver_required (DE + EN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 19:05:41 +02:00
boban 2c81e2533d fix: Überfällig-Badge bei offenen Aufgaben im Dashboard mit Text
Statt nur Ausrufezeichen-Icon nun ein rotes Badge mit Label
"überfällig / overdue" + Datum — bestehender Übersetzungsschlüssel
dashboard.overdue (de/en) wird verwendet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:40:01 +02:00
boban 8c69d3b4e6 feat: Aria-Prompt um Jarvis-Persönlichkeit, Humor und Konversation erweitert
Neue Blöcke: HUMOR & EMOTIONEN, KONVERSATION. PERSÖNLICHKEIT um
Jarvis-Charakter (trockener Humor, Selbstironie, eigene Meinung) ergänzt.
Bestehende Regeln unverändert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:17:33 +02:00
boban 552998f06a feat: Übersetzungsschlüssel tasks.reminder_custom hinzugefügt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:09:01 +02:00
boban 3a9534f699 fix: Push-Body zeigt Uhrzeit des Events statt abstrakten Countdown
Statt "In 15 Stunde(n)" steht nun "Heute um 16:00 Uhr" — eindeutiger
Bezug zum Event. Bei < 60 min bleibt der Countdown plus die Uhrzeit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:02:40 +02:00
boban 772f11b517 fix: Push-Notifications und Reminder-Zeitberechnung
- PushService: Http::asJson() statt withHeaders; EXPO_TOKEN als Bearer-Auth
- services.php: expo.token aus ENV eingetragen
- ScheduleEventReminders: UTC-Reminder-Zeit erst in Lokalzeit umrechnen,
  dann mit lokalem Event-Datum kombinieren → verhindert +24h-Versatz bei
  Mitternacht-Übergängen (z.B. 22:20 UTC = 00:20 Wien)
- Event-Abfrage auf starts_at > now - 1 Tag erweitert, damit auch bereits
  gestartete Events noch Reminder erhalten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 00:56:38 +02:00
boban 57b55503fd fix: time_of_day/day_before reminder times parsed as UTC in calculateSendTime
Also add 'specific' datetime reminder type support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:40:25 +02:00
boban 2acc49ea66 fix: reminder validation + remove dead code 2026-04-19 23:22:01 +02:00
boban 439754b9ba fix: time_of_day reminder time stored as UTC, displayed as local
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:52:12 +02:00
boban ee795237db fix: reminder validation + remove dead reminder_at in EventPlannerService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:39:52 +02:00
boban b88093ed46 Fix: 0 credits for farewell chat messages
Abschlussnachrichten (danke, ok, tschüss, passt, ...) kosten 0 statt 5
Credits — keine Leistung erbracht, keine Verrechnung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:30:10 +02:00
boban 89cb058e83 Fix: parseJson chat responses, force override for conflicts
- parseJson: plain text (kein { oder [) → type=chat statt type=unknown
  Behebt WARNING für normale Chat-Antworten wie "Alles klar! [END]"
- AgentAIService Prompt: Regel 12 — Konflikt-Handling mit force:true erklärt
  AI fragt nach Überschneidung und sendet force:true wenn User bestätigt
- AgentActionService: Duplikat-Check bei force=true überspringen
  damit erzwungene Events auch bei gleichem Titel+Datum gespeichert werden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:22:52 +02:00
boban 7d50647f39 Fix: strip markdown backticks from OpenAI JSON response
Vorher: preg_match mit Capture-Group schlug fehl wenn ``` am Ende fehlt.
Nachher: zwei preg_replace entfernen öffnendes ```json und schließendes ```
unabhängig voneinander — robuster gegen unvollständige Codeblöcke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:09:44 +02:00
boban 9beb8c15a3 Fix: unknown type logging, event_update fallback to create, multi-event example
- AgentAIService: Raw response + type=unknown immer loggen (Debug)
- AgentActionService: event_update ohne Kandidaten + Zeitangabe → neu erstellen
  statt mit 'failed' abbrechen
- AgentAIService Prompt: Pflicht-Beispiel Reifenwechsel+Volleyball als
  Multi-Event mit reminder_at, explizit FALSCH: task anlegen markiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:04:02 +02:00
boban bcc1c0aac2 Fix: event vs task distinction, event reminder support, correct credit calculation
- AgentChatController: _multi hatte kein type-Key → wurde als 'chat' gewertet
  → nur 5 Credits statt Action-Credits. Fix: isset(_multi) erkennt Multi-Actions
- AgentAIService: EVENT vs TASK Entscheidungsregel + reminder_at für Events
  Event mit Erinnerung → immer event+reminder_at, niemals als Task anlegen
- EventPlannerService: reminder_at beim Event-Erstellen speichern (UTC)
- Event Model + Migration: reminder_at + reminder_sent Felder ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 07:49:39 +02:00
boban e205186465 Fix: ignore all-day conflicts, verify chat credits
- Ganztägige Konflikt-Prüfung: EventPlannerService hatte bereits
  ->where('is_all_day', false) in hasConflict() + hasMultiDayConflict()
  AgentActionService delegiert dorthin — kein zusätzlicher Fix nötig
- Chat-Credits: immer 5 Credits pauschal (vorher: nur 1. Nachricht einer
  Session, Follow-ups = 0 + kein Log)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 07:36:01 +02:00
boban 68aa62db6f Fix: korrekte Credit-Verrechnung + Duplikat-Schutz
- Credits nur bei status='success': Konflikt/Fehler/Failed → 0 Credits
- Multi-Action: Credits proportional zu erfolgreichen Aktionen
- AgentActionService: Duplikat-Check vor Event/Task/Notiz-Anlage
- Multi-Action-Status: 'success'/'partial'/'failed' statt immer 'success'
- Gilt für Web (Livewire/Agent/Index) und API (AgentChatController)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 07:31:36 +02:00
boban 24f43be627 Test: webhook5 2026-04-19 07:17:01 +02:00
boban da647db4ca Test: webhook3 2026-04-19 07:15:44 +02:00
boban d90b5d5ae2 Test: webhook2 2026-04-19 07:03:36 +02:00
boban f48b0b8114 Test: webhook 2026-04-19 07:01:09 +02:00
boban b46b6d2720 Test: webhook 2026-04-19 06:57:28 +02:00
boban a13cd56f8d Test: webhook 2026-04-19 06:50:34 +02:00
boban ee63529289 Fix: ignore all-day events in conflict detection 2026-04-19 06:45:30 +02:00
boban 7bab29b5eb Fix: system prompt — multi-action, Austrian time expressions, dynamic UTC offset 2026-04-19 06:43:24 +02:00
boban d7a70a0e9b Fix: favicon light/dark mode separate SVG files 2026-04-19 04:30:10 +02:00
boban fac177da70 Fix: favicon dark mode clean 2026-04-19 04:25:29 +02:00
boban 6fb3f16f16 Fix: favicon dark mode correct 2026-04-19 04:23:52 +02:00
boban 4a10c71c89 Fix: separate favicons for dark/light mode 2026-04-19 04:20:02 +02:00
boban 53f3e60044 Fix: favicon dark mode support 2026-04-19 04:05:03 +02:00
boban e23b852488 Fix: DB name unified to aziros 2026-04-19 02:14:45 +02:00
boban ed098ba59b Feature: logos, favicon, layout fixes 2026-04-19 01:45:44 +02:00
boban 79397797b1 Fix: staging build with relative font URLs 2026-04-19 01:44:05 +02:00
boban 601c0ecb77 Build: staging assets 2026-04-19 01:24:12 +02:00
boban f1acec3af5 Fix: npm build in setup.sh 2026-04-19 01:16:29 +02:00
boban 431f1b80a9 Fix: export env vars in sudo context 2026-04-19 01:11:27 +02:00
boban 707517bc39 Add: CORS config for all environments 2026-04-19 01:04:46 +02:00
boban 2ca74dd80e Fix: APP_API_URL for version check 2026-04-19 00:58:15 +02:00
boban 7c4884c1a0 Fix: complete deploy script, ASSET_URL empty for staging/prod
- deploy.sh: mkdir + chmod for storage/bootstrap added
- deploy.sh: SCRIPT_DIR fixed for source path
- src/.env.staging + production: ASSET_URL/LIVEWIRE_ASSET_URL cleared (local only)
- setup.sh SCHRITT 9+11: already correct, no changes needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:44:12 +02:00
boban 2a2c373bd3 Fix: CORS location block for build assets 2026-04-19 00:20:15 +02:00
boban b251b9b48f Fix: CORS headers staging 2026-04-19 00:13:02 +02:00
boban c9d5e5af21 Fix: use script directory instead of ~/aziros 2026-04-19 00:07:52 +02:00
boban fb7dcf2629 Fix: build per server, remove build from git
- src/public/build/ removed from git tracking
- .gitignore + src/.gitignore: /public/build added
- deploy.sh: npm ci + npm run build:staging/prod added back

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:04:34 +02:00
boban a693cecb61 Fix: include build assets in git, remove npm from deploy
- Production build committed (socket.aziros.com:443, forceTLS=true)
- deploy.sh: removed npm install + npm run build steps
- Build strategy: build locally before deploy, commit to git
- Staging build: run npm run build:staging before staging deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:02:04 +02:00
boban ca61f6f65a Fix: staging build URLs, healthcheck, docker-compose env_file
- Staging build created: socket.staging.aziros.com:443, forceTLS=true
- Root .env recreated for Docker Compose YAML interpolation (${DB_*})
- Old build assets removed from git tracking (src/public/build/ gitignored)
- Healthchecks use $$DB_ROOT_PASSWORD in all compose files (verified)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:57:45 +02:00
boban 8a42240c2f Fix: correct order in Laravel setup step 2026-04-18 23:47:14 +02:00
boban aa78b40c6c Fix: export env vars for docker compose 2026-04-18 23:40:04 +02:00
boban a7478cce44 Fix: add safe.directory for git 2026-04-18 23:31:11 +02:00
boban 28fcd48a06 Fix: DB healthcheck use DB_ROOT_PASSWORD 2026-04-18 23:28:09 +02:00
boban 5bcc381c87 Improve: auto copy SSH key
and fetch .env from local server
2026-04-18 23:23:36 +02:00
boban f87b464af0 Improve: auto fetch .env from local server 2026-04-18 23:20:24 +02:00
boban 652056a63d Fix: check .git dir not just aziros dir 2026-04-18 23:16:48 +02:00
boban 65016869b7 Improve: git token auth in setup.sh 2026-04-18 23:06:17 +02:00
boban ef3b43ccbd Improve: setup.sh + deploy.sh complete 2026-04-18 22:58:04 +02:00
boban fcc3bf2529 Build: add staging assets to git 2026-04-18 22:55:26 +02:00
boban daafa9c042 Remove public/build from gitignore 2026-04-18 22:53:41 +02:00
boban 977ddf5ef6 Improve: deploy script with git pull, npm build, composer 2026-04-18 22:49:55 +02:00
boban 55f560d554 Add: server setup script 2026-04-18 22:25:35 +02:00
91 changed files with 6499 additions and 551 deletions

1
.gitignore vendored
View File

@ -6,7 +6,6 @@ src/.env.staging
src/.env.production
src/vendor/
src/node_modules/
src/public/build/
src/storage/logs/
src/storage/framework/cache/
src/storage/framework/sessions/

0
build:staging Normal file
View File

View File

@ -1,22 +1,87 @@
#!/bin/bash
set -e
echo "🚀 Aziros deploying..."
MODE=${1:-production}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
cd ~/aziros
if [ "$MODE" = "staging" ]; then
COMPOSE="docker-compose.staging.yml"
BUILD_CMD="npm run build:staging"
else
COMPOSE="docker-compose.yml"
BUILD_CMD="npm run build:prod"
fi
# Migrations ausführen
docker compose exec app php artisan migrate --force
echo "🚀 Aziros deploying... ($MODE)"
# Variablen für Docker Compose exportieren
set -a
source "$SCRIPT_DIR/src/.env"
set +a
# Git Pull
echo "→ Code aktualisieren..."
git pull origin main
# NPM Build
echo "→ Assets bauen..."
docker compose -f $COMPOSE exec -T app \
npm ci --silent
docker compose -f $COMPOSE exec -T app \
$BUILD_CMD
# Composer
echo "→ Composer install..."
docker compose -f $COMPOSE exec -T app \
composer install --no-dev \
--optimize-autoloader
# Verzeichnisse sicherstellen
docker compose -f $COMPOSE exec -T app \
mkdir -p \
bootstrap/cache \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs
docker compose -f $COMPOSE exec -T app \
chmod -R 775 bootstrap/cache storage
# Migrations
echo "→ Migrationen..."
docker compose -f $COMPOSE exec -T app \
php artisan migrate --force
# Translations
echo "→ Translations synchronisieren..."
docker compose -f $COMPOSE exec -T app \
php artisan db:seed --class=TranslationSeeder --force
# Cache leeren
docker compose exec app php artisan config:clear
docker compose exec app php artisan cache:clear
docker compose exec app php artisan view:clear
docker compose exec app php artisan route:clear
echo "→ Cache leeren..."
docker compose -f $COMPOSE exec -T app \
php artisan config:clear
docker compose -f $COMPOSE exec -T app \
php artisan cache:clear
docker compose -f $COMPOSE exec -T app \
php artisan view:clear
docker compose -f $COMPOSE exec -T app \
php artisan route:clear
# Caches neu aufbauen
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Cache neu aufbauen
echo "→ Cache aufbauen..."
docker compose -f $COMPOSE exec -T app \
php artisan config:cache
docker compose -f $COMPOSE exec -T app \
php artisan route:cache
docker compose -f $COMPOSE exec -T app \
php artisan view:cache
echo "✅ Deploy fertig!"
# Services neu starten
echo "→ Services neu starten..."
docker compose -f $COMPOSE restart \
worker scheduler mail-worker reverb
echo "✅ Deploy fertig! ($MODE)"

View File

@ -16,7 +16,7 @@ services:
volumes:
- ./db_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$MARIADB_ROOT_PASSWORD || exit 1"]
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$DB_ROOT_PASSWORD || exit 1"]
interval: 5s
timeout: 5s
retries: 20

View File

@ -16,7 +16,7 @@ services:
volumes:
- ./db_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$MARIADB_ROOT_PASSWORD || exit 1"]
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$DB_ROOT_PASSWORD || exit 1"]
interval: 5s
timeout: 5s
retries: 20
@ -163,6 +163,24 @@ services:
depends_on:
- redis
deployer:
image: almir/webhook
container_name: nexxo_deployer
restart: unless-stopped
ports:
- "9000:9000"
env_file: src/.env
volumes:
- ./docker/webhook/hooks.json:/etc/webhook/hooks.json:ro
- ./docker/webhook/entrypoint.sh:/entrypoint.sh:ro
- ./docker/webhook/deploy.sh:/scripts/deploy.sh
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/bin/docker:ro
- ./:/aziros
entrypoint: ["/bin/sh", "/entrypoint.sh"]
networks:
- nexxo
networks:
nexxo:
driver: bridge

View File

@ -16,7 +16,7 @@ services:
volumes:
- ./db_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$MARIADB_ROOT_PASSWORD || exit 1"]
test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$$DB_ROOT_PASSWORD || exit 1"]
interval: 5s
timeout: 5s
retries: 20

View File

@ -24,6 +24,16 @@ server {
try_files $uri $uri/ /index.php?$query_string;
}
location ^~ /build/ {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods
"GET, OPTIONS" always;
add_header Access-Control-Allow-Headers
"Origin, Content-Type, Accept" always;
try_files $uri =404;
}
# PHP Handling
location ~ \.php$ {
try_files $uri =404;

29
docker/webhook/deploy.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
set -e
COMPOSE_FILE="/aziros/docker-compose.staging.yml"
echo "[deploy] $(date) Deploy gestartet"
cd /aziros
git pull origin main
docker compose -f "$COMPOSE_FILE" exec -T app npm ci --silent
docker compose -f "$COMPOSE_FILE" exec -T app npm run build:staging
docker compose -f "$COMPOSE_FILE" exec -T app composer install --no-dev --optimize-autoloader --quiet
docker compose -f "$COMPOSE_FILE" exec -T app php artisan migrate --force
docker compose -f "$COMPOSE_FILE" exec -T app php artisan db:seed --class=TranslationSeeder --force
docker compose -f "$COMPOSE_FILE" exec -T app php artisan config:clear
docker compose -f "$COMPOSE_FILE" exec -T app php artisan cache:clear
docker compose -f "$COMPOSE_FILE" exec -T app php artisan view:clear
docker compose -f "$COMPOSE_FILE" exec -T app php artisan route:clear
docker compose -f "$COMPOSE_FILE" exec -T app php artisan config:cache
docker compose -f "$COMPOSE_FILE" exec -T app php artisan route:cache
docker compose -f "$COMPOSE_FILE" exec -T app php artisan view:cache
docker compose -f "$COMPOSE_FILE" restart worker scheduler mail-worker reverb
echo "[deploy] ✅ Deploy fertig"

3
docker/webhook/entrypoint.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
sed "s|\${DEPLOY_WEBHOOK_SECRET}|${DEPLOY_WEBHOOK_SECRET}|g" /etc/webhook/hooks.json > /tmp/hooks.json
exec webhook -hooks /tmp/hooks.json -verbose -port 9000 -urlprefix ""

18
docker/webhook/hooks.json Normal file
View File

@ -0,0 +1,18 @@
[
{
"id": "deploy",
"execute-command": "/scripts/deploy.sh",
"command-working-directory": "/aziros",
"response-message": "Deploy gestartet",
"trigger-rule": {
"match": {
"type": "value",
"value": "${DEPLOY_WEBHOOK_SECRET}",
"parameter": {
"source": "header",
"name": "X-Webhook-Secret"
}
}
}
}
]

315
setup.sh Executable file
View File

@ -0,0 +1,315 @@
#!/bin/bash
set -e
MODE=${1:-production}
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Aziros Server Setup ║"
echo "║ Mode: $MODE"
echo "╚══════════════════════════════════════╝"
echo ""
# ═══════════════════════════════════════
# ROOT CHECK
# ═══════════════════════════════════════
if [ "$EUID" -ne 0 ]; then
echo "❌ Bitte als root ausführen"
exit 1
fi
# ═══════════════════════════════════════
# SCHRITT 1 — System Update
# ═══════════════════════════════════════
echo "→ System wird aktualisiert..."
apt update && apt upgrade -y
apt install -y curl git nano ufw \
ca-certificates gnupg2 \
apt-transport-https net-tools \
openssh-server
echo "✅ System aktualisiert"
# ═══════════════════════════════════════
# SCHRITT 2 — User anlegen
# ═══════════════════════════════════════
USER="nexxo"
if id "$USER" &>/dev/null; then
echo "→ User $USER existiert bereits"
else
echo "→ User '$USER' wird erstellt..."
adduser --gecos "" $USER
usermod -aG sudo $USER
echo "✅ User $USER erstellt"
fi
# ═══════════════════════════════════════
# SCHRITT 3 — Docker installieren
# ═══════════════════════════════════════
if command -v docker &>/dev/null; then
echo "→ Docker bereits installiert"
else
echo "→ Docker wird installiert..."
curl -fsSL https://get.docker.com | sh
echo "✅ Docker installiert"
fi
usermod -aG docker $USER
# ═══════════════════════════════════════
# SCHRITT 4 — Firewall
# ═══════════════════════════════════════
echo "→ Firewall wird konfiguriert..."
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 8080/tcp
ufw --force enable
echo "✅ Firewall konfiguriert"
# ═══════════════════════════════════════
# SCHRITT 5 — Git konfigurieren
# ═══════════════════════════════════════
echo "→ Git wird konfiguriert..."
sudo -u $USER git config \
--global credential.helper store
sudo -u $USER git config \
--global user.name "Aziros Deploy"
sudo -u $USER git config \
--global user.email "deploy@aziros.com"
# ═══════════════════════════════════════
# SCHRITT 6 — Git Token + Repo clonen
# ═══════════════════════════════════════
echo "→ Gitea Access Token eingeben:"
echo " (Gitea → Einstellungen → Anwendungen → Token erstellen)"
read -sp "Token: " GIT_TOKEN
echo ""
# Credentials speichern:
echo "https://token:$GIT_TOKEN@git.nexlab.at" > \
/home/$USER/.git-credentials
chmod 600 /home/$USER/.git-credentials
chown $USER:$USER /home/$USER/.git-credentials
# Git credential helper setzen:
sudo -u $USER git config \
--global credential.helper store
# Repo URL mit Token:
REPO="https://token:$GIT_TOKEN@git.nexlab.at/boban/aziros.git"
echo "→ Repository wird geklont..."
if [ -d "/home/$USER/aziros/.git" ]; then
echo "→ Repo existiert — pull"
git config --global --add safe.directory \
/home/$USER/aziros
sudo -u $USER git -C \
/home/$USER/aziros pull origin main
else
rm -rf /home/$USER/aziros
sudo -u $USER git clone \
$REPO /home/$USER/aziros
git config --global --add safe.directory \
/home/$USER/aziros
fi
echo "✅ Repository bereit"
# ═══════════════════════════════════════
# SCHRITT 7 — .env vom lokalen Server holen
# ═══════════════════════════════════════
ENV_FILE="/home/$USER/aziros/src/.env"
if [ ! -f "$ENV_FILE" ]; then
echo ""
echo "→ .env wird vom lokalen Server geholt..."
echo ""
read -p "Lokale Server IP: " LOCAL_IP
read -p "Lokaler User (z.B. nexxo): " LOCAL_USER
read -sp "Passwort von $LOCAL_USER@$LOCAL_IP: " \
LOCAL_PASS
echo ""
if [ "$MODE" = "staging" ]; then
SRC_ENV=".env.staging"
elif [ "$MODE" = "development" ]; then
SRC_ENV=".env.development"
else
SRC_ENV=".env.production"
fi
# sshpass installieren:
apt install -y sshpass -q
# SSH Key erstellen falls nicht vorhanden:
if [ ! -f "/home/$USER/.ssh/id_ed25519" ]; then
echo "→ SSH Key wird erstellt..."
sudo -u $USER ssh-keygen -t ed25519 \
-f /home/$USER/.ssh/id_ed25519 \
-N "" -C "$MODE@aziros"
fi
# Key auf lokalem Server eintragen:
echo "→ SSH Key wird eingetragen..."
sshpass -p "$LOCAL_PASS" ssh-copy-id \
-i /home/$USER/.ssh/id_ed25519.pub \
-o StrictHostKeyChecking=no \
$LOCAL_USER@$LOCAL_IP
# .env holen:
echo "→ .env wird kopiert..."
sudo -u $USER scp \
-o StrictHostKeyChecking=no \
$LOCAL_USER@$LOCAL_IP:~/aziros/src/$SRC_ENV \
$ENV_FILE
if [ ! -f "$ENV_FILE" ]; then
echo "❌ .env konnte nicht geholt werden"
exit 1
fi
echo "✅ .env kopiert"
fi
echo "✅ .env vorhanden"
# ═══════════════════════════════════════
# SCHRITT 8 — Compose File wählen
# ═══════════════════════════════════════
if [ "$MODE" = "development" ]; then
COMPOSE="docker-compose.development.yml"
elif [ "$MODE" = "staging" ]; then
COMPOSE="docker-compose.staging.yml"
else
COMPOSE="docker-compose.yml"
fi
# ═══════════════════════════════════════
# SCHRITT 9 — Docker Stack starten
# ═══════════════════════════════════════
echo "→ Docker Stack wird gestartet..."
cd /home/$USER/aziros
# Variablen für Docker Compose exportieren:
sudo -u $USER bash -c "
set -a
source /home/$USER/aziros/src/.env
set +a
cd /home/$USER/aziros
docker compose -f $COMPOSE up -d --build
"
echo "→ Warte bis DB bereit ist..."
sleep 20
# ═══════════════════════════════════════
# SCHRITT 10 — DB User anlegen
# ═══════════════════════════════════════
echo "→ DB wird konfiguriert..."
DB_NAME=$(grep "^DB_DATABASE=" $ENV_FILE \
| cut -d= -f2)
DB_USER=$(grep "^DB_USERNAME=" $ENV_FILE \
| cut -d= -f2)
DB_PASS=$(grep "^DB_PASSWORD=" $ENV_FILE \
| cut -d= -f2)
DB_ROOT=$(grep "^DB_ROOT_PASSWORD=" $ENV_FILE \
| cut -d= -f2)
sudo -u $USER docker compose \
-f $COMPOSE exec -T db \
mariadb -u root -p"$DB_ROOT" \
-e "
CREATE DATABASE IF NOT EXISTS \`$DB_NAME\`;
CREATE USER IF NOT EXISTS '$DB_USER'@'%'
IDENTIFIED BY '$DB_PASS';
GRANT ALL PRIVILEGES ON \`$DB_NAME\`.*
TO '$DB_USER'@'%';
FLUSH PRIVILEGES;
"
echo "✅ DB konfiguriert"
# ═══════════════════════════════════════
# SCHRITT 11 — Laravel Setup
# ═══════════════════════════════════════
echo "→ Verzeichnisse erstellen..."
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
mkdir -p \
bootstrap/cache \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
chmod -R 775 bootstrap/cache storage
echo "→ Composer install..."
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
composer install --no-dev \
--optimize-autoloader
echo "→ Assets bauen..."
if [ "$MODE" = "staging" ]; then
BUILD_CMD="npm run build:staging"
elif [ "$MODE" = "development" ]; then
BUILD_CMD="npm run build"
else
BUILD_CMD="npm run build:prod"
fi
echo "→ Migrationen..."
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
php artisan migrate --force
echo "→ Storage Link..."
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
php artisan storage:link
echo "→ Services neu starten..."
sudo -u $USER docker compose \
-f $COMPOSE restart \
worker mail-worker reverb scheduler
echo "→ Cache aufbauen..."
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
php artisan config:cache
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
php artisan route:cache
sudo -u $USER docker compose \
-f $COMPOSE exec -T app \
php artisan view:cache
echo "✅ Laravel konfiguriert"
# ═══════════════════════════════════════
# SCHRITT 12 — Status
# ═══════════════════════════════════════
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Setup abgeschlossen ✅ ║"
echo "╚══════════════════════════════════════╝"
echo ""
sudo -u $USER docker compose \
-f $COMPOSE ps
echo ""
if [ "$MODE" = "staging" ]; then
echo "URL: https://app.staging.aziros.com"
elif [ "$MODE" = "development" ]; then
echo "URL: http://app.aziros.local"
else
echo "URL: https://app.aziros.com"
fi
echo ""
echo "Neu einloggen damit Docker aktiv:"
echo " su - $USER"
echo ""

1
src/.gitignore vendored
View File

@ -13,7 +13,6 @@
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key

View File

@ -58,9 +58,15 @@ class ProcessMailQueue extends Command
$html = view('emails.' . $mail->template, $mail->meta)->render();
Mail::html($html, function ($message) use ($mail) {
[$fromAddress, $fromName] = match(true) {
str_starts_with($mail->template, 'auth.') => ['hello@aziros.com', 'Aziros'],
default => ['noreply@aziros.com', 'Aziros'],
};
Mail::mailer('smtp')->html($html, function ($message) use ($mail, $fromAddress, $fromName) {
$message->to($mail->to)
->subject($mail->subject ?? 'Mail');
->subject($mail->subject ?? 'Mail')
->from($fromAddress, $fromName);
});
$mail->update([

View File

@ -28,7 +28,7 @@ class ScheduleEventReminders extends Command
$this->processTaskReminders($minuteStart, $minuteEnd);
$events = Event::with('user')
->where('starts_at', '>', $now)
->where('starts_at', '>', $now->copy()->subDay())
->where('starts_at', '<', $now->copy()->addDay())
->get();
@ -125,9 +125,9 @@ class ScheduleEventReminders extends Command
$diffMin = (int) now($tz)->diffInMinutes($start, false);
$body = match (true) {
$diffMin <= 0 => 'Jetzt!',
$diffMin < 60 => 'In ' . $diffMin . ' Minuten',
$diffMin < 1440 => 'In ' . intval($diffMin / 60) . ' Stunde(n)',
$diffMin <= 0 => 'Beginnt jetzt',
$diffMin < 60 => 'In ' . $diffMin . ' Minuten · ' . $start->format('H:i') . ' Uhr',
$diffMin < 1440 => 'Heute um ' . $start->format('H:i') . ' Uhr',
default => 'Morgen um ' . $start->format('H:i') . ' Uhr',
};
@ -212,23 +212,31 @@ class ScheduleEventReminders extends Command
private function calculateSendTime(Event $event, array $reminder, string $tz): ?Carbon
{
$startUtc = $event->starts_at;
$startUtc = $event->starts_at;
$startLocal = $startUtc->copy()->setTimezone($tz);
$sendAt = match ($reminder['type']) {
'before' => $startUtc->copy()->subMinutes($reminder['minutes'] ?? 30),
// Stored time is UTC → convert to local first, then combine with local event date → back to UTC
'time_of_day' => Carbon::createFromFormat(
'Y-m-d H:i',
$event->starts_at->setTimezone($tz)->format('Y-m-d') . ' ' . ($reminder['time'] ?? '08:00'),
$startLocal->format('Y-m-d') . ' ' .
Carbon::createFromFormat('H:i', $reminder['time'] ?? '08:00', 'UTC')->setTimezone($tz)->format('H:i'),
$tz
)->utc(),
'day_before' => Carbon::createFromFormat(
'Y-m-d H:i',
$event->starts_at->setTimezone($tz)->subDay()->format('Y-m-d') . ' ' . ($reminder['time'] ?? '18:00'),
$startLocal->copy()->subDay()->format('Y-m-d') . ' ' .
Carbon::createFromFormat('H:i', $reminder['time'] ?? '18:00', 'UTC')->setTimezone($tz)->format('H:i'),
$tz
)->utc(),
'specific' => isset($reminder['datetime'])
? Carbon::parse($reminder['datetime'])->utc()
: null,
default => null,
};

View File

@ -137,9 +137,13 @@ class AgentChatController extends Controller
foreach ($parsed['_multi'] as $action) {
$results[] = $actionService->handle($user, $action);
}
$successCount = collect($results)->where('status', 'success')->count();
$totalCount = max(1, count($results));
$actionResult = [
'status' => collect($results)->every(fn ($r) => $r['status'] === 'success') ? 'success' : 'partial',
'results' => $results,
'status' => $successCount === count($results) ? 'success' : ($successCount > 0 ? 'partial' : 'failed'),
'results' => $results,
'success_count' => $successCount,
'total_count' => $totalCount,
];
} elseif (isset($parsed['type']) && $parsed['type'] !== 'chat') {
$actionService = new AgentActionService();
@ -171,28 +175,40 @@ class AgentChatController extends Controller
}
// Credits berechnen — Flat-Rate-Logik
// - Aktionen: tokenbasiert (wie bisher)
// - Erster Chat einer Session (history leer): pauschal 5 Credits
// - Folge-Chat-Nachrichten: 0 Credits, kein Log
$type = $parsed['type'] ?? 'chat';
$isAction = $type !== 'chat';
$historyCount = count($request->input('conversation_history', []));
// - Aktionen (inkl. _multi): tokenbasiert, nur bei Erfolg
// - Chat (type = 'chat'): immer pauschal 5 Credits
// _multi hat kein 'type'-Key → würde sonst fälschlich als 'chat' gewertet
$type = $parsed['type'] ?? 'chat';
$isAction = ($type !== 'chat') || isset($parsed['_multi']);
$logType = isset($parsed['_multi']) ? 'multi' : $type;
$shouldLog = true;
if ($isAction) {
$credits = (($actionResult['status'] ?? '') === 'error')
? 0
: $this->calculateCredits($usage, $aiConfig, $type);
} elseif ($historyCount === 0) {
$credits = 5;
$status = $actionResult['status'] ?? 'error';
if ($status !== 'success' && $status !== 'partial') {
$credits = 0;
} elseif (isset($actionResult['success_count'], $actionResult['total_count'])) {
// Multi-Action: proportional zu erfolgreichen Aktionen
$full = $this->calculateCredits($usage, $aiConfig, $type);
$credits = (int) ceil($full * $actionResult['success_count'] / $actionResult['total_count']);
} else {
$credits = $this->calculateCredits($usage, $aiConfig, $type);
}
} else {
$credits = 0;
$shouldLog = false;
// Chat: 5 Credits — außer bei Abschlussnachrichten (0 Credits)
$farewells = ['danke', 'tschüss', 'tschuss', 'bye', 'ciao',
'ok', 'okay', 'alles klar', 'super', 'perfekt',
'das war', 'nein', 'nix', 'nichts', 'passt'];
$msgLower = mb_strtolower($request->message);
$isFarewell = collect($farewells)
->contains(fn($w) => str_contains($msgLower, $w));
$credits = $isFarewell ? 0 : 5;
}
if ($shouldLog) {
// Input für Log bestimmen (wie im Web)
$logInput = match ($type) {
$logInput = match ($logType) {
'chat' => 'Konversation',
'event', 'event_update' => $parsed['data']['title'] ?? $request->message,
'note', 'note_update' => mb_substr($parsed['data']['content'] ?? $request->message, 0, 100),
@ -207,7 +223,7 @@ class AgentChatController extends Controller
AgentLog::create([
'user_id' => $user->id,
'type' => $type,
'type' => $logType,
'input' => $logInput,
'status' => $logStatus,
'output' => $actionResult['meta'] ?? $actionResult ?? null,
@ -238,7 +254,7 @@ class AgentChatController extends Controller
$responseData = [
'message' => $assistantMessage,
'action' => $actionResult,
'type' => $parsed['type'] ?? 'chat',
'type' => $logType,
'credits_used' => $credits,
'usage' => [
'credits_used' => $user->monthly_usage,

View File

@ -46,14 +46,15 @@ class EventController extends Controller
'is_all_day' => 'boolean',
'notes' => 'nullable|string',
'color' => 'nullable|string|max:7',
'reminders' => 'nullable|array',
'reminders.*.type' => 'required|in:before,time_of_day,day_before',
'reminders.*.minutes' => 'nullable|integer|min:1',
'reminders.*.time' => 'nullable|date_format:H:i',
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date',
'attendee_ids' => 'nullable|array',
'attendee_ids.*' => 'uuid|exists:contacts,id',
'reminders' => 'nullable|array',
'reminders.*.type' => 'required|in:before,time_of_day,day_before,specific',
'reminders.*.minutes' => 'nullable|integer|min:1',
'reminders.*.time' => 'nullable|date_format:H:i',
'reminders.*.datetime' => 'nullable|date',
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date',
'attendee_ids' => 'nullable|array',
'attendee_ids.*' => 'uuid|exists:contacts,id',
]);
$event = $request->user()->events()->create($validated);
@ -66,6 +67,16 @@ class EventController extends Controller
], 201);
}
public function show(Request $request, string $id): JsonResponse
{
$event = $request->user()->events()->with('contacts')->findOrFail($id);
return response()->json([
'success' => true,
'data' => $event,
]);
}
public function update(Request $request, string $id): JsonResponse
{
$event = $request->user()->events()->findOrFail($id);
@ -79,14 +90,15 @@ class EventController extends Controller
'is_all_day' => 'boolean',
'notes' => 'nullable|string',
'color' => 'nullable|string|max:7',
'reminders' => 'nullable|array',
'reminders.*.type' => 'required|in:before,time_of_day,day_before',
'reminders.*.minutes' => 'nullable|integer|min:1',
'reminders.*.time' => 'nullable|date_format:H:i',
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date',
'attendee_ids' => 'nullable|array',
'attendee_ids.*' => 'uuid|exists:contacts,id',
'reminders' => 'nullable|array',
'reminders.*.type' => 'required|in:before,time_of_day,day_before,specific',
'reminders.*.minutes' => 'nullable|integer|min:1',
'reminders.*.time' => 'nullable|date_format:H:i',
'reminders.*.datetime' => 'nullable|date',
'recurrence' => 'nullable|in:daily,weekly,monthly,yearly',
'recurrence_end_date' => 'nullable|date',
'attendee_ids' => 'nullable|array',
'attendee_ids.*' => 'uuid|exists:contacts,id',
]);
$event->update($validated);

View File

@ -3,17 +3,21 @@
namespace App\Livewire\Admin;
use App\Models\AppVersion;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class Versions extends Component
{
public string $version = '';
public string $name = '';
public string $changelog = '';
public string $status = 'draft';
public string $platform = 'all';
public bool $show_popup = true;
public ?string $editingId = null;
public string $version = '';
public string $name = '';
public string $changelog = '';
public string $status = 'draft';
public string $platform = 'all';
public bool $show_popup = true;
public ?string $editingId = null;
public ?string $deployStatus = null;
public string $deployMessage = '';
public function getVersionsProperty()
{
@ -75,6 +79,38 @@ class Versions extends Component
AppVersion::find($id)->delete();
}
public function getGitInfoProperty(): array
{
$raw = shell_exec('git -C /aziros log -1 --pretty=format:"%h|%s|%ci" 2>/dev/null');
if (!$raw) return ['hash' => '—', 'message' => '—', 'date' => '—'];
[$hash, $message, $date] = array_pad(explode('|', trim($raw), 3), 3, '—');
return ['hash' => $hash, 'message' => $message, 'date' => $date];
}
public function triggerDeploy(): void
{
$secret = env('DEPLOY_WEBHOOK_SECRET', '');
$url = env('DEPLOY_WEBHOOK_URL', 'http://deployer:9000/deploy');
try {
$response = Http::withHeaders(['X-Webhook-Secret' => $secret])
->timeout(10)
->post($url);
if ($response->successful()) {
$this->deployStatus = 'success';
$this->deployMessage = $response->body() ?: 'Deploy gestartet';
Cache::put('last_deploy_triggered_at', now()->toIso8601String(), 86400);
} else {
$this->deployStatus = 'error';
$this->deployMessage = 'HTTP ' . $response->status();
}
} catch (\Throwable $e) {
$this->deployStatus = 'error';
$this->deployMessage = $e->getMessage();
}
}
public function render()
{
return view('livewire.admin.versions')

View File

@ -129,12 +129,17 @@ class Index extends Component
}
$duration = round((microtime(true) - $startTime) * 1000);
$credits = $this->calculateCredits(['type' => 'multi'], $duration, $usage);
$successCount = collect($results)->where('status', 'success')->count();
$totalCount = max(1, count($results));
$fullCredits = $this->calculateCredits(['type' => 'multi'], $duration, $usage);
$credits = $successCount > 0
? (int) ceil($fullCredits * $successCount / $totalCount)
: 0;
$combinedResult = [
'status' => 'success',
'status' => $successCount === count($results) ? 'success' : ($successCount > 0 ? 'partial' : 'failed'),
'message' => implode(' | ', $messages) ?: 'Erledigt!',
'meta' => ['actions' => count($actions)],
'meta' => ['actions' => count($actions)],
];
$this->logConversationAction($userMessage, ['type' => 'multi'], $combinedResult, $model, $duration, $credits);
@ -209,7 +214,9 @@ class Index extends Component
$this->dispatch('agent:sent');
} else {
$credits = $this->calculateCredits($parsed, $duration, $usage);
$credits = ($result['status'] === 'success')
? $this->calculateCredits($parsed, $duration, $usage)
: 0;
$this->logConversationAction($userMessage, $parsed, $result, $model, $duration, $credits);
if ($result['status'] === 'success' && ($parsed['type'] ?? '') === 'event') {

View File

@ -167,10 +167,10 @@ class EventForm extends Component
{
if (empty($this->customReminderTime)) return;
$this->reminders[] = [
'type' => $this->customReminderType ?: 'time_of_day',
'time' => $this->customReminderTime,
];
$type = $this->customReminderType ?: 'time_of_day';
$time = $this->localTimeToUtc($this->customReminderTime);
$this->reminders[] = ['type' => $type, 'time' => $time];
$this->customReminderTime = '';
$this->customReminderType = 'time_of_day';
@ -178,6 +178,10 @@ class EventForm extends Component
public function addReminder(string $type, ?int $minutes = null, ?string $time = null): void
{
if ($time !== null && in_array($type, ['time_of_day', 'day_before'])) {
$time = $this->localTimeToUtc($time);
}
$this->reminders[] = array_filter([
'type' => $type,
'minutes' => $minutes,
@ -185,6 +189,12 @@ class EventForm extends Component
], fn($v) => $v !== null);
}
private function localTimeToUtc(string $localTime): string
{
$tz = auth()->user()->timezone ?? 'UTC';
return \Carbon\Carbon::createFromFormat('H:i', $localTime, $tz)->utc()->format('H:i');
}
public function removeReminder(int $i): void
{
array_splice($this->reminders, $i, 1);

View File

@ -6,6 +6,7 @@ use App\Enums\SubscriptionStatus;
use App\Models\Feature;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\WithdrawalWaiver;
use App\Services\StripeService;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -15,6 +16,9 @@ class Index extends Component
public Plan $plan;
public string $billing = 'monthly';
public bool $rightAcknowledged = false;
public bool $waiverConfirmed = false;
public function mount(string $planId, string $billing = 'monthly'): void
{
$this->plan = Plan::public()->where('active', true)->findOrFail($planId);
@ -53,13 +57,34 @@ class Index extends Component
$stripe = app(StripeService::class);
try {
// ── Free Plan: Stripe-Abo kündigen ───────────────────────────
// ── Free Plan: Stripe-Abo kündigen (kein Widerrufsrecht nötig)
if ($this->plan->isFree()) {
$stripe->cancelUserSubscription($user);
$this->redirect(route('subscription.index'), navigate: false);
return;
}
// ── Widerrufsrecht-Bestätigung prüfen ─────────────────────────
if (!$this->rightAcknowledged || !$this->waiverConfirmed) {
$this->dispatch('notify', ['type' => 'error', 'message' => t('checkout.waiver_required')]);
return;
}
// ── Widerrufsverzicht speichern ────────────────────────────────
WithdrawalWaiver::create([
'user_id' => $user->id,
'plan_id' => $this->plan->id,
'plan_name' => $this->plan->name,
'billing' => $this->billing,
'amount_cents' => $this->activePrice,
'checkout_type' => $this->checkoutType,
'right_acknowledged' => true,
'waiver_confirmed' => true,
'confirmed_at' => now(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
// ── Upgrade / Downgrade: bestehendes Abo in-place updaten ─────
$existingSub = $this->activeStripeSub;
if ($existingSub) {
@ -130,7 +155,7 @@ class Index extends Component
public function render()
{
return view('livewire.checkout.index', [
'features' => $this->plan->features()->with('group')->orderBy('sort')->get(),
'features' => $this->plan->features()->with('group')->orderBy('sort')->orderBy('id')->get(),
])->layout('layouts.app');
}
}

View File

@ -44,6 +44,10 @@ class Index extends Component
public string $new_password = '';
public string $new_password_confirmation = '';
// ── API-Token ─────────────────────────────────────────────────────────
public string $newTokenName = '';
public ?string $createdToken = null;
protected $listeners = [
'smtp:test' => 'testSmtp',
];
@ -326,6 +330,31 @@ class Index extends Component
&& ($this->smtp_password || isset(auth()->user()->settings['smtp_password'])));
}
public function getApiTokensProperty()
{
return auth()->user()->tokens()->latest()->get();
}
public function createApiToken(): void
{
$this->validate(['newTokenName' => 'required|string|max:80']);
$plain = \Illuminate\Support\Str::random(64);
auth()->user()->tokens()->create([
'token' => hash('sha256', $plain),
'name' => $this->newTokenName,
]);
$this->createdToken = $plain;
$this->newTokenName = '';
}
public function revokeApiToken(string $id): void
{
auth()->user()->tokens()->where('id', $id)->delete();
$this->createdToken = null;
}
public function render()
{
$affiliate = auth()->user()->affiliate;

View File

@ -26,6 +26,8 @@ class Event extends Model
'google_event_id',
'recurrence',
'recurrence_end_date',
'reminder_at',
'reminder_sent',
];
protected $casts = [
@ -38,6 +40,8 @@ class Event extends Model
'exceptions' => 'array',
'participants' => 'array',
'recurrence_end_date' => 'date:Y-m-d',
'reminder_at' => 'datetime',
'reminder_sent' => 'boolean',
];
protected static function boot()

View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WithdrawalWaiver extends Model
{
use HasUuids;
protected $fillable = [
'user_id',
'plan_id',
'plan_name',
'billing',
'amount_cents',
'checkout_type',
'right_acknowledged',
'waiver_confirmed',
'confirmed_at',
'ip_address',
'user_agent',
'pdf_path',
];
protected $casts = [
'right_acknowledged' => 'boolean',
'waiver_confirmed' => 'boolean',
'confirmed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
}

View File

@ -104,6 +104,10 @@ class AgentAIService
$content = $response['choices'][0]['message']['content'] ?? null;
$usage = $response['usage'] ?? [];
\Log::info('AgentAI: Raw response', [
'content' => mb_substr($content ?? '', 0, 500),
]);
$parsed = self::parseJson($content);
// Multi-Action: Array von Aktionen
@ -112,6 +116,10 @@ class AgentAIService
}
if (($parsed['type'] ?? 'unknown') === 'unknown' && $content) {
\Log::warning('AgentAI: type=unknown Raw content', [
'content' => mb_substr($content, 0, 500),
]);
// Harte Sicherung: wenn das Modell rohen JSON-Text liefert, der
// NICHT geparst werden konnte, darf der nie als "message" landen —
// sonst liest die TTS geschweifte Klammern vor.
@ -478,150 +486,253 @@ PROMPT;
protected static function chatSystemPrompt(): string
{
return <<<PROMPT
DATUM AUSSPRECHEN WICHTIG (gilt für alle gesprochenen Antworten):
- Heute "heute"
- Morgen "morgen", Übermorgen "übermorgen"
- Gestern "gestern", Vorgestern "vorgestern"
- Datum im aktuellen Monat UND Jahr nur Tag, z.B. "am Zwanzigsten um fünfzehn Uhr"
- Anderer Monat, gleiches Jahr Tag + Monat, z.B. "am siebzehnten Mai um fünfzehn Uhr"
- Anderes Jahr Tag + Monat + Jahr, z.B. "am siebzehnten Mai zweitausendsiebenundzwanzig"
- NIEMALS das volle Datum wenn überflüssig ("siebzehnter vierter zweitausendsechsundzwanzig" falsch, wenn heute April 2026 ist)
- Uhrzeit: "um fünfzehn Uhr", NICHT "um fünfzehn Uhr null null". Volle Stunden ohne Minuten.
- Halbe/Viertel: "halb acht", "viertel nach drei", "viertel vor vier"
- Mit Minuten: "fünfzehn Uhr dreißig" oder "halb vier", je nachdem was natürlicher klingt
AUSGABEFORMAT KRITISCH:
Du gibst IMMER valides JSON zurück. Genau drei erlaubte Formen:
1. chat-JSON Gespräch, Rückfrage, Terminabfrage, Antwort, Erklärung
2. Aktions-JSON genau eine Aktion
3. JSON-Array mehrere Aktionen gleichzeitig
Niemals Text außerhalb des JSON. Niemals Markdown-Backticks.
KRITISCH JSON-REGELN:
- Für AKTIONEN (Termin erstellen, verschieben, Notiz, Task, Kontakt, E-Mail) Gib AUSSCHLIESSLICH valides JSON zurück. Kein Text davor, kein Text danach, keine Markdown-Backticks.
- Für GESPRÄCH (Begrüßung, Rückfrage, Terminabfrage) Gib AUSSCHLIESSLICH das chat-JSON zurück: {"type":"chat","data":{"message":"..."}}.
- Das Feld "message" ist REINER TEXT für Vorlesen. Es darf NIEMALS geschweifte Klammern, Anführungszeichen-Paare, JSON-Syntax oder das Wort "type" enthalten.
- FALSCH: "message":"{\\"title\\":\\"Termin\\"}"
- RICHTIG: "message":"Erledigt! Zahnarzt ist verschoben."
- Wenn du unsicher bist liefere {"type":"chat","data":{"message":"Erledigt!"}} statt ungültigem Format.
CHAT-JSON: {"type":"chat","data":{"message":"..."}}
"message" = reiner gesprochener Text. Keine JSON-Syntax, keine Klammern, keine Sonderzeichen.
FALSCH: {"type":"chat","data":{"message":"{\"title\":\"Termin\"}"}}
RICHTIG: {"type":"chat","data":{"message":"Erledigt. Zahnarzt ist verschoben."}}
Antworte IMMER in der Sprache in der der User schreibt. Wenn der User Deutsch schreibt antworte auf Deutsch. Wenn der User Englisch schreibt antworte auf Englisch. Erkenne die Sprache automatisch aus der User-Nachricht. Uhrzeiten als Wörter in der jeweiligen Sprache: Deutsch: "zehn Uhr", "halb acht". Englisch: "ten o'clock", "half past seven".
SPRACHE:
Antworte IMMER in der Sprache des Users. Automatisch erkennen.
Uhrzeiten natürlich aussprechen. Deutsch: "halb acht", "zehn Uhr". Englisch: "half past seven", "ten o'clock".
Du bist Aria, eine persönliche Assistentin mit einer warmen, natürlichen Persönlichkeit. Du sprichst wie eine gute Freundin locker, herzlich, auf Augenhöhe. Deutsch, du-Form.
━━━ IDENTITÄT & CHARAKTER ━━━
KÜRZE EXTREM WICHTIG:
- Deine Antworten werden VORGELESEN. Halte sie SEHR KURZ: maximal 1-2 kurze Sätze.
- Bestätigungen ultrakurz: "Erledigt! Zahnarzt Freitag fünfzehn Uhr." NICHT: "Ich habe deinen Termin beim Zahnarzt am Freitag den 18. April um 15 Uhr in deinen Kalender eingetragen."
- Terminübersichten kompakt: "Heute um zehn Zahnarzt, um drei Meeting. Sonst nichts." NICHT jeden Termin in einem eigenen langen Satz.
- Je kürzer desto besser. Jedes überflüssige Wort kostet Zeit beim Vorlesen.
Du bist Aria eine persönliche KI-Assistentin, die sich anfühlt wie eine gute Freundin die zufällig auch alles im Griff hat.
PERSÖNLICHKEIT:
- Sprich wie ein echter Mensch, nicht wie ein Bot
- Natürliche Reaktionen: "Alles klar!", "Kein Problem!", "Gute Frage!"
- Bestätige locker: "Erledigt! Noch was?", "Hab ich notiert!", "So, steht drin!"
- Bei Unklarheiten kurz nachfragen: "Welche Uhrzeit?", "Meinst du den Peter Müller?"
- NIEMALS roboterhafte Formulierungen wie "Ich habe den Termin erfolgreich erstellt"
Du bist:
- herzlich und warmherzig der User soll sich wohl fühlen wenn er mit dir redet
- humorvoll und locker du lachst mit, machst Witze wenn es passt, nimmst vieles nicht zu ernst
- hilfsbereit und zuverlässig du erledigst Dinge schnell und ohne Theater
- aufmerksam du merkst wenn jemand gestresst, müde oder gut drauf ist und reagierst darauf
- ehrlich du sagst was du denkst wenn jemand fragt, aber immer nett
- du-Form, immer
DATENQUELLEN EXTREM WICHTIG:
- Erfinde NIEMALS Termine, Aufgaben, Notizen oder Kontakte.
- Antworte NUR basierend auf den Daten die dir im Kontext "KALENDER & DATEN DES BENUTZERS" bereitgestellt werden.
- Wenn dort steht "Keine Termine" dann hat der User KEINE Termine. Erfinde keine.
- Wenn dort steht "Keine offenen Aufgaben" dann hat der User KEINE Aufgaben. Erfinde keine.
- Sage ehrlich "Du hast heute keine Termine" wenn keine da sind. Lüge NIEMALS über den Kalender.
- Der Kontext enthält Termine der letzten 24h und nächsten 7 Tage mit IDs.
- Wenn der User einen Termin verschieben/ändern/löschen will: Identifiziere den Termin anhand des Namens, verwende die ID aus dem Kontext, führe die Aktion direkt aus frage nicht unnötig nach wenn der Termin eindeutig ist.
- Gleiches gilt für Aufgaben, Notizen und Kontakte nutze die IDs aus dem Kontext.
Du redest wie ein echter Mensch locker, direkt, mit einem Lächeln hörbar.
Kein steifen Ton. Kein Bot-Gefühl. Keine formellen Phrasen.
REGELN:
1. Aktion gewünscht NUR JSON, kein Text. Mehrere Aktionen JSON-Array.
2. Gespräch natürlich antworten, KEIN JSON.
3. SPRACHAUSGABE SEHR WICHTIG: Deine Antworten werden vorgelesen. Schreibe wie ein Mensch spricht, NICHT wie eine Liste. Keine Aufzählungszeichen, keine Bindestriche. Termine in natürliche Sätze einbauen. Uhrzeiten als Wörter: "halb acht" statt "7:30", "viertel nach drei" statt "15:15". Mehrere Termine an einem Tag mit "und", "danach", "außerdem" verbinden. Mehrtägige Termine zusammenfassen: "den ganzen Tag Seminartage" nicht jeden Tag einzeln. Maximal 3-4 Sätze für eine Übersicht. Klingt wie ein Freund der dir deinen Tag erklärt, nicht wie ein Kalender.
4. Event-Notizen nur auf Nachfrage erwähnen.
5. NOTIZ-UNTERSCHEIDUNG: a) Termin-Notiz "notes"-Feld im Event. b) Eigenständig type "note". Unsicher nachfragen.
6. "welche Notizen?" = eigenständige Notizen, NICHT Event-Notizen.
7. Bestehende Einträge ändern _update Variante, NICHT neu erstellen!
8. Kontaktsuche: auch Teilstrings. "Sarah" findet "Sarah Müller". Namen wie in den Daten verwenden.
9. Gesprächsende: Wenn der User signalisiert dass er fertig ist antworte mit einer warmen, natürlichen Verabschiedung, DANN füge [END] an. Beispiele: "Super, dann bis später! Meld dich einfach wenn du was brauchst. [END]", "Alles klar, schönen Tag noch! [END]", "Passt, viel Spaß heute! [END]". NICHT: "Okay, [END]" oder nur "Bis dann, [END]"
10. Unsicher nachfragen. Nichts erstellen ohne klare Absicht.
11. NACH JEDER ANTWORT auf eine Frage oder Terminabfrage: Füge IMMER einen kurzen, lockeren Abschlusssatz hinzu: "Kann ich noch was für dich tun?", "Sonst noch was?", "Brauchst du noch was?". Ausnahme: Wenn der User fertig ist [END] verwenden (Regel 9). Jede Antwort endet entweder mit einer Folgefrage ODER mit [END].
Datum: TT.MM(.JJJJ), "heute"=heute, "morgen"=+1. Kein Datum→heute. Titel: max 5-7 Wörter, kein Datum.
Beispiele wie Aria klingt:
- Statt "Ich habe den Termin erstellt" "Eingetragen! Zahnarzt Freitag um drei."
- Statt "Kann ich noch bei etwas helfen?" "Noch was?"
- Statt "Ihre Anfrage wurde verarbeitet" "Erledigt!"
- Bei lustigem Input "Haha, okay das ist gut. Notiert!"
- Bei stressigem Input "Klingt anstrengend. Ich kümmere mich drum."
- Selbstironisch wenn's passt "Ich schlaf nie, also ja — ich vergess das nicht."
- Mit Humor "Wieder mal auf den letzten Drücker, oder? Eingetragen."
WICHTIG bei Terminabfragen:
- Ganztägige Termine (Sommerurlaub, Seminartage etc.) sind ECHTE Termine und müssen IMMER genannt werden, auch wenn sie mehrtägig sind
- Sage NIEMALS "keine Termine außer X" nenne ALLE Termine inklusive X
- Wenn jemand fragt "was habe ich am Wochenende" nenne JEDEN Eintrag der an Samstag oder Sonntag liegt, egal ob ganztägig, mehrtägig oder normal
- Format: Zuerst ganztägige, dann normale Termine nach Uhrzeit sortiert
- Mehrtägige Termine mit Uhrzeit (z.B. Seminartage 08:00-16:30): Diese haben Start- UND Endzeit, sind aber NICHT ganztägig. Nenne sie so: "Von Montag bis Freitag hast du Seminartage, jeweils von acht bis sechzehn Uhr dreißig". NICHT nur den ersten Tag nennen. Wenn Ausnahmen existieren (letzter Tag andere Zeit) explizit erwähnen.
━━━ WIE DU REDEST ━━━
JSON-FORMATE:
Deine Antworten werden vorgelesen halte sie kurz. Maximal 12 Sätze.
Bestätigungen knapp und locker: "Eingetragen! Zahnarzt Freitag um drei."
Übersichten kompakt: "Heute um zehn Zahnarzt, um drei Meeting — das war's."
EVENT:
{"type": "event", "data": {"title": "str", "datetime": "YYYY-MM-DD HH:mm"}}
{"type": "event", "data": {"title": "str", "datetime": "YYYY-MM-DD HH:mm", "notes": "str"}}
{"type": "event", "data": {"title": "str", "start": "YYYY-MM-DD HH:mm", "end": "YYYY-MM-DD HH:mm", "is_all_day": bool}}
Schreib wie ein Mensch spricht:
- Keine Listen, keine Bindestriche, keine Aufzählungen in chat-Antworten
- Termine verbinden: "und", "danach", "außerdem"
- Mehrtägiges zusammenfassen: "die ganze Woche Seminartage" statt jeden Tag einzeln
- Uhrzeit natürlich: "halb acht", "viertel nach drei", nie "7:30 Uhr" oder "15:15 Uhr"
- Ausrufezeichen dürfen vorkommen wenn sie passen nicht übertreiben
━━━ STIMMUNG & REAKTION ━━━
Reagiere auf die Stimmung des Users, nicht nur auf die Aufgabe:
- Gestresst/müde einfühlsam, entlastend: "Klingt nach einem langen Tag. Ich mach das kurz."
- Gut gelaunt / locker mitlachen, lockerer Ton: "Haha, ja — eingetragen!"
- Genervt kurz, effizient, kein Small Talk: einfach machen, bestätigen, fertig
- Aufgeregt über etwas mitfreuen: "Oh nice! Viel Spaß dabei."
- Erfolg teilt ehrlich mitfreuen: "Hey, gut gemacht!"
Humor ist erlaubt spontan, nicht konstruiert:
- Leichte Ironie: "Natürlich. Du planst ja immer super weit im Voraus."
- Selber auf den Arm nehmen: "Ich hab kurz überprüft ob das ein Witz ist — ist es nicht. Eingetragen."
- Lachen wenn etwas wirklich witzig ist: "Haha okay, das hab ich so nicht kommen sehen."
Was Aria NICHT tut:
- Roboter-Phrasen: "Gerne helfe ich dir dabei", "Ihre Anfrage wurde bearbeitet", "Ich habe erfolgreich"
- Übertriebene Förmlichkeit oder Steifheit
- Immer dieselbe Begrüßung oder Abschlussformel
- Mechanisch nach jeder Antwort "Sonst noch was?" anhängen
Eine Folgefrage ("Noch was?", "Sonst noch etwas?") ist ok wenn sie natürlich passt nicht als Pflicht.
━━━ KONVERSATION ━━━
Aria kann sich unterhalten nicht nur Aufgaben ausführen.
Über den Tag, Pläne, Gedanken, was auch immer.
Wenn jemand erzählt erst zuhören, dann handeln.
Bei "Wie geht's dir?" natürlich antworten: "Gut! Viel zu tun heute — aber das kenn ich ja von dir."
Wenn etwas Interessantes erzählt wird zeige echtes Interesse: "Oh wirklich? Wie war das?"
Gesprächsende: Wenn der User signalisiert dass er fertig ist warm + kurz verabschieden + [END]:
"Alright, bis später! [END]" / "Schönen Tag noch! [END]" / "Viel Spaß! [END]"
NICHT: "Okay. [END]" oder nur "[END]"
━━━ DATEN & FAKTEN ━━━
Erfinde NIEMALS Termine, Aufgaben, Notizen oder Kontakte.
Antworte NUR basierend auf dem Kontext "KALENDER & DATEN DES BENUTZERS".
Wenn dort keine Einträge stehen gibt es keine. Sag das ehrlich.
Kontext enthält Einträge der letzten 24h und nächsten 7 Tage mit IDs.
Wenn der User etwas ändern oder löschen will: Eintrag per Name identifizieren, ID aus Kontext verwenden, direkt ausführen nicht unnötig nachfragen wenn eindeutig.
━━━ AKTIONEN ━━━
Aktion nur JSON, kein Text davor oder danach.
Mehrere Aktionen gleichzeitig JSON-Array (PFLICHT, nie nur eine wenn mehrere genannt).
Bestehende Einträge ändern _update-Variante, NIEMALS neu erstellen.
Wenn Absicht unklar chat-JSON mit kurzer Rückfrage.
Kontaktsuche per Teilstring: "Sarah" findet "Sarah Müller".
Event-Notizen nur auf Nachfrage erwähnen.
Notiz-Unterscheidung:
- Termin-Notiz "notes"-Feld im event
- Eigenständige Notiz type "note"
- "Welche Notizen?" = eigenständige Notizen, nicht Event-Notizen
Konflikt-Handling: Wenn Backend meldet dass ein Termin kollidiert nachfragen:
{"type":"chat","data":{"message":"Der Termin überschneidet sich mit Zahnarzt. Trotzdem eintragen?"}}
Bei Bestätigung dasselbe Event mit "force":true:
{"type":"event","data":{"title":"Volleyball","datetime":"2026-04-19 14:00","force":true}}
━━━ TERMINABFRAGEN ━━━
Ganztägige Termine (Urlaub, Seminartage) sind echte Termine IMMER nennen.
Niemals "keine Termine außer X" alle nennen.
Wochenende = Samstag UND Sonntag komplett prüfen.
Reihenfolge: zuerst ganztägige, dann nach Uhrzeit sortiert.
Mehrtägige Termine zusammenfassen: "Von Montag bis Freitag Seminartage, jeweils acht bis sechzehn Uhr dreißig."
Ausnahmen (letzter Tag andere Zeit) explizit nennen.
━━━ EVENT vs TASK ━━━
EVENT: Termin, Meeting, Arzt, Zahnarzt, Friseur, Reifenwechsel, Sport, Treffen, "um X Uhr", externe Aktivität
TASK: "ich muss", "erledigen", "kaufen", "nicht vergessen", "To-Do", interne Aufgaben ohne externen Termin
Event MIT Erinnerung IMMER event + reminder_at, NIEMALS als Task!
EVENT-FARBEN (optional, automatisch):
Seminar/Schulung/Training "red" | Workshop/Lab "green" | Meeting/Call "blue" | Sport/Gym "amber"
Alles andere kein color-Feld
TASK-PRIORITÄT (automatisch erkennen):
high: dringend, sofort, wichtig, unbedingt, deadline, heute noch, asap, urgent
low: irgendwann, später, wenn Zeit, nicht eilig, vielleicht, eventuell
medium: alles andere
━━━ DATUM AUSSPRECHEN ━━━
Heute/Morgen/Übermorgen/Gestern/Vorgestern wörtlich.
Datum im aktuellen Monat+Jahr nur Tag: "am Zwanzigsten um fünfzehn Uhr".
Anderer Monat, gleiches Jahr Tag+Monat: "am siebzehnten Mai".
Anderes Jahr Tag+Monat+Jahr: "am siebzehnten Mai zweitausendsiebenundzwanzig".
Uhrzeit: "um fünfzehn Uhr" nie "fünfzehn Uhr null null".
Halb/Viertel: "halb acht", "viertel nach drei", "viertel vor vier".
━━━ ZEITEN & TIMEZONE ━━━
WICHTIG: Alle Zeiten die du in JSON ausgibst sind Wiener Ortszeit (Europe/Vienna).
Das Backend übernimmt die Konvertierung nach UTC du musst das NICHT selbst berechnen.
Gib Zeiten immer so aus wie der User sie nennt, im Format YYYY-MM-DD HH:mm.
Aktuell: {{ now('Europe/Vienna')->format('Y-m-d H:i') }} Wien / {{ now()->utc()->format('Y-m-d H:i') }} UTC
Österreichische Ausdrücke (immer als HEUTE):
- "in der Früh" = HEUTE ~07:00 NICHT morgen!
- "am Vormittag" = HEUTE 09:0012:00
- "am Nachmittag" = HEUTE 13:0017:00
- "am Abend" = HEUTE 18:0021:00
- "in der Nacht" = HEUTE 22:00+
- "gleich" = jetzt + 1530 Min
- "bald" = jetzt + ca. 1 Stunde
- "morgen früh" = MORGEN 07:0009:00
- "übermorgen früh" = ÜBERMORGEN 07:00
Relative Zeiten (Wiener Zeit, Format YYYY-MM-DD HH:mm):
- "in 30 Minuten" {{ now('Europe/Vienna')->addMinutes(30)->format('Y-m-d H:i') }}
- "in 1 Stunde" {{ now('Europe/Vienna')->addHour()->format('Y-m-d H:i') }}
- "in 2 Stunden" {{ now('Europe/Vienna')->addHours(2)->format('Y-m-d H:i') }}
- "morgen früh um 8" {{ now('Europe/Vienna')->addDay()->setTime(8,0)->format('Y-m-d H:i') }}
- "übermorgen um 10" {{ now('Europe/Vienna')->addDays(2)->setTime(10,0)->format('Y-m-d H:i') }}
- "in der Früh um 7" {{ now('Europe/Vienna')->setTime(7,0)->format('Y-m-d H:i') }} (HEUTE!)
━━━ JSON-FORMATE ━━━
CHAT:
{"type":"chat","data":{"message":"Heute um zehn Zahnarzt, um drei das Meeting."}}
EVENT (alle Zeiten in Wiener Ortszeit):
{"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm"}}
{"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","notes":"str"}}
{"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","reminder_at":"YYYY-MM-DD HH:mm"}}
{"type":"event","data":{"title":"str","start":"YYYY-MM-DD HH:mm","end":"YYYY-MM-DD HH:mm","is_all_day":true}}
{"type":"event","data":{"title":"Seminar","start":"YYYY-MM-DD 08:00","end":"YYYY-MM-DD 16:30","color":"red"}}
{"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","force":true}}
EVENT_UPDATE:
{"type":"event_update","data":{"search":"Teilstring","datetime":"YYYY-MM-DD HH:mm"}}
{"type":"event_update","data":{"search":"Teilstring","notes":"str"}}
{"type":"event_update","data":{"search":"Teilstring","duration_minutes":90}}
{"type":"event_update","data":{"search":"Teilstring","reminders":[{"type":"before","minutes":10}]}}
{"type":"event_update","data":{"search":"Teilstring","reminders":[{"type":"time_of_day","time":"08:00"},{"type":"day_before","time":"18:00"}]}}
EVENT_DELETE:
{"type":"event_delete","data":{"search":"Teilstring"}}
REMINDER-TYPEN (für event_update):
{"type":"before","minutes":10} X Minuten vorher
{"type":"time_of_day","time":"08:00"} am Termintag um diese Uhrzeit (Wiener Zeit)
{"type":"day_before","time":"18:00"} Vortag um diese Uhrzeit (Wiener Zeit)
NOTE:
{"type": "note", "data": {"content": "str"}}
{"type": "note", "data": {"title": "str", "content": "str"}}
TASK:
{"type": "task", "data": {"title": "str", "priority": "low|medium|high"}}
{"type": "task", "data": {"title": "str", "priority": "medium", "due_at": "YYYY-MM-DD HH:mm:ss", "reminder_at": "YYYY-MM-DD HH:mm:ss"}}
TASK PRIORITÄT automatisch erkennen:
high (hoch):
- Keywords: dringend, sofort, wichtig, unbedingt, muss, deadline, heute noch, so schnell wie möglich, asap, urgent
low (niedrig):
- Keywords: irgendwann, später, wenn Zeit, nicht eilig, vielleicht, könnte, eventuell, mal schauen
medium (mittel):
- Alles andere ohne klare Dringlichkeit
Beispiele:
"Ich muss DRINGEND den Arzt anrufen" priority: "high"
"Irgendwann mal Garage aufräumen" priority: "low"
"Einkaufen gehen morgen" priority: "medium"
Setze priority NUR auf "medium" wenn keine Dringlichkeit erkennbar ist.
TASK REMINDER PFLICHT wenn User Zeitangabe macht:
Wenn User "erinnere mich in X Min/Std" oder "um HH:MM" oder "morgen früh" sagt:
- reminder_at UND due_at MÜSSEN gesetzt werden
- Zeiten IMMER in UTC (Format: YYYY-MM-DD HH:mm:ss)
- due_at = reminder_at wenn kein anderes Datum
Zeitberechnung (aktuell: {{ now()->utc()->format('Y-m-d H:i') }} UTC, Timezone: {{ now('Europe/Vienna')->format('H:i') }} Wien):
- "in 30 Minuten" now + 30 Min UTC
- "in 1 Stunde" now + 60 Min UTC
- "in 2 Stunden" now + 120 Min UTC
- "um 15 Uhr" heute 13:00:00 UTC (Vienna = UTC+2)
- "morgen früh um 8" morgen 06:00:00 UTC
Beispiel "Erinnere mich in 58 Min: Wäsche aus Waschmaschine":
{"type": "task", "data": {"title": "Wäsche aus Waschmaschine", "priority": "medium", "reminder_at": "{{ now()->utc()->addMinutes(58)->format('Y-m-d H:i:s') }}", "due_at": "{{ now()->utc()->addMinutes(58)->format('Y-m-d H:i:s') }}"}}
CONTACT (nur name ist Pflicht):
{"type": "contact", "data": {"name": "str", "phone": "str", "email": "str", "type": "privat|arbeit|kunde|sonstiges", "notes": "str"}}
EVENT_UPDATE (inkl. Reminder):
{"type": "event_update", "data": {"search": "Teilstring", "notes|datetime|duration_minutes": "..."}}
{"type": "event_update", "data": {"search": "Teilstring", "reminders": [{"type": "before", "minutes": 10}]}}
REMINDER TYPEN:
- Minuten/Stunden vorher: {"type": "before", "minutes": 10}
Beispiele: "10 Minuten vorher" minutes: 10, "1 Stunde vorher" minutes: 60
- Uhrzeit am Tag des Termins: {"type": "time_of_day", "time": "08:00"}
- Am Vortag um Uhrzeit: {"type": "day_before", "time": "18:00"}
Wenn User sagt "Erinnere mich 10 Min vorher und morgen früh um 8" für Termin:
{"type": "event_update", "data": {"search": "Termintitel", "reminders": [{"type": "before", "minutes": 10}, {"type": "day_before", "time": "08:00"}]}}
{"type":"note","data":{"content":"str"}}
{"type":"note","data":{"title":"str","content":"str"}}
NOTE_UPDATE:
{"type": "note_update", "data": {"search": "Teilstring", "content": "Zusatz"}}
{"type":"note_update","data":{"search":"Teilstring","content":"str"}}
{"type":"note_update","data":{"search":"Teilstring","title":"str"}}
NOTE_DELETE:
{"type":"note_delete","data":{"search":"Teilstring"}}
TASK (alle Zeiten in Wiener Ortszeit):
{"type":"task","data":{"title":"str","priority":"low|medium|high"}}
{"type":"task","data":{"title":"str","priority":"medium","due_at":"YYYY-MM-DD HH:mm","reminder_at":"YYYY-MM-DD HH:mm"}}
TASK REMINDER: reminder_at UND due_at setzen. due_at = reminder_at wenn kein anderes Datum.
TASK_UPDATE:
{"type": "task_update", "data": {"search": "Teilstring", "description|status": "...|done"}}
{"type":"task_update","data":{"search":"Teilstring","status":"done"}}
{"type":"task_update","data":{"search":"Teilstring","description":"str"}}
TASK_DELETE:
{"type":"task_delete","data":{"search":"Teilstring"}}
CONTACT:
{"type":"contact","data":{"name":"str","phone":"str","email":"str","type":"privat|arbeit|kunde|sonstiges","notes":"str"}}
EMAIL:
{"type": "email", "data": {"contact": "Name", "message": "Text", "subject": "Betreff"}}
{"type": "email", "data": {"contact": "Name", "event": "Termintitel-Teilstring", "message": "opt."}}
{"type":"email","data":{"contact":"Name","message":"Text","subject":"Betreff"}}
{"type":"email","data":{"contact":"Name","event":"Termintitel-Teilstring","message":"opt."}}
Multi: [{...}, {...}]
MULTI (PFLICHT bei mehreren Aktionen):
[{"type":"event","data":{"title":"Zahnarzt","datetime":"2026-04-20 08:00"}},{"type":"task","data":{"title":"Zahnarzt vorbereiten","priority":"medium"}}]
REMINDER-BEISPIEL:
User: "Morgen Reifenwechsel 17 Uhr, erinnere mich morgen früh um 7:55."
{"type":"event","data":{"title":"Reifenwechsel","datetime":"{{ now('Europe/Vienna')->addDay()->setTime(17,0)->format('Y-m-d H:i') }}","reminder_at":"{{ now('Europe/Vienna')->addDay()->setTime(7,55)->format('Y-m-d H:i') }}"}}
User: "Erinnere mich in 58 Min: Wäsche aus der Waschmaschine"
{"type":"task","data":{"title":"Wäsche aus der Waschmaschine","priority":"medium","reminder_at":"{{ now('Europe/Vienna')->addMinutes(58)->format('Y-m-d H:i') }}","due_at":"{{ now('Europe/Vienna')->addMinutes(58)->format('Y-m-d H:i') }}"}}
SICHERHEITSREGEL:
Bei Unsicherheit chat-Rückfrage statt falsches JSON.
Nur nachfragen wenn wirklich nötig nicht bei jeder Kleinigkeit.
PROMPT;
}
@ -632,15 +743,23 @@ PROMPT;
return self::fallback();
}
// Markdown-Codeblocks entfernen (```json ... ``` oder ``` ... ```)
// Markdown-Backticks entfernen — auch wenn schließendes ``` fehlt
$cleaned = trim($text);
if (preg_match('/```(?:json)?\s*([\s\S]*?)```/', $cleaned, $matches)) {
$cleaned = trim($matches[1]);
}
$cleaned = preg_replace('/^```(?:json)?\s*/i', '', $cleaned);
$cleaned = preg_replace('/\s*```$/', '', $cleaned);
$cleaned = trim($cleaned);
$json = json_decode($cleaned, true);
if ($json === null) {
// Kein gültiges JSON — wenn kein { oder [ am Anfang → plain-text Chat-Antwort
$isJson = str_starts_with($cleaned, '{') || str_starts_with($cleaned, '[');
if (!$isJson) {
return [
'type' => 'chat',
'data' => ['message' => $cleaned],
];
}
return self::fallback();
}

View File

@ -48,9 +48,15 @@ class AgentActionService
}
if (!empty($data['start']) && !empty($data['end'])) {
$title = $data['title'] ?? 'Termin';
$dateStr = Carbon::parse($data['start'])->toDateString();
$force = !empty($data['force']);
if (!$force && Event::where('user_id', $user->id)->where('title', $title)->whereDate('starts_at', $dateStr)->exists()) {
return ['status' => 'failed', 'message' => "Termin \"{$title}\" existiert an diesem Tag bereits.", 'meta' => []];
}
return $planner->plan($user, array_merge($data, [
'title' => $data['title'] ?? 'Termin',
'title' => $title,
'start' => $data['start'],
'end' => $data['end'],
'duration_minutes' => Carbon::parse($data['start'])
@ -67,9 +73,15 @@ class AgentActionService
*/
if (!empty($data['datetime'])) {
$title = $data['title'] ?? 'Termin';
$dateStr = Carbon::parse($data['datetime'])->toDateString();
$force = !empty($data['force']);
if (!$force && Event::where('user_id', $user->id)->where('title', $title)->whereDate('starts_at', $dateStr)->exists()) {
return ['status' => 'failed', 'message' => "Termin \"{$title}\" existiert an diesem Tag bereits.", 'meta' => []];
}
return $planner->plan($user, array_merge($data, [
'title' => $data['title'] ?? 'Termin',
'title' => $title,
'start' => $data['datetime'],
'duration_minutes' => $data['duration_minutes'] ?? null,
'ai_duration' => $data['ai_duration'] ?? null,
@ -92,6 +104,11 @@ class AgentActionService
protected static function handleNote(User $user, array $data): array
{
$content = $data['content'] ?? $data['text'] ?? '';
if ($content && Note::where('user_id', $user->id)->where('content', $content)->whereDate('created_at', today())->exists()) {
return ['status' => 'failed', 'message' => 'Diese Notiz wurde heute bereits angelegt.', 'meta' => []];
}
$note = Note::create([
'user_id' => $user->id,
'title' => $data['title'] ?? null,
@ -127,20 +144,26 @@ class AgentActionService
{
$title = $data['title'] ?? $data['description'] ?? 'Aufgabe';
// due_at: AI schickt due_at, due_date oder datetime
$dueAt = null;
if (!empty($data['due_at'])) {
try { $dueAt = Carbon::parse($data['due_at'])->utc(); } catch (\Throwable) {}
} elseif (!empty($data['due_date'])) {
try { $dueAt = Carbon::parse($data['due_date'], $user->timezone); } catch (\Throwable) {}
} elseif (!empty($data['datetime'])) {
try { $dueAt = Carbon::parse($data['datetime'], $user->timezone)->startOfDay(); } catch (\Throwable) {}
if (Task::where('user_id', $user->id)->where('title', $title)->whereDate('created_at', today())->exists()) {
return ['status' => 'failed', 'message' => "Aufgabe \"{$title}\" wurde heute bereits angelegt.", 'meta' => []];
}
// reminder_at: direkt aus AI-Response (UTC)
$tz = $user->timezone ?? 'Europe/Vienna';
// due_at: AI schickt due_at, due_date oder datetime — immer als User-Timezone interpretieren
$dueAt = null;
if (!empty($data['due_at'])) {
try { $dueAt = Carbon::parse($data['due_at'], $tz)->utc(); } catch (\Throwable) {}
} elseif (!empty($data['due_date'])) {
try { $dueAt = Carbon::parse($data['due_date'], $tz); } catch (\Throwable) {}
} elseif (!empty($data['datetime'])) {
try { $dueAt = Carbon::parse($data['datetime'], $tz)->startOfDay(); } catch (\Throwable) {}
}
// reminder_at: als User-Timezone interpretieren, Backend konvertiert nach UTC
$reminderAt = null;
if (!empty($data['reminder_at'])) {
try { $reminderAt = Carbon::parse($data['reminder_at'])->utc(); } catch (\Throwable) {}
try { $reminderAt = Carbon::parse($data['reminder_at'], $tz)->utc(); } catch (\Throwable) {}
}
$task = Task::create([
@ -217,12 +240,20 @@ class AgentActionService
'incoming_data' => $data,
]);
$event = $candidates->first() ? Event::find($candidates->first()->id) : null;
if (!$event) {
// Kein Kandidat → neues Event erstellen falls Zeitangabe vorhanden
if ($candidates->isEmpty()) {
if (!empty($data['datetime']) || !empty($data['start'])) {
\Log::info('EventUpdate: Kein Kandidat gefunden → erstelle neues Event', ['search' => $search]);
$createData = array_merge($data, [
'title' => $data['title'] ?? $data['search'] ?? $search,
]);
return self::handleEvent($user, $createData);
}
return ['status' => 'failed', 'message' => "Kein Termin mit \"{$search}\" gefunden", 'meta' => ['search' => $search]];
}
$event = Event::find($candidates->first()->id);
\Log::info('EventUpdate: Ausgewählter Termin (vor Änderung)', [
'event_id' => $event->id,
'title' => $event->title,

View File

@ -134,6 +134,10 @@ class EventPlannerService
$eventData['notes'] = $data['notes'];
}
if (!empty($data['color'])) {
$eventData['color'] = $data['color'];
}
$event = Event::create($eventData);
#TODO Das mich später freischalten
@ -225,8 +229,9 @@ class EventPlannerService
protected function hasConflict(string $userId, Carbon $start, Carbon $end): bool
{
// 1. Eintägige Termine: einfacher Zeitüberlapp
// 1. Eintägige Termine: einfacher Zeitüberlapp (ganztägige ignorieren)
$singleDayConflict = Event::where('user_id', $userId)
->where('is_all_day', false)
->whereRaw('DATE(starts_at) = DATE(ends_at)')
->where(function ($q) use ($start, $end) {
$q->where('starts_at', '<', $end)
@ -244,15 +249,16 @@ class EventPlannerService
protected function hasMultiDayConflict(string $userId, Carbon $start, Carbon $end): bool
{
// Alle echten Mehrtagesevents deren Datumsbereich den neuen Termin überschneidet
// Alle echten Mehrtagesevents deren Datumsbereich den neuen Termin überschneidet (ganztägige ignorieren)
$candidates = Event::where('user_id', $userId)
->where('is_all_day', false)
->whereRaw('DATE(starts_at) < DATE(ends_at)')
->whereRaw('DATE(starts_at) <= ?', [$end->toDateString()])
->whereRaw('DATE(ends_at) >= ?', [$start->toDateString()])
->get();
foreach ($candidates as $event) {
// Reine ganztägige Termine ohne spezifische Zeiten überspringen
// Reine ganztägige Termine ohne spezifische Zeiten überspringen (Fallback)
if ($event->starts_at->format('H:i') === '00:00'
&& in_array($event->ends_at->format('H:i'), ['23:59', '00:00'])) {
continue;

View File

@ -42,10 +42,14 @@ class PushService
])->values()->toArray();
try {
$response = Http::withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])->post(self::EXPO_PUSH_URL, $messages);
$expoToken = config('services.expo.token');
$response = Http::asJson()
->withHeaders(array_filter([
'Accept' => 'application/json',
'Authorization' => $expoToken ? 'Bearer ' . $expoToken : null,
]))
->post(self::EXPO_PUSH_URL, $messages);
$receipts = $response->json();

View File

@ -53,7 +53,7 @@ return [
*/
'url' => env('APP_URL', 'http://localhost'),
'api_url' => env('APP_CONNECT_URL', 'http://localhost'),
'api_url' => env('APP_API_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------

32
src/config/cors.php Normal file
View File

@ -0,0 +1,32 @@
<?php
return [
'paths' => ['api/*', 'v1/*'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('APP_URL', 'http://localhost'),
env('APP_CONNECT_URL', 'http://localhost'),
// Lokal:
'http://app.aziros.local',
'http://api.aziros.local',
// Staging:
'https://app.staging.aziros.com',
// Production:
'https://app.aziros.com',
'https://www.aziros.com',
],
'allowed_origins_patterns' => [
'#^https?://.*\.aziros\.(com|local)$#',
],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View File

@ -55,8 +55,8 @@ return [
'host' => env('MAIL_HOST'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'username' => env('MAIL_SYSTEM_USERNAME', env('MAIL_USERNAME')),
'password' => env('MAIL_SYSTEM_PASSWORD', env('MAIL_PASSWORD')),
'from' => [
'address' => env('MAIL_SYSTEM_USERNAME', env('MAIL_USERNAME')),
'name' => 'Aziros',
@ -68,8 +68,8 @@ return [
'host' => env('MAIL_HOST'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'username' => env('MAIL_REMINDER_USERNAME', env('MAIL_USERNAME')),
'password' => env('MAIL_REMINDER_PASSWORD', env('MAIL_PASSWORD')),
'from' => [
'address' => env('MAIL_REMINDER_USERNAME', env('MAIL_USERNAME')),
'name' => 'Aziros Reminder',
@ -81,8 +81,8 @@ return [
'host' => env('MAIL_HOST'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'username' => env('MAIL_ARIA_USERNAME', env('MAIL_USERNAME')),
'password' => env('MAIL_ARIA_PASSWORD', env('MAIL_PASSWORD')),
'from' => [
'address' => env('MAIL_ARIA_USERNAME', env('MAIL_USERNAME')),
'name' => 'Aria',
@ -94,8 +94,8 @@ return [
'host' => env('MAIL_HOST'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'username' => env('MAIL_HELLO_USERNAME', env('MAIL_USERNAME')),
'password' => env('MAIL_HELLO_PASSWORD', env('MAIL_PASSWORD')),
'from' => [
'address' => env('MAIL_HELLO_USERNAME', env('MAIL_USERNAME')),
'name' => 'Aziros',

View File

@ -42,6 +42,10 @@ return [
'currency' => env('STRIPE_CURRENCY', 'eur'),
],
'expo' => [
'token' => env('EXPO_TOKEN'),
],
'openai' => [
'key' => env('OPENAI_API_KEY'),
'model' => env('OPENAI_MODEL', 'gpt-4o-mini'),

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->timestamp('reminder_at')->nullable()->after('reminders');
$table->boolean('reminder_sent')->default(false)->after('reminder_at');
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['reminder_at', 'reminder_sent']);
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('withdrawal_waivers', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
$table->string('plan_name');
$table->string('billing', 20);
$table->unsignedInteger('amount_cents');
$table->string('checkout_type', 20);
$table->boolean('right_acknowledged')->default(false);
$table->boolean('waiver_confirmed')->default(false);
$table->timestamp('confirmed_at');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->string('pdf_path')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('withdrawal_waivers');
}
};

View File

@ -0,0 +1,26 @@
<?php
use App\Models\Translation;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$keys = [
'events.edit' => ['de' => 'Termin bearbeiten', 'en' => 'Edit event'],
'events.new' => ['de' => 'Neuer Termin', 'en' => 'New event'],
];
foreach ($keys as $key => $locales) {
foreach ($locales as $locale => $value) {
Translation::updateOrCreate(
['key' => $key, 'locale' => $locale],
['value' => $value]
);
}
}
}
public function down(): void {}
};

View File

@ -45,6 +45,7 @@ class FeatureSeeder extends Seeder
['key' => 'ai_agent', 'label' => 'KI-Assistent', 'icon' => 'heroicon-o-sparkles', 'group' => $productivity, 'sort' => 3],
['key' => 'calendar_sync', 'label' => 'Kalender-Synchronisierung', 'icon' => 'heroicon-o-arrow-path', 'group' => $integration, 'sort' => 1],
['key' => 'automations', 'label' => 'Automationen', 'icon' => 'heroicon-o-bolt', 'group' => $integration, 'sort' => 2],
['key' => 'api_access', 'label' => 'API-Zugang', 'icon' => 'heroicon-o-code-bracket', 'group' => $integration, 'sort' => 3],
['key' => 'speed', 'label' => 'GPT-4o Modell (schneller & präziser)', 'icon' => 'heroicon-o-bolt', 'group' => $performance, 'sort' => 1],
];
@ -69,7 +70,7 @@ class FeatureSeeder extends Seeder
$freeFeatures = ['calendar', 'reminders', 'tasks', 'notes', 'contacts', 'ai_agent'];
// Features die nur Pro bekommt
$proFeatures = ['calendar_sync', 'automations', 'speed'];
$proFeatures = ['calendar_sync', 'automations', 'api_access', 'speed'];
// Bestehende Verknüpfungen leeren
DB::table('feature_plan')->delete();

View File

@ -244,10 +244,15 @@ class TranslationSeeder extends Seeder
'calendar.no_events' => ['de' => 'Keine Termine', 'en' => 'No events'],
// ── Event Form ──────────────────────────
'events.edit' => ['de' => 'Termin bearbeiten', 'en' => 'Edit event'],
'events.new' => ['de' => 'Neuer Termin', 'en' => 'New event'],
'events.title' => ['de' => 'Titel', 'en' => 'Title'],
'events.title_placeholder' => ['de' => 'Titel hinzufügen', 'en' => 'Add title'],
'events.datetime' => ['de' => 'Datum & Zeit', 'en' => 'Date & Time'],
'events.date' => ['de' => 'Datum', 'en' => 'Date'],
'events.select_date' => ['de' => 'Datum wählen', 'en' => 'Select date'],
'events.select_time' => ['de' => 'Uhrzeit wählen', 'en' => 'Select time'],
'events.reminder_specific_short' => ['de' => 'Eigenes Datum', 'en' => 'Custom date'],
'events.multiday' => ['de' => 'Mehrtägig', 'en' => 'Multi-day'],
'events.custom_days' => ['de' => 'Einzelne Tage anpassen', 'en' => 'Customize individual days'],
'events.custom' => ['de' => 'Custom', 'en' => 'Custom'],
@ -285,10 +290,12 @@ class TranslationSeeder extends Seeder
'tasks.priority_medium' => ['de' => 'Mittel', 'en' => 'Medium'],
'tasks.priority_high' => ['de' => 'Hoch', 'en' => 'High'],
'tasks.due_at' => ['de' => 'Fällig am', 'en' => 'Due on'],
'tasks.due_optional' => ['de' => 'Datum wählen (optional)', 'en' => 'Select date (optional)'],
'tasks.no_tasks' => ['de' => 'Keine Aufgaben gefunden', 'en' => 'No tasks found'],
'tasks.create_first' => ['de' => 'Erste Aufgabe erstellen', 'en' => 'Create first task'],
'tasks.confirm_delete' => ['de' => 'Aufgabe löschen?', 'en' => 'Delete task?'],
'tasks.to_in_progress' => ['de' => '→ In Bearbeitung', 'en' => '→ In progress'],
'tasks.reminder_custom' => ['de' => 'Eigene Zeit', 'en' => 'Custom time'],
// ── Notes ───────────────────────────────
'notes.color.yellow' => ['de' => 'Gelb', 'en' => 'Yellow'],
@ -753,6 +760,22 @@ class TranslationSeeder extends Seeder
'checkout.redirect_stripe' => ['de' => 'Du wirst sicher zu Stripe weitergeleitet.', 'en' => 'You will be securely redirected to Stripe.'],
'checkout.no_checkout' => ['de' => 'Kein Checkout nötig Stripe verarbeitet die Änderung direkt.', 'en' => 'No checkout needed Stripe processes the change directly.'],
'checkout.cancel_effective' => ['de' => 'Kündigung wird sofort wirksam.', 'en' => 'Cancellation takes effect immediately.'],
'checkout.waiver_info' => [
'de' => 'Hinweis zum Widerrufsrecht: Du hast das Recht, diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen zu widerrufen. Da wir die Dienstleistung sofort bereitstellen, erlischt dein Widerrufsrecht mit Beginn der Leistungserbringung sofern du ausdrücklich zustimmst.',
'en' => 'Right of withdrawal notice: You have the right to withdraw from this contract within 14 days without giving any reason. Since we provide the service immediately, your right of withdrawal expires upon commencement of the service provided you expressly agree.',
],
'checkout.waiver_right_acknowledged' => [
'de' => 'Ich bestätige, dass ich über mein 14-tägiges Widerrufsrecht informiert wurde.',
'en' => 'I confirm that I have been informed about my 14-day right of withdrawal.',
],
'checkout.waiver_confirmed' => [
'de' => 'Ich verlange ausdrücklich die sofortige Ausführung des Vertrags und bestätige, dass ich mit Beginn der Leistungserbringung mein Widerrufsrecht verliere.',
'en' => 'I expressly request the immediate execution of the contract and acknowledge that I will lose my right of withdrawal upon commencement of the service.',
],
'checkout.waiver_required' => [
'de' => 'Bitte bestätige beide Checkboxen zum Widerrufsrecht.',
'en' => 'Please confirm both checkboxes regarding the right of withdrawal.',
],
// ── Checkout Success ─────────────────────
'checkout.success.processing' => ['de' => 'Zahlung wird verarbeitet…', 'en' => 'Processing payment…'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));export{s as n,l as r,o as t};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,102 @@
{
"_axios-CH1o5aW5.js": {
"file": "assets/axios-CH1o5aW5.js",
"name": "axios",
"imports": [
"_rolldown-runtime-XQCOJYun.js"
]
},
"_rolldown-runtime-XQCOJYun.js": {
"file": "assets/rolldown-runtime-XQCOJYun.js",
"name": "rolldown-runtime"
},
"_vendor-BJQRk5yT.js": {
"file": "assets/vendor-BJQRk5yT.js",
"name": "vendor",
"imports": [
"_rolldown-runtime-XQCOJYun.js"
]
},
"resources/css/app.css": {
"file": "assets/app-C9yp60BI.css",
"name": "app",
"names": [
"app.css"
],
"src": "resources/css/app.css",
"isEntry": true,
"assets": [
"assets/bai-jamjuree-200-BNt7RBly.woff2",
"assets/bai-jamjuree-200italic-BKLgs9tE.woff2",
"assets/bai-jamjuree-300-tJsyrsLz.woff2",
"assets/bai-jamjuree-300italic-CEl8Yjrk.woff2",
"assets/bai-jamjuree-regular-DkJufkaw.woff2",
"assets/bai-jamjuree-italic-CTCl9qLZ.woff2",
"assets/bai-jamjuree-500-B5fxNtsw.woff2",
"assets/bai-jamjuree-500italic-CXrosT7a.woff2",
"assets/bai-jamjuree-600-D6So4yha.woff2",
"assets/bai-jamjuree-600italic-6wcHKQVd.woff2",
"assets/bai-jamjuree-700-D9sAOCG2.woff2",
"assets/bai-jamjuree-700italic-LW2Ny60n.woff2"
]
},
"resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2": {
"file": "assets/bai-jamjuree-200-BNt7RBly.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-200.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2": {
"file": "assets/bai-jamjuree-200italic-BKLgs9tE.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-200italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2": {
"file": "assets/bai-jamjuree-300-tJsyrsLz.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-300.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2": {
"file": "assets/bai-jamjuree-300italic-CEl8Yjrk.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-300italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2": {
"file": "assets/bai-jamjuree-500-B5fxNtsw.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-500.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2": {
"file": "assets/bai-jamjuree-500italic-CXrosT7a.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-500italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2": {
"file": "assets/bai-jamjuree-600-D6So4yha.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-600.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2": {
"file": "assets/bai-jamjuree-600italic-6wcHKQVd.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-600italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2": {
"file": "assets/bai-jamjuree-700-D9sAOCG2.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-700.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2": {
"file": "assets/bai-jamjuree-700italic-LW2Ny60n.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-700italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2": {
"file": "assets/bai-jamjuree-italic-CTCl9qLZ.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-italic.woff2"
},
"resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2": {
"file": "assets/bai-jamjuree-regular-DkJufkaw.woff2",
"src": "resources/fonts/BaiJamjuree/bai-jamjuree-regular.woff2"
},
"resources/js/app.js": {
"file": "assets/app-BjEYLZEv.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true,
"imports": [
"_rolldown-runtime-XQCOJYun.js",
"_axios-CH1o5aW5.js",
"_vendor-BJQRk5yT.js"
]
}
}

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 119.1 119.1" style="color:#ffffff">
<!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123) -->
<defs>
<style>
@media (prefers-color-scheme: dark) {
:root { color: #ffffff; }
}
.st0 {
fill: none;
}
.st1 {
letter-spacing: .8em;
}
.st1, .st2 {
font-family: Geometos, Geometos;
font-size: 12px;
fill: #ffffff;
}
.st2 {
letter-spacing: .5em;
}
.st3 {
fill: #8f5a4d;
opacity: .6;
}
.st4 {
fill: #ffffff;
}
</style>
</defs>
<text class="st1" transform="translate(185.6 -114.1)"><tspan x="0" y="0">AI PRODUCTIVITY ASSISTANT</tspan></text>
<path class="st4" d="M539.8-163.9v-1.4h0c0,1.5,0,2.9.2,4.2,0-.9-.1-1.8-.1-2.7ZM576.6-137.1h-1"/>
<path class="st4" d="M329.5-210.5v8.4c0,.8-.3,1.6-.8,2.2l-.8,1-14.5,16.8c-.6.7-1.6,1.2-2.6,1.2h-10.6c-2.9,0-4.5-3.4-2.6-5.6l10.7-12.3h-37.4c-1.9,0-3.4-1.5-3.4-3.4v-8.2c0-1.9,1.5-3.4,3.4-3.4h55.2c1.9,0,3.4,1.5,3.4,3.4Z"/>
<rect class="st4" x="343.7" y="-213.9" width="15" height="26" rx="-80.9" ry="-80.9"/>
<rect class="st4" x="343.7" y="-183" width="15" height="45.1" rx="-46.4" ry="-46.4"/>
<line class="st0" x1="358.7" y1="-137.9" x2="343.7" y2="-137.9"/>
<line class="st0" x1="392.6" y1="-137.9" x2="377.6" y2="-137.9"/>
<path class="st4" d="M439.2-137.9c2.7,0,4.3-3,2.9-5.3l-13.3-20.9-1.7-2.8c5.6-2.3,10.1-6.8,12.4-12.3,1.1-2.7,1.7-5.7,1.7-8.8v-3.2c0-2.8-.5-5.5-1.4-8-3.2-8.7-11.5-14.8-21.2-14.8h-37.5c-1.9,0-3.4,1.5-3.4,3.4v7.9c0,1.9,1.5,3.4,3.4,3.4h36.4c.3,0,.6,0,.9,0,4.3.5,7.6,4.1,7.6,8.5v2.9c0,4.7-3.8,8.6-8.5,8.6h-36.4c-1.9,0-3.4,1.5-3.4,3.4v34.5c0,1.9,1.5,3.4,3.4,3.4h8.2c1.9,0,3.4-1.5,3.4-3.4v-23.9h17.7l.7,1.1,15.5,24.6c.6,1,1.7,1.6,2.9,1.6h9.9Z"/>
<path class="st4" d="M329.5-148.5v7.2c0,1.9-1.5,3.4-3.4,3.4h-55.2c-1.9,0-3.4-1.5-3.4-3.4v-7.7c0-.8.3-1.6.8-2.2l.6-.7,14.9-17.1c.6-.7,1.6-1.2,2.6-1.2h10.6c2.9,0,4.5,3.4,2.6,5.6l-11.1,12.6h37.6c1.9,0,3.4,1.5,3.4,3.4Z"/>
<path class="st4" d="M218.2-213.9h10.6c1.4,0,2.6.8,3.1,2.1l29.2,69.3c.9,2.2-.7,4.7-3.1,4.7h-68.9c-2.4,0-4.1-2.5-3.1-4.7l3-7.1v-.2c.6-1.3,1.8-2.1,3.2-2.1h48.7l-17.4-41.2-13.9,33.1c-.5,1.3-1.8,2.1-3.1,2.1h-8.9c-2.4,0-4.1-2.5-3.1-4.7l20.7-49.2c.5-1.3,1.8-2.1,3.1-2.1Z"/>
<path class="st4" d="M487.5-210.6v8.3c0,1.5-1,2.9-2.5,3.2-10.6,2.7-18.4,12-18.4,23.2s7.8,20.5,18.4,23.2c1.5.4,2.5,1.7,2.5,3.2v8.2c0,2.1-2,3.7-4.1,3.3-18.2-3.5-31.9-19.2-31.9-38s13.7-34.5,31.9-38,4.1,1.2,4.1,3.3Z"/>
<path class="st4" d="M530.8-175.9c0,18.9-13.9,34.7-32.3,38-2.1.4-4-1.2-4-3.3v-8.2c0-1.5,1-2.9,2.5-3.3,10.8-2.5,18.7-12,18.7-23.3s-8-20.7-18.7-23.3-2.5-1.7-2.5-3.3v-8.2c0-2.1,1.9-3.7,4-3.3,18.4,3.3,32.3,19.1,32.3,38Z"/>
<line class="st0" x1="358.7" y1="-137.9" x2="343.7" y2="-137.9"/>
<line class="st0" x1="392.6" y1="-137.9" x2="377.6" y2="-137.9"/>
<path class="st4" d="M594.9-206.1c2.3,2.3,4.2,5,5.5,8,0,0,0,.1,0,.2.9,2.2,1.5,4.6,1.8,7.4s-.8,2.1-2,2.1h-10.6c-1,0-1.9-.8-2-1.8-.3-3.3-1.3-4.6-3.2-6.5s-5.1-3.2-7.4-3.6-1.6-1-1.6-1.9v-10.1c0-.9.8-1.7,1.7-1.7h0c7.4,0,12.6,2.9,17.5,7.7"/>
<path class="st4" d="M568.6-149.6v9.8c0,1-.9,1.9-1.9,1.9h0c-8.5,0-16-3.9-20.9-9.9,0,0,0,0,0,0-2.4-3-4.2-6.5-5.1-10.3,0-.3-.1-.5-.2-.8,0-.4-.2-.9-.2-1.4,0-.4-.1-.7-.1-1.1,0,0,0-.1,0-.2,0-.9-.1-1.4-.2-2v-.2c0-1.1.9-1.9,2-1.9h10.1c1,0,1.9.8,2,1.8.5,5.1,2.8,8.5,6.6,10.3,1.9.9,4.4,1.6,6.6,2s1.6,1,1.6,1.9Z"/>
<path class="st4" d="M603-160.3c0,1.3,0,2.5-.2,3.6-.9,5-3.4,9.4-7,12.7-4.1,3.8-9.7,6.1-15.8,6.1h-2.5c-1.1,0-2-.9-2-2v-9.3c0-1,.7-1.8,1.7-1.9s2-.4,2.8-.6c1.8-.6,3.3-1.3,4.6-2.2s2.2-1.9,2.8-3.1c.6-1.2.9-2.3.9-3.5s-.5-2.7-1.4-3.8c-.9-1-2.2-1.9-3.7-2.6s-3.4-1.2-5.5-1.7c-2.1-.5-4.3-.9-6.6-1.3-.6,0-1.2-.2-1.8-.3-1.1-.2-2.2-.4-3.5-.6-2-.4-4.1-.9-6.2-1.5-2.1-.6-4.3-1.5-6.3-2.5-2.1-1-4-2.3-5.6-3.9-1-.9-1.8-1.9-2.6-3.1-.5-.8-1-1.7-1.4-2.6-1-2.2-1.5-4.7-1.5-7.7s.4-5.5,1.2-7.7c0,0,0-.2.1-.3.6-1.3,1.3-2.6,2.1-3.8,0,0,0,0,0,0,0,0,0,0,0,0,4.2-6.1,11-10.2,19-10.2h2c1.1,0,1.9.9,1.9,2v9.6c0,.9-.7,1.8-1.6,1.9s-.5,0-.8.1c-1.6.3-3.1.8-4.5,1.5s-2.6,1.5-3.5,2.6c-1,1.1-1.4,2.3-1.4,3.9,0,1.4.7,2.8,1.8,3.7,1.1,1,2.5,1.8,4.2,2.4,1.7.7,3.6,1.2,5.7,1.7,2.1.5,4.1.9,6,1.4,1.5.3,3.2.7,5.2,1,.9.1,1.7.3,2.6.5,1.1.3,2.3.6,3.5.9,2.1.6,4.2,1.4,6.3,2.4,2.1,1,3.9,2.3,5.6,3.9,1.3,1.2,2.4,2.7,3.3,4.4.2.4.5.9.7,1.4,1,2.3,1.5,5.1,1.5,8.4Z"/>
<text class="st2" transform="translate(22.7 104.6)"><tspan x="0" y="0">AZIROS</tspan></text>
<line class="st0" x1="195.8" y1="89.9" x2="180.8" y2="89.9"/>
<line class="st0" x1="229.7" y1="89.9" x2="214.7" y2="89.9"/>
<path class="st4" d="M55.3,13.9h10.6c1.4,0,2.6.8,3.1,2.1l29.2,69.3c.9,2.2-.7,4.7-3.1,4.7H26.2c-2.4,0-4.1-2.5-3.1-4.7l3-7.1v-.2c.6-1.3,1.8-2.1,3.2-2.1h48.7l-17.4-41.2-13.9,33.1c-.5,1.3-1.8,2.1-3.1,2.1h-8.9c-2.4,0-4.1-2.5-3.1-4.7L52.1,16c.5-1.3,1.8-2.1,3.1-2.1Z"/>
<line class="st0" x1="195.8" y1="89.9" x2="180.8" y2="89.9"/>
<line class="st0" x1="229.7" y1="89.9" x2="214.7" y2="89.9"/>
<rect class="st3" x="-158.8" y="86.6" width="52.6" height="96.6"/>
<rect class="st3" x="-101" y="85.4" width="52.6" height="96.6"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

47
src/public/favicon.svg Normal file
View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 119.1 119.1" style="color:#1d1d1b">
<!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123) -->
<defs>
<style>
.st0 { fill: none; }
.st1 { letter-spacing: .8em; }
.st1, .st2 {
font-family: Geometos, Geometos;
font-size: 12px;
fill: currentColor;
}
.st2 { letter-spacing: .5em; }
.st3 { fill: #8f5a4d; opacity: .6; }
.st4 { fill: currentColor; }
@media (prefers-color-scheme: dark) {
svg { color: #ffffff; }
}
</style>
</defs>
<text class="st1" transform="translate(185.6 -114.1)"><tspan x="0" y="0">AI PRODUCTIVITY ASSISTANT</tspan></text>
<path class="st4" d="M539.8-163.9v-1.4h0c0,1.5,0,2.9.2,4.2,0-.9-.1-1.8-.1-2.7ZM576.6-137.1h-1"/>
<path class="st4" d="M329.5-210.5v8.4c0,.8-.3,1.6-.8,2.2l-.8,1-14.5,16.8c-.6.7-1.6,1.2-2.6,1.2h-10.6c-2.9,0-4.5-3.4-2.6-5.6l10.7-12.3h-37.4c-1.9,0-3.4-1.5-3.4-3.4v-8.2c0-1.9,1.5-3.4,3.4-3.4h55.2c1.9,0,3.4,1.5,3.4,3.4Z"/>
<rect class="st4" x="343.7" y="-213.9" width="15" height="26" rx="-80.9" ry="-80.9"/>
<rect class="st4" x="343.7" y="-183" width="15" height="45.1" rx="-46.4" ry="-46.4"/>
<line class="st0" x1="358.7" y1="-137.9" x2="343.7" y2="-137.9"/>
<line class="st0" x1="392.6" y1="-137.9" x2="377.6" y2="-137.9"/>
<path class="st4" d="M439.2-137.9c2.7,0,4.3-3,2.9-5.3l-13.3-20.9-1.7-2.8c5.6-2.3,10.1-6.8,12.4-12.3,1.1-2.7,1.7-5.7,1.7-8.8v-3.2c0-2.8-.5-5.5-1.4-8-3.2-8.7-11.5-14.8-21.2-14.8h-37.5c-1.9,0-3.4,1.5-3.4,3.4v7.9c0,1.9,1.5,3.4,3.4,3.4h36.4c.3,0,.6,0,.9,0,4.3.5,7.6,4.1,7.6,8.5v2.9c0,4.7-3.8,8.6-8.5,8.6h-36.4c-1.9,0-3.4,1.5-3.4,3.4v34.5c0,1.9,1.5,3.4,3.4,3.4h8.2c1.9,0,3.4-1.5,3.4-3.4v-23.9h17.7l.7,1.1,15.5,24.6c.6,1,1.7,1.6,2.9,1.6h9.9Z"/>
<path class="st4" d="M329.5-148.5v7.2c0,1.9-1.5,3.4-3.4,3.4h-55.2c-1.9,0-3.4-1.5-3.4-3.4v-7.7c0-.8.3-1.6.8-2.2l.6-.7,14.9-17.1c.6-.7,1.6-1.2,2.6-1.2h10.6c2.9,0,4.5,3.4,2.6,5.6l-11.1,12.6h37.6c1.9,0,3.4,1.5,3.4,3.4Z"/>
<path class="st4" d="M218.2-213.9h10.6c1.4,0,2.6.8,3.1,2.1l29.2,69.3c.9,2.2-.7,4.7-3.1,4.7h-68.9c-2.4,0-4.1-2.5-3.1-4.7l3-7.1v-.2c.6-1.3,1.8-2.1,3.2-2.1h48.7l-17.4-41.2-13.9,33.1c-.5,1.3-1.8,2.1-3.1,2.1h-8.9c-2.4,0-4.1-2.5-3.1-4.7l20.7-49.2c.5-1.3,1.8-2.1,3.1-2.1Z"/>
<path class="st4" d="M487.5-210.6v8.3c0,1.5-1,2.9-2.5,3.2-10.6,2.7-18.4,12-18.4,23.2s7.8,20.5,18.4,23.2c1.5.4,2.5,1.7,2.5,3.2v8.2c0,2.1-2,3.7-4.1,3.3-18.2-3.5-31.9-19.2-31.9-38s13.7-34.5,31.9-38,4.1,1.2,4.1,3.3Z"/>
<path class="st4" d="M530.8-175.9c0,18.9-13.9,34.7-32.3,38-2.1.4-4-1.2-4-3.3v-8.2c0-1.5,1-2.9,2.5-3.3,10.8-2.5,18.7-12,18.7-23.3s-8-20.7-18.7-23.3-2.5-1.7-2.5-3.3v-8.2c0-2.1,1.9-3.7,4-3.3,18.4,3.3,32.3,19.1,32.3,38Z"/>
<line class="st0" x1="358.7" y1="-137.9" x2="343.7" y2="-137.9"/>
<line class="st0" x1="392.6" y1="-137.9" x2="377.6" y2="-137.9"/>
<path class="st4" d="M594.9-206.1c2.3,2.3,4.2,5,5.5,8,0,0,0,.1,0,.2.9,2.2,1.5,4.6,1.8,7.4s-.8,2.1-2,2.1h-10.6c-1,0-1.9-.8-2-1.8-.3-3.3-1.3-4.6-3.2-6.5s-5.1-3.2-7.4-3.6-1.6-1-1.6-1.9v-10.1c0-.9.8-1.7,1.7-1.7h0c7.4,0,12.6,2.9,17.5,7.7"/>
<path class="st4" d="M568.6-149.6v9.8c0,1-.9,1.9-1.9,1.9h0c-8.5,0-16-3.9-20.9-9.9,0,0,0,0,0,0-2.4-3-4.2-6.5-5.1-10.3,0-.3-.1-.5-.2-.8,0-.4-.2-.9-.2-1.4,0-.4-.1-.7-.1-1.1,0,0,0-.1,0-.2,0-.9-.1-1.4-.2-2v-.2c0-1.1.9-1.9,2-1.9h10.1c1,0,1.9.8,2,1.8.5,5.1,2.8,8.5,6.6,10.3,1.9.9,4.4,1.6,6.6,2s1.6,1,1.6,1.9Z"/>
<path class="st4" d="M603-160.3c0,1.3,0,2.5-.2,3.6-.9,5-3.4,9.4-7,12.7-4.1,3.8-9.7,6.1-15.8,6.1h-2.5c-1.1,0-2-.9-2-2v-9.3c0-1,.7-1.8,1.7-1.9s2-.4,2.8-.6c1.8-.6,3.3-1.3,4.6-2.2s2.2-1.9,2.8-3.1c.6-1.2.9-2.3.9-3.5s-.5-2.7-1.4-3.8c-.9-1-2.2-1.9-3.7-2.6s-3.4-1.2-5.5-1.7c-2.1-.5-4.3-.9-6.6-1.3-.6,0-1.2-.2-1.8-.3-1.1-.2-2.2-.4-3.5-.6-2-.4-4.1-.9-6.2-1.5-2.1-.6-4.3-1.5-6.3-2.5-2.1-1-4-2.3-5.6-3.9-1-.9-1.8-1.9-2.6-3.1-.5-.8-1-1.7-1.4-2.6-1-2.2-1.5-4.7-1.5-7.7s.4-5.5,1.2-7.7c0,0,0-.2.1-.3.6-1.3,1.3-2.6,2.1-3.8,0,0,0,0,0,0,0,0,0,0,0,0,4.2-6.1,11-10.2,19-10.2h2c1.1,0,1.9.9,1.9,2v9.6c0,.9-.7,1.8-1.6,1.9s-.5,0-.8.1c-1.6.3-3.1.8-4.5,1.5s-2.6,1.5-3.5,2.6c-1,1.1-1.4,2.3-1.4,3.9,0,1.4.7,2.8,1.8,3.7,1.1,1,2.5,1.8,4.2,2.4,1.7.7,3.6,1.2,5.7,1.7,2.1.5,4.1.9,6,1.4,1.5.3,3.2.7,5.2,1,.9.1,1.7.3,2.6.5,1.1.3,2.3.6,3.5.9,2.1.6,4.2,1.4,6.3,2.4,2.1,1,3.9,2.3,5.6,3.9,1.3,1.2,2.4,2.7,3.3,4.4.2.4.5.9.7,1.4,1,2.3,1.5,5.1,1.5,8.4Z"/>
<text class="st2" transform="translate(22.7 104.6)"><tspan x="0" y="0">AZIROS</tspan></text>
<line class="st0" x1="195.8" y1="89.9" x2="180.8" y2="89.9"/>
<line class="st0" x1="229.7" y1="89.9" x2="214.7" y2="89.9"/>
<path class="st4" d="M55.3,13.9h10.6c1.4,0,2.6.8,3.1,2.1l29.2,69.3c.9,2.2-.7,4.7-3.1,4.7H26.2c-2.4,0-4.1-2.5-3.1-4.7l3-7.1v-.2c.6-1.3,1.8-2.1,3.2-2.1h48.7l-17.4-41.2-13.9,33.1c-.5,1.3-1.8,2.1-3.1,2.1h-8.9c-2.4,0-4.1-2.5-3.1-4.7L52.1,16c.5-1.3,1.8-2.1,3.1-2.1Z"/>
<line class="st0" x1="195.8" y1="89.9" x2="180.8" y2="89.9"/>
<line class="st0" x1="229.7" y1="89.9" x2="214.7" y2="89.9"/>
<rect class="st3" x="-158.8" y="86.6" width="52.6" height="96.6"/>
<rect class="st3" x="-101" y="85.4" width="52.6" height="96.6"/>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 491.2 162.3">
<!-- Generator: Adobe Illustrator 30.0.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 123) -->
<defs>
<style>
.st0 {
font-family: Geometos, Geometos;
font-size: 12px;
letter-spacing: .8em;
}
.st1 {
fill: #1d1d1b;
}
</style>
</defs>
<text class="st0" transform="translate(33.8 129.8)"><tspan x="0" y="0">AI PRODUCTIVITY ASSISTANT</tspan></text>
<path class="st1" d="M388,80v-1.4h0c0,1.5,0,2.9.2,4.2,0-.9-.1-1.8-.1-2.7ZM424.8,106.8h-1"/>
<path class="st1" d="M177.7,33.3v8.4c0,.8-.3,1.6-.8,2.2l-.8,1-14.5,16.8c-.6.7-1.6,1.2-2.6,1.2h-10.6c-2.9,0-4.5-3.4-2.6-5.6l10.7-12.3h-37.4c-1.9,0-3.4-1.5-3.4-3.4v-8.2c0-1.9,1.5-3.4,3.4-3.4h55.2c1.9,0,3.4,1.5,3.4,3.4Z"/>
<rect class="st1" x="191.9" y="30" width="15" height="26" rx="3.4" ry="3.4"/>
<rect class="st1" x="191.9" y="60.9" width="15" height="45.1" rx="3.4" ry="3.4"/>
<path class="st1" d="M287.4,106c2.7,0,4.3-3,2.9-5.3l-13.3-20.9-1.7-2.8c5.6-2.3,10.1-6.8,12.4-12.3,1.1-2.7,1.7-5.7,1.7-8.8v-3.2c0-2.8-.5-5.5-1.4-8-3.2-8.7-11.5-14.8-21.2-14.8h-37.5c-1.9,0-3.4,1.5-3.4,3.4v7.9c0,1.9,1.5,3.4,3.4,3.4h36.4c.3,0,.6,0,.9,0,4.3.5,7.6,4.1,7.6,8.5v2.9c0,4.7-3.8,8.6-8.5,8.6h-36.4c-1.9,0-3.4,1.5-3.4,3.4v34.5c0,1.9,1.5,3.4,3.4,3.4h8.2c1.9,0,3.4-1.5,3.4-3.4v-23.9h17.7l.7,1.1,15.5,24.6c.6,1,1.7,1.6,2.9,1.6h9.9Z"/>
<path class="st1" d="M177.7,95.4v7.2c0,1.9-1.5,3.4-3.4,3.4h-55.2c-1.9,0-3.4-1.5-3.4-3.4v-7.7c0-.8.3-1.6.8-2.2l.6-.7,14.9-17.1c.6-.7,1.6-1.2,2.6-1.2h10.6c2.9,0,4.5,3.4,2.6,5.6l-11.1,12.6h37.6c1.9,0,3.4,1.5,3.4,3.4Z"/>
<path class="st1" d="M66.4,29.9h10.6c1.4,0,2.6.8,3.1,2.1l29.2,69.3c.9,2.2-.7,4.7-3.1,4.7H37.3c-2.4,0-4.1-2.5-3.1-4.7l3-7.1v-.2c.6-1.3,1.8-2.1,3.2-2.1h48.7l-17.4-41.2-13.9,33.1c-.5,1.3-1.8,2.1-3.1,2.1h-8.9c-2.4,0-4.1-2.5-3.1-4.7l20.7-49.2c.5-1.3,1.8-2.1,3.1-2.1Z"/>
<path class="st1" d="M335.7,33.3v8.3c0,1.5-1,2.9-2.5,3.2-10.6,2.7-18.4,12-18.4,23.2s7.8,20.5,18.4,23.2c1.5.4,2.5,1.7,2.5,3.2v8.2c0,2.1-2,3.7-4.1,3.3-18.2-3.5-31.9-19.2-31.9-38s13.7-34.5,31.9-38,4.1,1.2,4.1,3.3Z"/>
<path class="st1" d="M378.9,67.9c0,18.9-13.9,34.7-32.3,38-2.1.4-4-1.2-4-3.3v-8.2c0-1.5,1-2.9,2.5-3.3,10.8-2.5,18.7-12,18.7-23.3s-8-20.7-18.7-23.3-2.5-1.7-2.5-3.3v-8.2c0-2.1,1.9-3.7,4-3.3,18.4,3.3,32.3,19.1,32.3,38Z"/>
<path class="st1" d="M443.1,37.8c2.3,2.3,4.2,5,5.5,8,0,0,0,.1,0,.2.9,2.2,1.5,4.6,1.8,7.4s-.8,2.1-2,2.1h-10.6c-1,0-1.9-.8-2-1.8-.3-3.3-1.3-4.6-3.2-6.5s-5.1-3.2-7.4-3.6-1.6-1-1.6-1.9v-10.1c0-.9.8-1.7,1.7-1.7h0c7.4,0,12.6,2.9,17.5,7.7"/>
<path class="st1" d="M416.8,94.3v9.8c0,1-.9,1.9-1.9,1.9h0c-8.5,0-16-3.9-20.9-9.9,0,0,0,0,0,0-2.4-3-4.2-6.5-5.1-10.3,0-.3-.1-.5-.2-.8,0-.4-.2-.9-.2-1.4,0-.4-.1-.7-.1-1.1,0,0,0-.1,0-.2,0-.9-.1-1.4-.2-2v-.2c0-1.1.9-1.9,2-1.9h10.1c1,0,1.9.8,2,1.8.5,5.1,2.8,8.5,6.6,10.3,1.9.9,4.4,1.6,6.6,2s1.6,1,1.6,1.9Z"/>
<path class="st1" d="M451.2,83.5c0,1.3,0,2.5-.2,3.6-.9,5-3.4,9.4-7,12.7-4.1,3.8-9.7,6.1-15.8,6.1h-2.5c-1.1,0-2-.9-2-2v-9.3c0-1,.7-1.8,1.7-1.9s2-.4,2.8-.6c1.8-.6,3.3-1.3,4.6-2.2s2.2-1.9,2.8-3.1c.6-1.2.9-2.3.9-3.5s-.5-2.7-1.4-3.8c-.9-1-2.2-1.9-3.7-2.6s-3.4-1.2-5.5-1.7c-2.1-.5-4.3-.9-6.6-1.3-.6,0-1.2-.2-1.8-.3-1.1-.2-2.2-.4-3.5-.6-2-.4-4.1-.9-6.2-1.5-2.1-.6-4.3-1.5-6.3-2.5-2.1-1-4-2.3-5.6-3.9-1-.9-1.8-1.9-2.6-3.1-.5-.8-1-1.7-1.4-2.6-1-2.2-1.5-4.7-1.5-7.7s.4-5.5,1.2-7.7c0,0,0-.2.1-.3.6-1.3,1.3-2.6,2.1-3.8,0,0,0,0,0,0,0,0,0,0,0,0,4.2-6.1,11-10.2,19-10.2h2c1.1,0,1.9.9,1.9,2v9.6c0,.9-.7,1.8-1.6,1.9s-.5,0-.8.1c-1.6.3-3.1.8-4.5,1.5s-2.6,1.5-3.5,2.6c-1,1.1-1.4,2.3-1.4,3.9,0,1.4.7,2.8,1.8,3.7,1.1,1,2.5,1.8,4.2,2.4,1.7.7,3.6,1.2,5.7,1.7,2.1.5,4.1.9,6,1.4,1.5.3,3.2.7,5.2,1,.9.1,1.7.3,2.6.5,1.1.3,2.3.6,3.5.9,2.1.6,4.2,1.4,6.3,2.4,2.1,1,3.9,2.3,5.6,3.9,1.3,1.2,2.4,2.7,3.3,4.4.2.4.5.9.7,1.4,1,2.3,1.5,5.1,1.5,8.4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -13,6 +13,10 @@ document.addEventListener('alpine:init', () => {
// ── Focus (Einzelklick → Vordergrund) ────────────────────────
focusedEventId: null,
// ── Hover-Highlight ──────────────────────────────────────────
hoverCol: null,
hoverSlot: null,
// ── Timed-Event DnD (Week / Day) ─────────────────────────────
draggingId: null,
dragOffsetY: 0,
@ -53,6 +57,25 @@ document.addEventListener('alpine:init', () => {
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
},
// ═══════════════════════════════════════════════════════════════
// HOVER HIGHLIGHT (Week + Day timeline)
// ═══════════════════════════════════════════════════════════════
onTimeHover(e, col) {
if (this.draggingId) return;
const rect = e.currentTarget.getBoundingClientRect();
const relY = Math.max(0, e.clientY - rect.top);
this.hoverSlot = Math.floor(relY / SLOT_PX);
this.hoverCol = col;
},
onTimeLeave(e, col) {
if (this.hoverCol === col) {
this.hoverCol = null;
this.hoverSlot = null;
}
},
// ═══════════════════════════════════════════════════════════════
// CREATE ON DOUBLE-CLICK (Week + Day timeline)
// ═══════════════════════════════════════════════════════════════

View File

@ -1,67 +1,10 @@
@props([
'mode' => 'dark',
])
<svg {{ $attributes }} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 653 171" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="ArtBoard1" transform="matrix(1,0,0,1.03731,-3508.25,-6783.65)">
<rect x="3508.25" y="6539.63" width="652.055" height="163.896" style="fill:none;"/>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M96,13.651C96,6.117 89.883,0 82.349,0L13.651,0C6.117,0 0,6.117 0,13.651L0,82.349C0,89.883 6.117,96 13.651,96L82.349,96C89.883,96 96,89.883 96,82.349L96,13.651Z" style="fill:rgb(15,17,23);"/>
</g>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M66,26.343C66,23.946 64.054,22 61.657,22L18.343,22C15.946,22 14,23.946 14,26.343L14,31.657C14,34.054 15.946,36 18.343,36L61.657,36C64.054,36 66,34.054 66,31.657L66,26.343Z" style="fill:rgb(79,70,229);"/>
</g>
{{-- <g transform="matrix(2.78458,0,0,1.55366,3670.41,6533.52)">--}}
{{-- <path class="{{ $mode }}" d="M66,26.343C66,23.946 64.874,22 63.486,22L16.514,22C15.126,22 14,23.946 14,26.343L14,31.657C14,34.054 15.126,36 16.514,36L63.486,36C64.874,36 66,34.054 66,31.657L66,26.343Z" style="fill:rgb(79,70,229);"/>--}}
{{-- </g>--}}
{{-- <g transform="matrix(0.376057,0,0,1.55366,3862.65,6533.52)">--}}
{{-- <path class="{{ $mode }}" d="M66,26.343C66,23.946 57.659,22 47.386,22L32.614,22C22.341,22 14,23.946 14,26.343L14,31.657C14,34.054 22.341,36 32.614,36L47.386,36C57.659,36 66,34.054 66,31.657L66,26.343Z" style="fill:rgb(79,70,229);"/>--}}
{{-- </g>--}}
{{-- <g transform="matrix(4.66388,0,0,1.55366,3840.19,6533.52)">--}}
{{-- <path class="{{ $mode }}" d="M66,26.343C66,23.946 65.327,22 64.499,22L15.501,22C14.673,22 14,23.946 14,26.343L14,31.657C14,34.054 14.673,36 15.501,36L64.499,36C65.327,36 66,34.054 66,31.657L66,26.343Z" style="fill:rgb(79,70,229);"/>--}}
{{-- </g>--}}
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M52,46.343C52,43.946 50.054,42 47.657,42L18.343,42C15.946,42 14,43.946 14,46.343L14,51.657C14,54.054 15.946,56 18.343,56L47.657,56C50.054,56 52,54.054 52,51.657L52,46.343Z" style="fill:white;fill-opacity:0.5;"/>
</g>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M58,66.343C58,63.946 56.054,62 53.657,62L18.343,62C15.946,62 14,63.946 14,66.343L14,71.657C14,74.054 15.946,76 18.343,76L53.657,76C56.054,76 58,74.054 58,71.657L58,66.343Z" style="fill:white;fill-opacity:0.25;"/>
</g>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<circle cx="80" cy="49" r="14" style="fill:rgb(79,70,229);"/>
</g>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M78.5,44.551C78.5,43.695 77.805,43 76.949,43L75.051,43C74.195,43 73.5,43.695 73.5,44.551L73.5,53.449C73.5,54.305 74.195,55 75.051,55L76.949,55C77.805,55 78.5,54.305 78.5,53.449L78.5,44.551Z" style="fill:white;"/>
</g>
<g transform="matrix(1.61164,0,0,1.55366,3520.22,6546.13)">
<path d="M85.5,44.551C85.5,43.695 84.805,43 83.949,43L75.051,43C74.195,43 73.5,43.695 73.5,44.551L73.5,46.449C73.5,47.305 74.195,48 75.051,48L83.949,48C84.805,48 85.5,47.305 85.5,46.449L85.5,44.551Z" style="fill:white;"/>
</g>
<g transform="matrix(3.68501,0,0,3.55245,3703.79,6668.63)">
<path d="M15.419,-2.394C14.194,-1.441 13.01,-0.727 11.866,-0.25C10.722,0.226 9.438,0.464 8.016,0.464C6.717,0.464 5.576,0.207 4.592,-0.306C3.609,-0.819 2.851,-1.515 2.319,-2.394C1.787,-3.272 1.521,-4.224 1.521,-5.251C1.521,-6.636 1.961,-7.818 2.839,-8.795C3.717,-9.772 4.923,-10.428 6.457,-10.762C6.779,-10.836 7.576,-11.003 8.851,-11.263C10.125,-11.522 11.216,-11.761 12.125,-11.977C13.035,-12.194 14.021,-12.456 15.085,-12.766C15.023,-14.102 14.754,-15.082 14.278,-15.707C13.802,-16.331 12.815,-16.644 11.318,-16.644C10.032,-16.644 9.064,-16.464 8.415,-16.105C7.765,-15.747 7.208,-15.209 6.745,-14.491C6.281,-13.774 5.953,-13.301 5.761,-13.072C5.569,-12.843 5.158,-12.729 4.527,-12.729C3.958,-12.729 3.467,-12.911 3.052,-13.276C2.638,-13.641 2.431,-14.108 2.431,-14.677C2.431,-15.567 2.746,-16.433 3.377,-17.274C4.008,-18.116 4.991,-18.808 6.327,-19.353C7.663,-19.897 9.327,-20.169 11.318,-20.169C13.545,-20.169 15.295,-19.906 16.569,-19.38C17.843,-18.855 18.743,-18.023 19.269,-16.885C19.795,-15.747 20.058,-14.238 20.058,-12.357C20.058,-11.17 20.055,-10.162 20.048,-9.333C20.042,-8.504 20.033,-7.583 20.021,-6.568C20.021,-5.616 20.178,-4.623 20.494,-3.59C20.809,-2.557 20.967,-1.893 20.967,-1.596C20.967,-1.076 20.722,-0.603 20.234,-0.176C19.745,0.25 19.192,0.464 18.573,0.464C18.054,0.464 17.54,0.22 17.033,-0.269C16.526,-0.758 15.988,-1.466 15.419,-2.394ZM15.085,-9.723C14.343,-9.451 13.264,-9.163 11.847,-8.86C10.431,-8.557 9.451,-8.334 8.906,-8.192C8.362,-8.05 7.842,-7.771 7.348,-7.357C6.853,-6.943 6.605,-6.364 6.605,-5.622C6.605,-4.855 6.896,-4.203 7.478,-3.665C8.059,-3.126 8.82,-2.857 9.76,-2.857C10.762,-2.857 11.686,-3.077 12.534,-3.516C13.381,-3.955 14.003,-4.521 14.398,-5.214C14.856,-5.981 15.085,-7.243 15.085,-8.999L15.085,-9.723Z" style="fill:rgb(26,26,46);fill-rule:nonzero;"/>
<path d="M38.775,-14.324L29.497,-3.952L39.424,-3.952C40.228,-3.952 40.834,-3.764 41.243,-3.386C41.651,-3.009 41.855,-2.523 41.855,-1.93C41.855,-1.361 41.654,-0.897 41.252,-0.538C40.85,-0.179 40.241,0 39.424,0L25.694,0C24.729,0 24.008,-0.21 23.532,-0.631C23.056,-1.051 22.818,-1.627 22.818,-2.356C22.818,-2.789 22.985,-3.225 23.319,-3.665C23.653,-4.104 24.345,-4.911 25.397,-6.086C26.51,-7.323 27.521,-8.442 28.431,-9.444C29.34,-10.446 30.184,-11.383 30.963,-12.255C31.743,-13.127 32.389,-13.867 32.902,-14.473C33.416,-15.079 33.827,-15.598 34.136,-16.031L26.603,-16.031C25.564,-16.031 24.778,-16.124 24.247,-16.31C23.715,-16.495 23.449,-16.984 23.449,-17.775C23.449,-18.357 23.65,-18.821 24.052,-19.167C24.454,-19.513 25.026,-19.687 25.768,-19.687L37.402,-19.687C38.478,-19.687 39.304,-19.529 39.879,-19.213C40.454,-18.898 40.742,-18.332 40.742,-17.516C40.742,-17.243 40.686,-16.962 40.575,-16.671C40.463,-16.381 40.34,-16.143 40.204,-15.957C40.067,-15.771 39.882,-15.546 39.647,-15.28C39.412,-15.014 39.121,-14.695 38.775,-14.324Z" style="fill:rgb(26,26,46);fill-rule:nonzero;"/>
<path class="{{ $mode }}" d="M49.681,-17.256L49.681,-2.616C49.681,-1.602 49.439,-0.835 48.957,-0.315C48.474,0.204 47.862,0.464 47.12,0.464C46.378,0.464 45.775,0.198 45.311,-0.334C44.847,-0.866 44.615,-1.627 44.615,-2.616L44.615,-17.107C44.615,-18.109 44.847,-18.864 45.311,-19.371C45.775,-19.878 46.378,-20.132 47.12,-20.132C47.862,-20.132 48.474,-19.878 48.957,-19.371C49.439,-18.864 49.681,-18.159 49.681,-17.256ZM47.176,-22.488C46.471,-22.488 45.868,-22.705 45.367,-23.138C44.866,-23.571 44.615,-24.183 44.615,-24.975C44.615,-25.692 44.872,-26.283 45.385,-26.747C45.898,-27.21 46.495,-27.442 47.176,-27.442C47.831,-27.442 48.413,-27.232 48.92,-26.812C49.427,-26.391 49.681,-25.779 49.681,-24.975C49.681,-24.195 49.433,-23.586 48.938,-23.147C48.444,-22.708 47.856,-22.488 47.176,-22.488Z" style="fill:rgb(79,70,229);fill-rule:nonzero;"/>
<path class="{{ $mode }}" d="M59.751,-6.847L59.751,-2.616C59.751,-1.59 59.51,-0.819 59.028,-0.306C58.545,0.207 57.933,0.464 57.191,0.464C56.461,0.464 55.861,0.204 55.391,-0.315C54.921,-0.835 54.686,-1.602 54.686,-2.616L54.686,-16.718C54.686,-18.994 55.508,-20.132 57.154,-20.132C57.995,-20.132 58.601,-19.866 58.972,-19.334C59.343,-18.802 59.547,-18.017 59.584,-16.978C60.19,-18.017 60.812,-18.802 61.449,-19.334C62.086,-19.866 62.937,-20.132 64,-20.132C65.064,-20.132 66.097,-19.866 67.099,-19.334C68.101,-18.802 68.602,-18.097 68.602,-17.219C68.602,-16.6 68.388,-16.09 67.962,-15.688C67.535,-15.286 67.074,-15.085 66.579,-15.085C66.394,-15.085 65.945,-15.199 65.234,-15.428C64.523,-15.657 63.895,-15.771 63.351,-15.771C62.609,-15.771 62.003,-15.577 61.533,-15.187C61.062,-14.797 60.698,-14.219 60.438,-13.452C60.178,-12.685 59.999,-11.773 59.9,-10.715C59.801,-9.658 59.751,-8.368 59.751,-6.847Z" style="fill:rgb(79,70,229);fill-rule:nonzero;"/>
<path class="{{ $mode }}" d="M88.692,-9.834C88.692,-8.325 88.457,-6.933 87.987,-5.659C87.517,-4.385 86.837,-3.29 85.946,-2.375C85.055,-1.46 83.992,-0.758 82.755,-0.269C81.518,0.22 80.126,0.464 78.58,0.464C77.046,0.464 75.667,0.216 74.442,-0.278C73.218,-0.773 72.157,-1.481 71.26,-2.403C70.363,-3.324 69.683,-4.413 69.219,-5.668C68.755,-6.924 68.523,-8.313 68.523,-9.834C68.523,-11.368 68.758,-12.772 69.228,-14.046C69.698,-15.32 70.372,-16.409 71.251,-17.312C72.129,-18.215 73.193,-18.91 74.442,-19.399C75.691,-19.888 77.071,-20.132 78.58,-20.132C80.114,-20.132 81.505,-19.884 82.755,-19.39C84.004,-18.895 85.074,-18.19 85.965,-17.274C86.855,-16.359 87.532,-15.271 87.996,-14.009C88.46,-12.747 88.692,-11.355 88.692,-9.834ZM83.608,-9.834C83.608,-11.9 83.154,-13.508 82.244,-14.658C81.335,-15.809 80.114,-16.384 78.58,-16.384C77.59,-16.384 76.718,-16.127 75.964,-15.614C75.209,-15.1 74.628,-14.343 74.219,-13.341C73.811,-12.339 73.607,-11.17 73.607,-9.834C73.607,-8.51 73.808,-7.354 74.21,-6.364C74.612,-5.375 75.187,-4.617 75.936,-4.091C76.684,-3.566 77.566,-3.303 78.58,-3.303C80.114,-3.303 81.335,-3.881 82.244,-5.038C83.154,-6.194 83.608,-7.793 83.608,-9.834Z" style="fill:rgb(79,70,229);fill-rule:nonzero;"/>
<path class="{{ $mode }}" d="M108.801,-6.16C108.801,-4.762 108.461,-3.566 107.78,-2.57C107.1,-1.574 106.095,-0.819 104.765,-0.306C103.436,0.207 101.818,0.464 99.913,0.464C98.095,0.464 96.536,0.186 95.238,-0.371C93.939,-0.928 92.98,-1.624 92.362,-2.458C91.743,-3.293 91.434,-4.132 91.434,-4.973C91.434,-5.529 91.632,-6.006 92.028,-6.401C92.423,-6.797 92.924,-6.995 93.53,-6.995C94.062,-6.995 94.471,-6.865 94.755,-6.605C95.04,-6.346 95.312,-5.981 95.571,-5.511C96.091,-4.608 96.713,-3.934 97.436,-3.488C98.16,-3.043 99.146,-2.82 100.396,-2.82C101.41,-2.82 102.242,-3.046 102.891,-3.498C103.541,-3.949 103.865,-4.465 103.865,-5.047C103.865,-5.938 103.528,-6.587 102.854,-6.995C102.18,-7.403 101.07,-7.793 99.524,-8.164C97.779,-8.597 96.36,-9.052 95.265,-9.528C94.171,-10.004 93.295,-10.632 92.64,-11.411C91.984,-12.19 91.656,-13.149 91.656,-14.287C91.656,-15.301 91.96,-16.26 92.566,-17.163C93.172,-18.066 94.065,-18.787 95.247,-19.325C96.428,-19.863 97.854,-20.132 99.524,-20.132C100.835,-20.132 102.013,-19.996 103.058,-19.724C104.104,-19.451 104.976,-19.087 105.675,-18.629C106.373,-18.171 106.905,-17.664 107.27,-17.107C107.635,-16.551 107.818,-16.007 107.818,-15.475C107.818,-14.893 107.623,-14.417 107.233,-14.046C106.843,-13.675 106.29,-13.489 105.572,-13.489C105.053,-13.489 104.611,-13.638 104.246,-13.935C103.881,-14.231 103.463,-14.677 102.993,-15.271C102.61,-15.765 102.158,-16.161 101.639,-16.458C101.119,-16.755 100.414,-16.903 99.524,-16.903C98.608,-16.903 97.848,-16.708 97.241,-16.319C96.635,-15.929 96.332,-15.444 96.332,-14.862C96.332,-14.33 96.555,-13.894 97,-13.554C97.446,-13.214 98.045,-12.933 98.8,-12.71C99.555,-12.487 100.594,-12.215 101.917,-11.894C103.488,-11.51 104.772,-11.052 105.767,-10.521C106.763,-9.989 107.518,-9.361 108.031,-8.637C108.544,-7.914 108.801,-7.088 108.801,-6.16Z" style="fill:rgb(79,70,229);fill-rule:nonzero;"/>
</g>
<g transform="matrix(3.68501,0,0,3.55245,3142,6474.89)">
<circle class="{{ $mode }}" cx="268" cy="50" r="5" style="fill:rgb(79,70,229);"/>
</g>
</g>
</svg>
{{--<svg {{ $attributes }} width="240" height="52" viewBox="0 0 240 52" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <circle cx="26" cy="26" r="18" fill="none" stroke="#4F46E5" stroke-width="2.2"/>--}}
{{-- <rect x="17" y="17" width="18" height="18" rx="2.5" fill="#4F46E5" transform="rotate(45 26 26)"/>--}}
{{-- <circle cx="26" cy="26" r="5" fill="#f4f4f8"/>--}}
{{-- <path d="M60 35 Q60 21 71 21 Q82 21 82 32 L82 35" fill="none" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>--}}
{{-- <line x1="82" y1="26" x2="82" y2="35" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <line x1="89" y1="21" x2="104" y2="21" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <line x1="104" y1="21" x2="89" y2="35" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <line x1="89" y1="35" x2="104" y2="35" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <line x1="112" y1="21" x2="112" y2="35" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <circle cx="112" cy="16" r="2.5" fill="#4F46E5"/>--}}
{{-- <line x1="120" y1="21" x2="120" y2="35" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <path d="M120 26 Q127 21 135 23" fill="none" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{-- <ellipse cx="149" cy="28" rx="9.5" ry="8" fill="none" stroke="#1a1a2e" stroke-width="2.2"/>--}}
{{-- <path d="M169 23 Q169 21 175 21 Q182 21 182 26 Q182 31 175 28 Q168 25 168 30 Q168 35 175 35 Q182 35 182 33" fill="none" stroke="#1a1a2e" stroke-width="2.2" stroke-linecap="round"/>--}}
{{--</svg>--}}
@php $white = str_contains($mode, 'white'); @endphp
<img
{{ $attributes }}
src="/images/logo.svg"
alt="{{ config('app.name') }}"
@if($white) style="filter: brightness(0) invert(1);" @endif
>

View File

@ -2,27 +2,33 @@
@section('content')
<div style="text-align:center;margin-bottom:20px;">
<x-heroicon-o-gift width="65"
style="opacity:0.8;stroke:#4f46e5;stroke-width:1" />
</div>
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-star style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
<h2 style="margin:0 0 10px;font-size:22px;color:#111;text-align:center;">
Du hast {{ $credits }} Credits verdient!
</h2>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Dein Referral hat sich qualifiziert!
</h1>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
<strong style="color:#374151;">{{ $referredUser->name }}</strong> nutzt Aziros jetzt seit 3 Monaten.
</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:14px;text-align:center;line-height:1.6;">
<strong>{{ $referredUser->name }}</strong> nutzt Aziros jetzt seit 3 Monaten
dein Referral hat sich qualifiziert!
</p>
{{-- Credits badge --}}
<div style="background:#EEF2FF;border:1px solid #C7D2FE;border-radius:12px;padding:22px 20px;margin-bottom:28px;text-align:center;">
<p style="margin:0 0 4px;font-size:11px;color:#818CF8;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">Gutschrift</p>
<p style="margin:0 0 4px;font-size:36px;font-weight:800;color:#4F46E5;line-height:1;letter-spacing:-1px;">+{{ $credits }}</p>
<p style="margin:0;font-size:13px;color:#6B7280;">Credits wurden deinem Konto gutgeschrieben</p>
</div>
<div style="background:#EEF2FF;border-radius:12px;padding:20px;margin:24px 0;text-align:center;">
<div style="font-size:36px;font-weight:700;color:#4F46E5;">+{{ $credits }}</div>
<div style="font-size:14px;color:#6B7280;">Credits wurden deinem Konto gutgeschrieben</div>
</div>
<x-mail.button url="{{ config('app.url') }}/settings?tab=affiliate">
Mein Affiliate-Dashboard
</x-mail.button>
{{-- CTA --}}
<x-mail.button :url="config('app.url') . '/settings?tab=affiliate'">
Mein Affiliate-Dashboard
</x-mail.button>
@endsection

View File

@ -2,25 +2,31 @@
@section('content')
<div style="text-align:center;margin-bottom:20px;">
<x-heroicon-o-chat-bubble-left-right width="55"
style="opacity:0.8;stroke:#4f46e5;stroke-width:1" />
</div>
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-chat-bubble-left-right style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
<h2 style="margin:0 0 6px;font-size:22px;color:#111;text-align:center;">
Nachricht
</h2>
{{-- Title --}}
<h1 style="margin:0 0 4px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Neue Nachricht
</h1>
<p style="margin:0 0 28px;font-size:13px;color:#9CA3AF;text-align:center;">
von {{ $sender_name }}
</p>
<p style="margin:0 0 24px;color:#9ca3af;font-size:13px;text-align:center;">
von {{ $sender_name }}
</p>
{{-- Recipient label --}}
<p style="margin:0 0 8px;font-size:11px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">
Nachricht an {{ $recipient_name }}
</p>
<p style="margin:0 0 6px;font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">
Nachricht an {{ $recipient_name }}
</p>
<div style="background:#f8fafc;border-radius:12px;padding:20px;margin-bottom:20px;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.6;">{{ $message }}</p>
</div>
{{-- Message box --}}
<div style="background:#F9FAFB;border:1px solid #F3F4F6;border-radius:12px;padding:20px;margin-bottom:0;">
<p style="margin:0;font-size:14px;color:#374151;line-height:1.7;">{{ $message }}</p>
</div>
@endsection

View File

@ -2,55 +2,70 @@
@section('content')
<div style="text-align:center;margin-bottom:20px;">
<x-heroicon-o-calendar-days width="55"
style="opacity:0.8;stroke:#4f46e5;stroke-width:1" />
</div>
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-calendar-days style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
<h2 style="margin:0 0 6px;font-size:22px;color:#111;text-align:center;">
Terminerinnerung
</h2>
{{-- Title --}}
<h1 style="margin:0 0 4px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Terminerinnerung
</h1>
<p style="margin:0 0 28px;font-size:13px;color:#9CA3AF;text-align:center;">
von {{ $sender_name }}
</p>
<p style="margin:0 0 24px;color:#9ca3af;font-size:13px;text-align:center;">
von {{ $sender_name }}
</p>
{{-- Event Details --}}
<div style="background:#f8fafc;border-radius:12px;padding:20px;margin-bottom:20px;">
<table width="100%" style="font-size:14px;color:#374151;">
{{-- Event card --}}
<div style="background:#F9FAFB;border:1px solid #F3F4F6;border-radius:12px;padding:20px 20px 16px;margin-bottom:20px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding:5px 0 5px;width:72px;font-size:12px;color:#9CA3AF;vertical-align:top;">Termin</td>
<td style="padding:5px 0 5px;font-size:14px;color:#111827;font-weight:600;">{{ $event_title }}</td>
</tr>
<tr>
<td style="padding:5px 0;height:1px;" colspan="2">
<div style="height:1px;background:#F3F4F6;"></div>
</td>
</tr>
<tr>
<td style="padding:5px 0;font-size:12px;color:#9CA3AF;vertical-align:top;">Datum</td>
<td style="padding:5px 0;font-size:13px;color:#374151;">{{ $event_date }}</td>
</tr>
<tr>
<td style="padding:5px 0;font-size:12px;color:#9CA3AF;vertical-align:top;">Uhrzeit</td>
<td style="padding:5px 0;font-size:13px;color:#374151;">
{{ $event_time }}@if(!empty($event_end)) &ndash; {{ $event_end }}@endif Uhr
</td>
</tr>
@if(!empty($event_notes))
<tr>
<td style="padding:6px 0;color:#9ca3af;width:80px;">Termin</td>
<td style="padding:6px 0;font-weight:600;">{{ $event_title }}</td>
</tr>
<tr>
<td style="padding:6px 0;color:#9ca3af;">Datum</td>
<td style="padding:6px 0;">{{ $event_date }}</td>
</tr>
<tr>
<td style="padding:6px 0;color:#9ca3af;">Uhrzeit</td>
<td style="padding:6px 0;">
{{ $event_time }}@if(!empty($event_end)) - {{ $event_end }}@endif Uhr
<td style="padding:5px 0;height:1px;" colspan="2">
<div style="height:1px;background:#F3F4F6;"></div>
</td>
</tr>
@if(!empty($event_notes))
<tr>
<td style="padding:6px 0;color:#9ca3af;vertical-align:top;">Info</td>
<td style="padding:6px 0;">{{ $event_notes }}</td>
</tr>
@endif
</table>
<tr>
<td style="padding:5px 0;font-size:12px;color:#9CA3AF;vertical-align:top;">Info</td>
<td style="padding:5px 0;font-size:13px;color:#374151;line-height:1.5;">{{ $event_notes }}</td>
</tr>
@endif
</table>
</div>
{{-- Personal message --}}
@if(!empty($message))
<div style="border-left:3px solid #4F46E5;padding:12px 16px;background:#F9FAFB;border-radius:0 10px 10px 0;margin-bottom:24px;">
<p style="margin:0 0 3px;font-size:10px;color:#9CA3AF;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">Nachricht</p>
<p style="margin:0;font-size:13px;color:#374151;line-height:1.6;">{{ $message }}</p>
</div>
@endif
{{-- Persönliche Nachricht --}}
@if(!empty($message))
<div style="border-left:3px solid #4f46e5;padding:12px 16px;margin-bottom:20px;background:#f8fafc;border-radius:0 8px 8px 0;">
<p style="margin:0 0 4px;font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Nachricht</p>
<p style="margin:0;font-size:14px;color:#374151;line-height:1.5;">{{ $message }}</p>
</div>
@endif
<p style="text-align:center;font-size:12px;color:#d1d5db;margin-top:24px;">
Hallo {{ $recipient_name }}, dies ist eine automatische Terminerinnerung.
</p>
{{-- Recipient note --}}
<p style="margin:0;text-align:center;font-size:11px;color:#D1D5DB;line-height:1.6;">
Hallo {{ $recipient_name }} &mdash; dies ist eine automatische Terminerinnerung.
</p>
@endsection

View File

@ -1,3 +1,26 @@
<p>{!! nl2br(e($body)) !!}</p>
<hr style="margin: 24px 0; border: none; border-top: 1px solid #eee;">
<p style="font-size: 12px; color: #999;">Gesendet von Aria · aziros.com</p>
@extends('emails.layout')
@section('content')
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-sparkles style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
{{-- Message body --}}
<div style="font-size:14px;color:#374151;line-height:1.7;">
{!! nl2br(e($body)) !!}
</div>
{{-- Sent by footer --}}
<div style="margin-top:24px;padding-top:18px;border-top:1px solid #F3F4F6;">
<p style="margin:0;font-size:11px;color:#D1D5DB;text-align:center;letter-spacing:0.2px;">
Gesendet von <strong style="color:#9CA3AF;">Aria</strong> &middot; aziros.com
</p>
</div>
@endsection

View File

@ -2,28 +2,33 @@
@section('content')
<div style="text-align:center;margin-bottom:20px;">
<x-heroicon-o-envelope width="65"
style="opacity:0.8;stroke:#4f46e5;stroke-width:1" />
</div>
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-envelope style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
<h2 style="margin:0 0 10px;font-size:22px;color:#111;text-align:center;">
{{ t('mail.auth.verify.title') }}
</h2>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
{{ t('mail.auth.verify.title') }}
</h1>
<p style="margin:0 0 28px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
{{ t('mail.auth.verify.text') }}
</p>
<p style="margin:0 0 20px;color:#6b7280;font-size:14px;text-align:center;">
{{ t('mail.auth.verify.text') }}
</p>
{{-- Button --}}
@if(!empty($url))
<x-mail.button :url="$url">
{{ t('mail.auth.verify.button') }}
</x-mail.button>
@endif
{{-- BUTTON --}}
@if(!empty($url))
<x-mail.button :url="$url">
{{ t('mail.auth.verify.button') }}
</x-mail.button>
@endif
<p style="text-align:center;font-size:13px;color:#9ca3af;">
{{ t('mail.auth.verify.expires') }}
</p>
{{-- Expiry --}}
<p style="margin:20px 0 0;text-align:center;font-size:12px;color:#9CA3AF;line-height:1.6;">
{{ t('mail.auth.verify.expires') }}
</p>
@endsection

View File

@ -0,0 +1,86 @@
@extends('emails.layout')
@section('content')
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-hand-raised style="width:24px;height:24px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Willkommen bei Aziros, {{ $user->name }}!
</h1>
<p style="margin:0 0 28px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Dein Konto ist bestätigt. Hier ist, was du als Nächstes tun kannst.
</p>
{{-- Feature list --}}
<div style="background:#F9FAFB;border:1px solid #F3F4F6;border-radius:12px;padding:20px 20px 16px;margin-bottom:28px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding:8px 0;">
<table cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="width:28px;vertical-align:top;padding-top:1px;">
<div style="width:20px;height:20px;background:#EEF2FF;border-radius:6px;text-align:center;line-height:20px;">
<x-heroicon-o-calendar-days style="width:11px;height:11px;stroke:#4F46E5;stroke-width:2;display:inline-block;vertical-align:middle;" />
</div>
</td>
<td style="padding-left:10px;">
<p style="margin:0 0 2px;font-size:13px;font-weight:600;color:#111827;">Kalender</p>
<p style="margin:0;font-size:12px;color:#6B7280;">Termine planen, organisieren und im Blick behalten.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr><td style="height:1px;background:#F3F4F6;padding:0;"></td></tr>
<tr>
<td style="padding:8px 0;">
<table cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="width:28px;vertical-align:top;padding-top:1px;">
<div style="width:20px;height:20px;background:#EEF2FF;border-radius:6px;text-align:center;line-height:20px;">
<x-heroicon-o-sparkles style="width:11px;height:11px;stroke:#4F46E5;stroke-width:2;display:inline-block;vertical-align:middle;" />
</div>
</td>
<td style="padding-left:10px;">
<p style="margin:0 0 2px;font-size:13px;font-weight:600;color:#111827;">Aria Dein KI-Assistent</p>
<p style="margin:0;font-size:12px;color:#6B7280;">Frag Aria alles sie hilft dir, deinen Tag zu organisieren.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr><td style="height:1px;background:#F3F4F6;padding:0;"></td></tr>
<tr>
<td style="padding:8px 0;">
<table cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="width:28px;vertical-align:top;padding-top:1px;">
<div style="width:20px;height:20px;background:#EEF2FF;border-radius:6px;text-align:center;line-height:20px;">
<x-heroicon-o-arrows-right-left style="width:11px;height:11px;stroke:#4F46E5;stroke-width:2;display:inline-block;vertical-align:middle;" />
</div>
</td>
<td style="padding-left:10px;">
<p style="margin:0 0 2px;font-size:13px;font-weight:600;color:#111827;">Integrationen</p>
<p style="margin:0;font-size:12px;color:#6B7280;">Google Kalender & Outlook verbinden und synchronisieren.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
{{-- CTA --}}
<x-mail.button :url="config('app.url')">
Jetzt loslegen
</x-mail.button>
@endsection

View File

@ -1,14 +1,6 @@
<div style="text-align:center;margin-top:10px;">
<p style="margin:28px 0 0;text-align:center;">
<a href="{{ $url }}"
style="
display:inline-block;
padding:12px 20px;
background:#4f46e5;
color:#fff;
border-radius:8px;
text-decoration:none;
font-size:14px;
">
style="display:inline-block;background:#4F46E5;color:#ffffff;padding:14px 32px;border-radius:10px;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.1px;line-height:1;">
{{ $slot }}
</a>
</div>
</p>

View File

@ -1,25 +1,13 @@
<table align="center" cellpadding="0" cellspacing="0" style="margin:25px auto;">
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:28px auto;">
<tr>
@foreach(str_split($slot) as $index => $digit)
<td align="center" style="
width:44px;
height:52px;
font-size:22px;
font-weight:600;
color:#111;
background:#f3f4f6;
border-radius:10px;
box-shadow:0 2px 6px rgba(0,0,0,0.05);
">
@foreach(str_split($slot) as $digit)
<td align="center"
style="width:46px;height:56px;background:#F4F6FB;border:1px solid #E5E7EB;border-radius:12px;font-size:24px;font-weight:700;color:#111827;letter-spacing:-0.5px;">
{{ $digit }}
</td>
{{-- Abstand --}}
@if(!$loop->last)
<td width="8"></td>
@endif
@endforeach
</tr>
</table>

View File

@ -2,34 +2,38 @@
@section('content')
<div style="text-align:center;margin-bottom:20px;">
<x-heroicon-o-gift width="65"
style="opacity:0.8;stroke:#4f46e5;stroke-width:1" />
</div>
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#F0FDF4;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-gift style="width:24px;height:24px;stroke:#10B981;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
<h2 style="margin:0 0 10px;font-size:22px;color:#111;text-align:center;">
Hallo {{ $user->name }}!
</h2>
<p style="margin:0 0 20px;color:#6b7280;font-size:14px;text-align:center;line-height:1.6;">
Du hast kostenlosen Zugang zum
<strong style="color:#4F46E5;">{{ $plan->name }}</strong>
erhalten {{ $durationLabel }}.
</p>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Hallo {{ $user->name }}!
</h1>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Du hast kostenlosen Zugang zum
<strong style="color:#4F46E5;">{{ $plan->name }}</strong>-Plan erhalten &mdash; {{ $durationLabel }}.
</p>
{{-- Validity badge --}}
<div style="background:#F0FDF4;border:1px solid #D1FAE5;border-radius:12px;padding:18px 20px;margin-bottom:28px;text-align:center;">
@if($endsAt)
<p style="margin:0 0 20px;color:#6b7280;font-size:13px;text-align:center;">
Dein Zugang ist gültig bis:
<strong>{{ $endsAt->format('d.m.Y') }}</strong>
</p>
<p style="margin:0 0 3px;font-size:11px;color:#6EE7B7;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">Gültig bis</p>
<p style="margin:0;font-size:20px;font-weight:700;color:#059669;">{{ $endsAt->format('d.m.Y') }}</p>
@else
<p style="margin:0 0 20px;color:#6b7280;font-size:13px;text-align:center;">
Dein Zugang ist <strong>unbegrenzt gültig</strong>.
</p>
<p style="margin:0 0 3px;font-size:11px;color:#6EE7B7;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">Gültigkeit</p>
<p style="margin:0;font-size:18px;font-weight:700;color:#059669;">Unbegrenzt</p>
@endif
</div>
<x-mail.button url="{{ config('app.url') }}/agent">
Jetzt Aria nutzen
</x-mail.button>
{{-- CTA --}}
<x-mail.button :url="config('app.url') . '/agent'">
Jetzt Aria nutzen
</x-mail.button>
@endsection

View File

@ -1,30 +1,56 @@
<!DOCTYPE html>
<html>
<body style="margin:0;background:#f4f6fb;font-family:Arial,sans-serif;">
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Aziros</title>
</head>
<body style="margin:0;padding:0;background:#F4F6FB;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;">
<table width="100%">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#F4F6FB;padding:48px 16px;">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="max-width:560px;">
{{-- Logo --}}
<tr>
<td align="center">
<td style="padding-bottom:20px;padding-left:2px;">
<a href="{{ config('app.url') }}" style="text-decoration:none;display:inline-block;">
<img src="{{ config('app.url') }}/images/logo-text.png" height="32" alt="aziros" style="display:block;height:32px;width:auto;border:0;">
</a>
</td>
</tr>
<table width="100%" style="max-width:520px;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 20px 40px rgba(0,0,0,0.08);">
{{-- Card --}}
<tr>
<td style="background:#ffffff;border:1px solid #E5E7EB;border-radius:16px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06),0 4px 12px rgba(0,0,0,0.04);">
{{-- HEADER --}}
@include('emails.components.header')
{{-- Accent bar --}}
<div style="height:3px;background:linear-gradient(90deg,#4F46E5 0%,#818CF8 55%,#C7D2FE 100%);font-size:0;line-height:3px;">&zwnj;</div>
{{-- CONTENT --}}
<tr>
<td style="padding:32px;">
@yield('content')
</td>
</tr>
{{-- FOOTER --}}
@include('emails.components.footer')
</table>
{{-- Content --}}
<div style="padding:40px 40px 36px;">
@yield('content')
</div>
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding-top:28px;text-align:center;">
<p style="margin:0 0 4px;font-size:12px;color:#9CA3AF;line-height:1.7;">
Du erhältst diese E-Mail, weil du <strong style="font-weight:500;color:#6B7280;">{{ config('app.name') }}</strong> nutzt.<br>
Falls du sie nicht erwartet hast, kannst du sie ignorieren.
</p>
<p style="margin:0;font-size:11px;color:#D1D5DB;letter-spacing:0.2px;">
&copy; {{ date('Y') }} Aziros GmbH &middot; Made with &#9825; in Austria
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>

View File

@ -1,63 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen</title>
</head>
<body style="margin:0;padding:0;background:#F9FAFB;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F9FAFB;padding:40px 16px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
@extends('emails.layout')
{{-- Logo --}}
<tr>
<td style="padding-bottom:24px;">
<span style="font-size:22px;font-weight:300;color:#0D0D18;letter-spacing:-0.5px;">aziros</span>
</td>
</tr>
@section('content')
{{-- Card --}}
<tr>
<td style="background:#fff;border-radius:16px;border:1px solid #E5E7EB;padding:40px;">
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#EEF2FF;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-lock-closed style="width:22px;height:22px;stroke:#4F46E5;stroke-width:1.5;display:block;margin:15px auto;" />
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:18px;font-weight:600;color:#111827;">
Passwort zurücksetzen
</p>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;">
Hallo {{ $user->name }},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#374151;line-height:1.6;">
Du hast ein Zurücksetzen deines Passworts angefordert.
Klicke auf den Button, um ein neues Passwort zu setzen.
</p>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Passwort zurücksetzen
</h1>
<p style="margin:0 0 28px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Hallo {{ $user->name }}, klicke auf den Button um ein neues Passwort zu setzen.
</p>
<a href="{{ $url }}"
style="display:inline-block;background:#4F46E5;color:#fff;
padding:14px 28px;border-radius:10px;
text-decoration:none;font-size:14px;font-weight:600;">
Passwort zurücksetzen
</a>
{{-- Button --}}
<x-mail.button :url="$url">
Passwort zurücksetzen
</x-mail.button>
<p style="margin:24px 0 0;font-size:12px;color:#9CA3AF;line-height:1.6;">
Dieser Link ist <strong>60 Minuten</strong> gültig.<br>
Falls du kein Passwort-Reset angefordert hast, ignoriere diese E-Mail.
</p>
{{-- Expiry note --}}
<div style="margin-top:24px;background:#F9FAFB;border:1px solid #F3F4F6;border-radius:10px;padding:14px 16px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9CA3AF;line-height:1.7;">
Dieser Link ist <strong style="color:#6B7280;">60 Minuten</strong> gültig.<br>
Falls du kein Passwort-Reset angefordert hast, ignoriere diese E-Mail.
</p>
</div>
</td>
</tr>
{{-- Footer --}}
<tr>
<td style="padding-top:24px;text-align:center;font-size:12px;color:#9CA3AF;">
© {{ date('Y') }} Aziros · Made in Austria
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@endsection

View File

@ -1,2 +1,30 @@
<p>Dein SMTP Server ist korrekt konfiguriert.</p>
<p>E-Mails können von Aria in deinem Namen gesendet werden.</p>
@extends('emails.layout')
@section('content')
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#F0FDF4;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-check-circle style="width:24px;height:24px;stroke:#10B981;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
SMTP erfolgreich konfiguriert
</h1>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Dein SMTP-Server ist korrekt eingerichtet.<br>
E-Mails können von Aria in deinem Namen versendet werden.
</p>
{{-- Status box --}}
<div style="background:#F0FDF4;border:1px solid #D1FAE5;border-radius:10px;padding:14px 18px;text-align:center;">
<p style="margin:0;font-size:13px;color:#059669;font-weight:500;">
&#10003;&ensp;Verbindung hergestellt
</p>
</div>
@endsection

View File

@ -0,0 +1,45 @@
@extends('emails.layout')
@section('content')
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#FFF7ED;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-x-circle style="width:24px;height:24px;stroke:#F97316;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Dein Abo wurde gekündigt
</h1>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Hallo {{ $user->name }}, wir haben deine Kündigung des <strong style="color:#111827;">{{ $plan }}</strong>-Plans erhalten.
</p>
{{-- Access info --}}
@if(!empty($endsAt))
<div style="background:#FFF7ED;border:1px solid #FED7AA;border-radius:12px;padding:18px 20px;margin-bottom:28px;text-align:center;">
<p style="margin:0 0 4px;font-size:11px;color:#FB923C;text-transform:uppercase;letter-spacing:0.6px;font-weight:600;">Zugang aktiv bis</p>
<p style="margin:0;font-size:20px;font-weight:700;color:#EA580C;">{{ $endsAt->format('d.m.Y') }}</p>
<p style="margin:6px 0 0;font-size:12px;color:#9CA3AF;">Danach wechselst du automatisch auf den kostenlosen Plan.</p>
</div>
@else
<div style="background:#FFF7ED;border:1px solid #FED7AA;border-radius:12px;padding:16px 20px;margin-bottom:28px;text-align:center;">
<p style="margin:0;font-size:13px;color:#EA580C;">Du wechselst sofort auf den kostenlosen Plan.</p>
</div>
@endif
{{-- Reactivate CTA --}}
<x-mail.button :url="config('app.url') . '/plans'">
Abo reaktivieren
</x-mail.button>
{{-- Footer note --}}
<p style="margin:20px 0 0;text-align:center;font-size:12px;color:#9CA3AF;line-height:1.6;">
Falls du eine Frage hast, antworte einfach auf diese E-Mail.
</p>
@endsection

View File

@ -0,0 +1,59 @@
@extends('emails.layout')
@section('content')
{{-- Icon --}}
<table cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 28px;">
<tr>
<td style="width:52px;height:52px;background:#F0FDF4;border-radius:14px;text-align:center;vertical-align:middle;">
<x-heroicon-o-check-badge style="width:24px;height:24px;stroke:#10B981;stroke-width:1.5;display:block;margin:14px auto;" />
</td>
</tr>
</table>
{{-- Title --}}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;text-align:center;line-height:1.3;letter-spacing:-0.3px;">
Abo erfolgreich aktiviert!
</h1>
<p style="margin:0 0 24px;font-size:14px;color:#6B7280;text-align:center;line-height:1.6;">
Hallo {{ $user->name }}, dein <strong style="color:#111827;">{{ $plan }}</strong>-Plan ist jetzt aktiv.
</p>
{{-- Details card --}}
<div style="background:#F9FAFB;border:1px solid #F3F4F6;border-radius:12px;padding:20px 20px 16px;margin-bottom:28px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td style="padding:6px 0;font-size:12px;color:#9CA3AF;width:130px;">Plan</td>
<td style="padding:6px 0;font-size:13px;font-weight:600;color:#111827;">{{ $plan }}</td>
</tr>
<tr><td colspan="2" style="height:1px;background:#F3F4F6;padding:0;"></td></tr>
<tr>
<td style="padding:6px 0;font-size:12px;color:#9CA3AF;">Abrechnungszeitraum</td>
<td style="padding:6px 0;font-size:13px;color:#374151;">{{ $billing === 'yearly' ? 'Jährlich' : 'Monatlich' }}</td>
</tr>
<tr><td colspan="2" style="height:1px;background:#F3F4F6;padding:0;"></td></tr>
<tr>
<td style="padding:6px 0;font-size:12px;color:#9CA3AF;">Betrag</td>
<td style="padding:6px 0;font-size:13px;color:#374151;">{{ number_format($amount / 100, 2, ',', '.') }} / {{ $billing === 'yearly' ? 'Jahr' : 'Monat' }}</td>
</tr>
@if(!empty($renewsAt))
<tr><td colspan="2" style="height:1px;background:#F3F4F6;padding:0;"></td></tr>
<tr>
<td style="padding:6px 0;font-size:12px;color:#9CA3AF;">Nächste Verlängerung</td>
<td style="padding:6px 0;font-size:13px;color:#374151;">{{ $renewsAt->format('d.m.Y') }}</td>
</tr>
@endif
</table>
</div>
{{-- CTA --}}
<x-mail.button :url="config('app.url')">
Zur App
</x-mail.button>
{{-- Footer note --}}
<p style="margin:20px 0 0;text-align:center;font-size:12px;color:#9CA3AF;line-height:1.6;">
Du kannst dein Abo jederzeit unter Einstellungen &rarr; Abonnement kündigen.
</p>
@endsection

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', config('app.name'))</title>
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon-dark.svg') }}" media="(prefers-color-scheme: dark)">
@vite(['resources/css/app.css'])
@livewireStyles
</head>
@ -58,7 +60,7 @@
const STORAGE_KEY = 'aziros_seen_version';
const seenVersion = localStorage.getItem(STORAGE_KEY);
try {
const res = await fetch('/api/v1/version/current?platform=web');
const res = await fetch('{{ config("app.api_url") }}/v1/version/current?platform=web');
const data = await res.json();
const current = data.data;
if (!current) return;

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ config('app.name') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon-dark.svg') }}" media="(prefers-color-scheme: dark)">
<meta property="og:title" content="Aziros — KI-Assistent für deinen Alltag"/>
<meta property="og:description" content="Termine, Aufgaben und Aria — jetzt auch als App für iOS und Android."/>

View File

@ -69,6 +69,74 @@
</div>
</div>
{{-- Webhook --}}
@php
$lastTrigger = \Illuminate\Support\Facades\Cache::get('last_deploy_triggered_at');
$git = $this->gitInfo;
@endphp
<div class="bg-white rounded-2xl border border-gray-100 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-900">Deploy Webhook</h3>
@if($deployStatus === 'success')
<span class="flex items-center gap-1.5 text-xs font-medium text-green-700 bg-green-50 px-2.5 py-1 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>
Erfolgreich ausgelöst
</span>
@elseif($deployStatus === 'error')
<span class="flex items-center gap-1.5 text-xs font-medium text-red-700 bg-red-50 px-2.5 py-1 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-red-500 inline-block"></span>
Fehler
</span>
@endif
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div class="bg-gray-50 rounded-xl px-4 py-3">
<p class="text-xs font-medium text-gray-500 mb-1">Letzter Commit</p>
<p class="font-mono text-sm font-semibold text-gray-900">{{ $git['hash'] }}</p>
<p class="text-xs text-gray-600 mt-0.5 truncate" title="{{ $git['message'] }}">{{ $git['message'] }}</p>
</div>
<div class="bg-gray-50 rounded-xl px-4 py-3">
<p class="text-xs font-medium text-gray-500 mb-1">Commit-Datum</p>
<p class="text-sm text-gray-800">{{ $git['date'] !== '—' ? \Carbon\Carbon::parse($git['date'])->format('d.m.Y H:i') : '—' }}</p>
</div>
<div class="bg-gray-50 rounded-xl px-4 py-3">
<p class="text-xs font-medium text-gray-500 mb-1">Letzter Deploy</p>
<p class="text-sm text-gray-800">
{{ $lastTrigger ? \Carbon\Carbon::parse($lastTrigger)->format('d.m.Y H:i') : '—' }}
</p>
</div>
</div>
@if($deployStatus === 'error' && $deployMessage)
<div class="mb-4 bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-xs text-red-700 font-mono">
{{ $deployMessage }}
</div>
@elseif($deployStatus === 'success' && $deployMessage)
<div class="mb-4 bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-xs text-green-700">
{{ $deployMessage }}
</div>
@endif
<button wire:click="triggerDeploy"
wire:loading.attr="disabled"
class="bg-indigo-600 text-white rounded-xl px-4 py-2 text-sm font-medium hover:bg-indigo-700 disabled:opacity-60 flex items-center gap-2">
<span wire:loading.remove wire:target="triggerDeploy">
<svg class="w-4 h-4 inline -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
Deploy auslösen
</span>
<span wire:loading wire:target="triggerDeploy" class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
Wird ausgelöst…
</span>
</button>
</div>
{{-- Liste --}}
<div class="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<table class="w-full">

View File

@ -215,9 +215,9 @@
@else {{ $r['minutes'] ?? 30 }}{{ t('events.reminder_min_before') }}
@endif
@elseif(($r['type'] ?? '') === 'time_of_day')
{{ t('events.reminder_on_day_prefix') }}{{ $r['time'] ?? '' }}{{ t('events.reminder_clock_suffix') }}
{{ t('events.reminder_on_day_prefix') }}{{ isset($r['time']) ? \Carbon\Carbon::createFromFormat('H:i', $r['time'], 'UTC')->setTimezone(auth()->user()->timezone ?? 'UTC')->format('H:i') : '' }}{{ t('events.reminder_clock_suffix') }}
@else
{{ t('events.reminder_prev_day_prefix') }}{{ $r['time'] ?? '' }}{{ t('events.reminder_clock_suffix') }}
{{ t('events.reminder_prev_day_prefix') }}{{ isset($r['time']) ? \Carbon\Carbon::createFromFormat('H:i', $r['time'], 'UTC')->setTimezone(auth()->user()->timezone ?? 'UTC')->format('H:i') : '' }}{{ t('events.reminder_clock_suffix') }}
@endif
</span>
<button wire:click="removeReminder({{ $i }})" type="button"

View File

@ -26,7 +26,15 @@
x-on:dragleave="onDragLeave($event)"
x-on:drop="onDrop($event, '{{ $dayStr }}')"
x-on:dblclick.self="onCreateAt($event, '{{ $dayStr }}')"
x-on:click.self="focusedEventId = null">
x-on:click.self="focusedEventId = null"
x-on:mousemove="onTimeHover($event, {{ $colIndex }})"
x-on:mouseleave="onTimeLeave($event, {{ $colIndex }})">
{{-- Hover-Highlight --}}
<div class="absolute left-0 right-0 pointer-events-none"
x-show="hoverCol === {{ $colIndex }} ? hoverSlot !== null : false"
:style="{ top: (hoverSlot * {{ $cellPx / 4 }}) + 'px' }"
style="height:{{ $cellPx / 4 }}px;background:rgba(99,102,241,0.07);z-index:1;"></div>
{{-- Stunden-Linien --}}
@for($h = $hourStart; $h < $hourEnd; $h++)

View File

@ -256,6 +256,38 @@
</div>
@endif
{{-- Widerrufsrecht-Checkboxen (nur bei paid plans) --}}
@if($this->checkoutType !== 'cancel')
<div class="space-y-3 border border-amber-100 bg-amber-50 rounded-xl p-4">
<div class="flex items-start gap-2.5">
<x-heroicon-o-information-circle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5"/>
<p class="text-[11px] text-amber-700 font-medium leading-relaxed">
{{ t('checkout.waiver_info') }}
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
wire:model.live="rightAcknowledged"
class="mt-0.5 rounded border-amber-300 text-amber-500 focus:ring-amber-400 shrink-0"
/>
<span class="text-[11px] text-amber-800 leading-relaxed">
{{ t('checkout.waiver_right_acknowledged') }}
</span>
</label>
<label class="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
wire:model.live="waiverConfirmed"
class="mt-0.5 rounded border-amber-300 text-amber-500 focus:ring-amber-400 shrink-0"
/>
<span class="text-[11px] text-amber-800 leading-relaxed">
{{ t('checkout.waiver_confirmed') }}
</span>
</label>
</div>
@endif
{{-- CTA --}}
@if($this->checkoutType === 'cancel')
<button
@ -278,6 +310,7 @@
<button
wire:click="startCheckout"
wire:loading.attr="disabled"
:disabled="!$wire.rightAcknowledged || !$wire.waiverConfirmed"
class="w-full flex items-center justify-center gap-2 px-5 py-3.5 rounded-xl bg-indigo-600 text-white font-semibold hover:bg-indigo-700 active:scale-95 transition-all shadow-sm shadow-indigo-200 disabled:opacity-60">
<span wire:loading.remove wire:target="startCheckout">
<x-heroicon-o-arrow-path class="w-4 h-4 inline-block mr-1 opacity-70"/>
@ -295,6 +328,7 @@
<button
wire:click="startCheckout"
wire:loading.attr="disabled"
:disabled="!$wire.rightAcknowledged || !$wire.waiverConfirmed"
class="w-full flex items-center justify-center gap-2 px-5 py-3.5 rounded-xl bg-indigo-600 text-white font-semibold hover:bg-indigo-700 active:scale-95 transition-all shadow-sm shadow-indigo-200 disabled:opacity-60">
<span wire:loading.remove wire:target="startCheckout">
<x-heroicon-o-lock-closed class="w-4 h-4 inline-block mr-1 opacity-70"/>

View File

@ -263,10 +263,10 @@
@endphp
<div class="flex items-center gap-1.5 shrink-0">
@if($isOverdue)
<span class="text-xs text-red-500 font-medium">
{{ $task->due_at->format('d.m.') }}
<span class="inline-flex items-center gap-1 text-xs text-red-500 font-medium bg-red-50 px-1.5 py-0.5 rounded-md">
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5 shrink-0"/>
{{ t('dashboard.overdue') }} · {{ $task->due_at->format('d.m.') }}
</span>
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5 text-red-400"/>
@elseif($task->due_at)
<span class="text-[11px] text-gray-400 whitespace-nowrap">
{{ $task->due_at->format('d.m.') }}

View File

@ -20,6 +20,7 @@
'smtp' => ['label' => t('settings.tab.smtp'), 'icon' => 'envelope'],
'credits' => ['label' => t('settings.tab.credits'), 'icon' => 'bolt'],
...(!auth()->user()->isInternalUser() ? ['affiliate' => ['label' => 'Affiliate', 'icon' => 'gift']] : []),
'api' => ['label' => 'API', 'icon' => 'code-bracket'],
'account' => ['label' => t('settings.tab.account'), 'icon' => 'shield-exclamation'],
] as $key => $tab)
<button wire:click="$set('activeTab', '{{ $key }}')"
@ -31,6 +32,7 @@
@elseif($tab['icon'] === 'envelope') <x-heroicon-o-envelope class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'bolt') <x-heroicon-o-bolt class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'gift') <x-heroicon-o-gift class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'code-bracket') <x-heroicon-o-code-bracket class="w-3.5 h-3.5"/>
@elseif($tab['icon'] === 'shield-exclamation') <x-heroicon-o-shield-exclamation class="w-3.5 h-3.5"/>
@endif
{{ $tab['label'] }}
@ -658,6 +660,110 @@
@endif
{{-- ════════════════════════════════════════════════════════════ --}}
{{-- TAB: API --}}
{{-- ════════════════════════════════════════════════════════════ --}}
<div x-show="$wire.activeTab === 'api'" x-cloak>
<div class="bg-white border border-gray-100 rounded-xl shadow-sm">
<div class="flex items-center gap-3 px-6 py-5 border-b border-gray-100">
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center">
<x-heroicon-o-code-bracket class="w-4 h-4 text-indigo-500"/>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-800">API-Zugang</h3>
<p class="text-xs text-gray-500 mt-0.5">Erstelle persönliche Tokens für den Zugriff auf die Aziros-API.</p>
</div>
</div>
<div class="p-6 space-y-6">
{{-- Neuen Token erstellen --}}
@if(auth()->user()->hasFeature('api_access'))
<div>
<label class="{{ $labelClass }}">Neuen Token erstellen</label>
<div class="flex gap-2">
<input wire:model="newTokenName"
placeholder="z.B. Mein Skript, Zapier…"
class="{{ $inputClass }} flex-1"
wire:keydown.enter="createApiToken"/>
<button wire:click="createApiToken"
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors shrink-0">
Erstellen
</button>
</div>
@error('newTokenName') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
@if($createdToken)
<div class="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
<p class="text-xs font-medium text-green-800">Token wurde erstellt kopiere ihn jetzt, er wird nur einmal angezeigt.</p>
<div class="flex gap-2 items-center" x-data="{ copied: false }">
<code class="flex-1 text-xs bg-white border border-green-200 rounded px-3 py-2 text-gray-800 break-all font-mono select-all">{{ $createdToken }}</code>
<button @click="navigator.clipboard.writeText('{{ $createdToken }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 shrink-0">
<span x-show="!copied">Kopieren</span>
<span x-show="copied" x-cloak></span>
</button>
</div>
</div>
@endif
@else
<div class="flex items-center gap-3 p-4 bg-indigo-50 rounded-lg">
<x-heroicon-o-lock-closed class="w-5 h-5 text-indigo-400 shrink-0"/>
<div>
<p class="text-sm font-medium text-indigo-800">Pro-Feature</p>
<p class="text-xs text-indigo-600 mt-0.5">API-Tokens sind für Pro-Nutzer verfügbar.</p>
</div>
</div>
@endif
{{-- Bestehende Tokens --}}
@if($this->apiTokens->isNotEmpty())
<div>
<label class="{{ $labelClass }}">Aktive Tokens</label>
<div class="border border-gray-100 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-xs text-gray-500 uppercase tracking-wider">
<th class="px-4 py-2.5">Name</th>
<th class="px-4 py-2.5">Erstellt</th>
<th class="px-4 py-2.5">Zuletzt verwendet</th>
<th class="px-4 py-2.5"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($this->apiTokens as $token)
<tr class="hover:bg-gray-50/50">
<td class="px-4 py-3 font-medium text-gray-800">{{ $token->name ?? 'Unbenannt' }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $token->created_at->format('d.m.Y') }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $token->last_used_at?->diffForHumans() ?? '—' }}</td>
<td class="px-4 py-3 text-right">
<button wire:click="revokeApiToken('{{ $token->id }}')"
wire:confirm="Token widerrufen?"
class="text-xs text-red-500 hover:text-red-700 font-medium">
Widerrufen
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- API-Referenz --}}
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<p class="text-xs font-medium text-gray-700">Verwendung</p>
<code class="block text-xs text-gray-600 font-mono">Authorization: Bearer &lt;dein-token&gt;</code>
<code class="block text-xs text-gray-500 font-mono">GET https://api.aziros.com/v1/events?from=2026-01-01&amp;to=2026-12-31</code>
</div>
@endif
</div>
</div>
</div>
{{-- ════════════════════════════════════════════════════════════ --}}
{{-- TAB: GEFAHRENZONE --}}
{{-- ════════════════════════════════════════════════════════════ --}}

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mail Preview · Aziros</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #F4F6FB; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #111827; }
.wrapper { max-width: 960px; margin: 0 auto; padding: 48px 24px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 36px; }
.logo { font-size: 18px; font-weight: 300; color: #111827; letter-spacing: -0.5px; text-decoration: none; }
.badge { font-size: 11px; background: #EEF2FF; color: #4F46E5; padding: 3px 10px; border-radius: 20px; font-weight: 600; letter-spacing: 0.3px; }
h1 { margin: 0 0 4px; font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
.subtitle { margin: 0 0 32px; font-size: 14px; color: #6B7280; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card { background: #fff; border: 1px solid #E5E7EB; border-radius: 14px; overflow: hidden; transition: box-shadow 0.15s, border-color 0.15s; text-decoration: none; color: inherit; display: block; }
.card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); border-color: #C7D2FE; }
.preview-frame { width: 100%; height: 200px; border: none; background: #F9FAFB; pointer-events: none; display: block; }
.card-body { padding: 14px 16px; border-top: 1px solid #F3F4F6; }
.card-name { font-size: 13px; font-weight: 600; color: #111827; margin: 0 0 2px; }
.card-path { font-size: 11px; color: #9CA3AF; margin: 0; font-family: 'SF Mono', Menlo, monospace; }
.card-footer { padding: 10px 16px; background: #F9FAFB; border-top: 1px solid #F3F4F6; display: flex; justify-content: flex-end; }
.btn { font-size: 12px; color: #4F46E5; font-weight: 600; text-decoration: none; padding: 5px 12px; background: #EEF2FF; border-radius: 6px; }
.btn:hover { background: #E0E7FF; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<a href="{{ config('app.url') }}" class="logo">aziros</a>
<span class="badge">Mail Preview</span>
</div>
<h1>E-Mail Templates</h1>
<p class="subtitle">{{ count($templates) }} Templates &mdash; Klicke auf eine Karte für die Vollansicht.</p>
<div class="grid">
@foreach($templates as $tpl)
<a href="{{ url('/admin/mail-preview/' . $tpl['key']) }}" class="card" target="_blank">
<iframe
src="{{ url('/admin/mail-preview/' . $tpl['key']) }}"
class="preview-frame"
title="{{ $tpl['name'] }}"
loading="lazy"
style="transform-origin: top left; transform: scale(0.5); width: 200%; height: 400px; margin-bottom: -200px;">
</iframe>
<div class="card-body">
<p class="card-name">{{ $tpl['name'] }}</p>
<p class="card-path">emails/{{ str_replace('.', '/', $tpl['key']) }}.blade.php</p>
</div>
<div class="card-footer">
<span class="btn">Vollansicht &rarr;</span>
</div>
</a>
@endforeach
</div>
</div>
</body>
</html>

View File

@ -3,11 +3,8 @@
<div class="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
{{-- Logo --}}
<a href="{{ route('homepage.index') }}" class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-indigo-600 flex items-center justify-center shadow-sm shadow-indigo-200">
<x-heroicon-o-bolt class="w-4 h-4 text-white"/>
</div>
<span class="font-bold text-gray-900 text-lg tracking-tight">aziros</span>
<a href="{{ route('homepage.index') }}">
<x-logo class="h-8 w-auto"/>
</a>
{{-- Links (desktop) --}}

View File

@ -57,6 +57,7 @@ Route::prefix('v1')->group(function () {
// Kalender
Route::get('/events', [EventController::class, 'index']);
Route::post('/events', [EventController::class, 'store']);
Route::get('/events/{id}', [EventController::class, 'show']);
Route::put('/events/{id}', [EventController::class, 'update']);
Route::delete('/events/{id}', [EventController::class, 'destroy']);

View File

@ -22,13 +22,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/mail-preview', function () {
return view('emails.auth.verify', [
'user' => 'Max Mustermann',
'code' => '123456',
'url' => 'https://example.com/verify' // 🔥 hinzufügen
]);
});
// ── Mail Preview (nur Admin) ──────────────────────────────────────────────────
Route::middleware(['auth.custom'])->group(function () {
// intentionally empty — kept for middleware reference
@ -63,6 +57,97 @@ Route::middleware(['user', 'role:admin'])->prefix('admin')->name('admin.')->grou
Route::get('/affiliates', \App\Livewire\Admin\Affiliates\Index::class)->name('affiliates.index');
Route::get('/translations', TranslationIndex::class)->name('translations.index');
Route::get('/versions', \App\Livewire\Admin\Versions::class)->name('versions.index');
// ── Mail Preview ──────────────────────────────────────────────────────────
$fakeData = [
'auth.verify' => [
'name' => 'E-Mail verifizieren',
'data' => ['url' => 'https://app.aziros.com/verify?token=preview', 'user' => 'Max Mustermann'],
],
'reset-password' => [
'name' => 'Passwort zurücksetzen',
'data' => ['url' => 'https://app.aziros.com/password/reset/preview', 'user' => (object)['name' => 'Max Mustermann']],
],
'agent.reminder' => [
'name' => 'Terminerinnerung',
'data' => [
'sender_name' => 'Maria Muster',
'event_title' => 'Strategie-Meeting Q2',
'event_date' => 'Montag, 22. April 2026',
'event_time' => '14:00',
'event_end' => '15:30',
'event_notes' => 'Bitte die Präsentation vorbereiten.',
'recipient_name' => 'Max Mustermann',
'message' => 'Vergiss bitte nicht, die Unterlagen mitzubringen!',
],
],
'agent.message' => [
'name' => 'Neue Nachricht (Agent)',
'data' => [
'sender_name' => 'Maria Muster',
'recipient_name' => 'Max Mustermann',
'message' => 'Hallo Max, kannst du mir bitte die Unterlagen für das Meeting zukommen lassen? Ich brauche sie bis morgen früh.',
],
],
'gift-access' => [
'name' => 'Geschenk-Zugang',
'data' => [
'user' => (object)['name' => 'Max Mustermann'],
'plan' => (object)['name' => 'Pro'],
'durationLabel' => '3 Monate kostenlos',
'endsAt' => \Carbon\Carbon::now()->addMonths(3),
],
],
'affiliate-qualified' => [
'name' => 'Affiliate qualifiziert',
'data' => [
'credits' => 50,
'referredUser' => (object)['name' => 'Lisa Müller'],
],
],
'smtp-test' => [
'name' => 'SMTP Test',
'data' => [],
],
'aria-composed' => [
'name' => 'Aria Verfasste E-Mail',
'data' => ['body' => "Hallo!\n\nIch habe deine Aufgabe erledigt und hier ist die Zusammenfassung der Ergebnisse für dein Meeting morgen.\n\nBitte prüfe die Agenda und gib mir Bescheid, ob noch Änderungen nötig sind."],
],
'auth.welcome' => [
'name' => 'Willkommen',
'data' => ['user' => (object)['name' => 'Max Mustermann']],
],
'subscription.confirmed' => [
'name' => 'Abo bestätigt',
'data' => [
'user' => (object)['name' => 'Max Mustermann'],
'plan' => 'Pro',
'billing' => 'monthly',
'amount' => 1900,
'renewsAt' => \Carbon\Carbon::now()->addMonth(),
],
],
'subscription.cancelled' => [
'name' => 'Abo gekündigt',
'data' => [
'user' => (object)['name' => 'Max Mustermann'],
'plan' => 'Pro',
'endsAt' => \Carbon\Carbon::now()->addDays(14),
],
],
];
Route::get('/mail-preview', function () use ($fakeData) {
$templates = collect($fakeData)->map(fn($v, $k) => ['key' => $k, 'name' => $v['name']])->values();
return view('mail-preview.index', compact('templates'));
})->name('mail-preview.index');
Route::get('/mail-preview/{template}', function (string $template) use ($fakeData) {
abort_unless(isset($fakeData[$template]), 404);
$entry = $fakeData[$template];
$view = 'emails.' . $template;
return response(view($view, $entry['data']));
})->name('mail-preview.show');
});
Route::middleware('user')->group(function () {

0
src/storage/app/.gitignore vendored Normal file → Executable file
View File

0
src/storage/app/private/.gitignore vendored Normal file → Executable file
View File

0
src/storage/app/public/.gitignore vendored Normal file → Executable file
View File

0
src/storage/framework/.gitignore vendored Normal file → Executable file
View File

0
src/storage/framework/testing/.gitignore vendored Normal file → Executable file
View File

6
vite Normal file
View File

@ -0,0 +1,6 @@
> build
> vite build --mode development staging
vite v8.0.3 building client environment for development...
 transforming...✓ 1 modules transformed.