Compare commits
138 Commits
ec41628fb6
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6774558b | |||
| 1db334f411 | |||
| ad7380f975 | |||
| b16e9ac8a0 | |||
| 6722f7807f | |||
| 7b6833cb42 | |||
| 289e706196 | |||
| d4f565488d | |||
| 4fb6d37525 | |||
| 169c0863f2 | |||
| b17e7a04f9 | |||
| ec8685d64d | |||
| 93ae35319e | |||
| be00d38470 | |||
| 06c5624742 | |||
| bad450e2d4 | |||
| 74f35d3831 | |||
| 07e34808be | |||
| 1084b960cf | |||
| 85e84b093b | |||
| 3a2d59b974 | |||
| ec8d84c993 | |||
| ff045c90d0 | |||
| a367a72c00 | |||
| 748a4ad2a8 | |||
| 15a1330fc5 | |||
| eec9b38af5 | |||
| ec0a977211 | |||
| 726fcc38ce | |||
| 30683b27ba | |||
| 6e1a3be8cf | |||
| efdd134556 | |||
| bd066fbd20 | |||
| 8388c6da2b | |||
| d90d856620 | |||
| 7cb29cd8da | |||
| 991e338d67 | |||
| db94eca626 | |||
| 1ae8b3e353 | |||
| 02b17d53ea | |||
| d49f6d51f4 | |||
| 995b3ff829 | |||
| 472f392107 | |||
| e6061868e6 | |||
| 2a85cab4ab | |||
| c458a9c26c | |||
| aa3ff2998f | |||
| 9645859091 | |||
| 8d7f5cbbb6 | |||
| 806fa199ce | |||
| 19247280a0 | |||
| da4995bb3e | |||
| 080a202a9f | |||
| ba06e6d033 | |||
| 3f6b27a89f | |||
| 9b4f80a44f | |||
| 1f5805e64c | |||
| 929d92afeb | |||
| 1a3568f69e | |||
| 0cca30a956 | |||
| 2592607b04 | |||
| f148f40d9e | |||
| 1dd4c6b6da | |||
| daa3637ef6 | |||
| 5e0186c534 | |||
| c2f614d7ad | |||
| e8c2cdfff9 | |||
| 2cfda1da57 | |||
| 4baece46bb | |||
| 5d28a34f02 | |||
| 831ec7e71c | |||
| 229ad10e6b | |||
| 43658c2921 | |||
| fa68e42f5f | |||
| e411addfe2 | |||
| 53ccdd2b69 | |||
| 6bf9260923 | |||
| 7c5b584890 | |||
| a128ca9921 | |||
| 965696b1ca | |||
| 787002532d | |||
| ba8565e59a | |||
| 539f30bdb7 | |||
| 7546b4021d | |||
| 1c98566e93 | |||
| d3987ea20b | |||
| 02a1ecb53d | |||
| 70adecae58 | |||
| 066470e82c | |||
| 106cfe6e33 | |||
| d4b20eb113 | |||
| f72719b9a4 | |||
| c62db8a2ef | |||
| 567dd9a2ca | |||
| ec877ef65b | |||
| e5add77a50 | |||
| b2f29dea27 | |||
| c02facdf5d | |||
| 22fe4dbd9f | |||
| 4af08a2304 | |||
| f27489b412 | |||
| 1bdd8af9ac | |||
| ffc47e02e7 | |||
| 8578ef8fe3 | |||
| 446211e9cb | |||
| fa65e99310 | |||
| 5294d73dc1 | |||
| 5dad99d8b3 | |||
| 5720a4a7e0 | |||
| 39c3a59744 | |||
| 5c69c44ed3 | |||
| ae61cc44e1 | |||
| a5f0d0473d | |||
| 07b70762ee | |||
| 92ba156603 | |||
| ea8427aba5 | |||
| c27e404ee1 | |||
| 6b8e146c4a | |||
| 961dbf1348 | |||
| 62ae7fe69e | |||
| b8eae846a5 | |||
| c39661c7e4 | |||
| 21fd89c854 | |||
| 031a280a62 | |||
| cef9d9ae11 | |||
| 7a1f0fcd8c | |||
| 56fea34fc5 | |||
| ef74aeefe0 | |||
| a30d741d71 | |||
| bc2fb2f842 | |||
| aab7b08296 | |||
| 0acc2a4c0a | |||
| fb17445807 | |||
| 0565ed678a | |||
| 21c8ff66fd | |||
| 16cd760dac | |||
| 80ce77c530 | |||
| 9485cd4428 |
@@ -1 +1 @@
|
||||
{"task_id": "30388f42-8544-8088-bc48-e59e9b973e91", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-03-10T19:23:35.891681"}
|
||||
{"task_id": "36288f42-8544-807a-a70a-e75f676885c2", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-05-16T11:54:05.978156"}
|
||||
@@ -0,0 +1,126 @@
|
||||
"Child ID";"Vorname Kind";"Nachname Kind";Geschlecht;Geburtsdatum;Einrichtung;Gruppe;Lehrer;"Gültig bis";Bezeichner;Referenz;Straße;PLZ;Ort;Staat;"Geschwisterkind Vorname (1)";"Geschwisterkind Nachname (1)";"Geschwisterkind Vorname (2)";"Geschwisterkind Nachname (2)";Einzelfotos;Gruppenfotos;"Familie / Geschwister";Foto;"Vom Kunden ausgewählt";"Vorname Eltern (1)";"Nachname Eltern (1)";"Email der Eltern (1)";"Telefonnummer der Eltern (1)";"Vorname Eltern (2)";"Nachname Eltern (2)";"Email der Eltern (2)";"Telefonnummer der Eltern (2)";"Zugangscode (1)";"Barcode (1)";"Logins (1)";"Zugangscode (2)";"Barcode (2)";"Logins (2)";Bestellungen
|
||||
49663204;Fares;AL-KHADHER;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8069.jpg;Nein;Familie;"Al Khadher";Husseinalkhadher8@gmail.com;;;;;;9CKZ9FRB;859242970856177;2;;;0;0
|
||||
49656019;Entoni;Altoni;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7999.jpg;Nein;Yuliia;Altoni;julichka.altony@gmail.com;;;;;;8PH6DT65;590974350307121;1;;;0;1
|
||||
49659604;"Rashane Tyler";Asasana;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8048.jpg;Nein;Penphaka;Asasana;asa-sa-na@hotmail.com;;;;;;57VSYGKZ;742438249864838;2;;;0;0
|
||||
49955890;Yunus;Batuge;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7831.jpg;Nein;Sümeyra;Senyurt;senyurtsumeyra7@gmail.com;;;;;;Y9LFLVQ6;807433233164209;15;;;0;1
|
||||
49652597;Josip;Bungic;;;;Bären;;;;;;;;;;;;;Nein;Ja;Nein;;Nein;Mirela;"Marijan Bungic";m.bungic@web.de;;;;;;JYCSXJTX;967076735653408;0;;;0;0
|
||||
50064753;Hazal;Cicek;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7714.jpg;Nein;Familie;Cicek;uelke.ardak@hotmail.de;;;;;;VNFYB935;306933685807165;0;;;0;0
|
||||
49601392;Levi;Damia;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;;Nein;Louisa;Damian;damian.louisa@web.de;;;;;;3VP45KKX;107953830470294;0;;;0;0
|
||||
50314236;Levi;Damian;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7936.jpg;Nein;Louisa;Damian;damian.louisa@web.de;;;;;;DVXDP3PH;677393543795054;1;;;0;1
|
||||
50211537;Gökhan;Dogan;;;;Bären;;;;;;;;;Eray;Dogan;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8096.jpg;Nein;Familie;Dogan;goeksel_dogan@web.de;;;;;;V9FBBSMP;152677334111372;2;;;0;0
|
||||
50220839;"Magdalena Personal";Forster;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;;Nein;Magdalena;Forster;magdalenaforster@aol.de;;;;;;7NG54JNY;74394435366624;0;;;0;0
|
||||
49629572;Philipp;Gabauer;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Familie;Gabauer;luzia.gabauer@web.de;;;;;;4HP8FX8K;20692770537744;0;;;0;0
|
||||
49652592;Emilia;"Herrmann Rodriguez";;;;Bären;;;;;;;;;;;;;Nein;Ja;Nein;;Nein;Lukas;Herrmann;Familie.Herrmann.Rodriguez@web.de;;;;;;7X7Y4BKV;73798042174951;0;;;0;0
|
||||
50060415;Konstantin;Karl;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7806.jpg;Nein;Katharina;Karl;katharina_karl@mailbox.org;;;;;;XNCV6XM7;810263015266358;0;;;0;0
|
||||
50060407;Paulina;Karl;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Katharina;Karl;katharina_karl@mailbox.org;;;;;;HSKMY37G;607082088640959;0;;;0;0
|
||||
49901894;Salomia;Karpenko;;;;Bären;;;;;;;;;Miroslav;Karpenko;;;Ja;Ja;Nein;IMG_7786.jpg;Nein;Familie;Karpenko;denis.k88@web.de;;;;;;4P7TJXJL;826081492713003;4;;;0;0
|
||||
49654259;Jan;Klyszcz;;;;Bären;;;;;;;;;Christoph;Klyszcz;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7859.jpg;Nein;Familie;Klyszcz;klyszcz.ewa92@gmail.com;;;;;;V9QQ3MHT;635050103722845;4;;;0;0
|
||||
50220757;Personal;Lang;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;;Nein;Susanne;Lang;susi67.sl@gmail.com;;;;;;J2B9F4FH;84529853902827;0;;;0;0
|
||||
49663258;Tuldi;"Lennart & Hannes";;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7915.jpg;Nein;Familie;Tuldi;olga_tuldi@yahoo.de;;;;;;Z7D4PJHV;628485247329265;10;;;0;0
|
||||
49727295;Leonardo;Liquori;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7654.jpg;Nein;Elisa;Mandelli;e.mandelli1@icloud.com;;;;;;CZBSHZXD;112332574322427;3;;;0;0
|
||||
49694659;Mara;Schmid;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7737.jpg;Nein;Familie;Schmid;izuther@googlemail.com;;;;;;M2QWP8PN;693636596918854;4;;;0;0
|
||||
49553844;Niklas;Schulze;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8027.jpg;Nein;Kristina;Schulze;m-k-ammersdorf@gmx.de;;Kristina;Schulze;kristina-anna-schulze@web.de;;TDJ47324;213569357182904;4;;;0;1
|
||||
49605342;Zoe;Seget;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7764.jpg;Nein;Sandra;Seget;sandra.seget@hotmail.de;;;;;;GTY9QMWP;335161472735404;3;;;0;1
|
||||
50211319;Valentin;Slugocki;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;;Nein;Bartek;Slugocki;bartek@slugocki.de;;;;;;24632X5S;557733375991183;0;;;0;0
|
||||
50219244;"Hannes & Lennart";Tuldi;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7959.jpg;Nein;Familie;Tuldi;olga_tuldi@yahoo.de;;;;;;SZ7D82KL;195111473283743;9;;;0;1
|
||||
49697372;Xaver;Wego;;;;Bären;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7883.jpg;Nein;Luisa;Wego;luisa.wego@web.deq;;Luisa;Wego;luisa.wego@web.de;;JT22FL8Y;470837065491819;0;6XJVMHVQ;423156711490859;6;1
|
||||
49655774;Maximilian;Wild;;;;Bären;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7977.jpg;Nein;Familie;Wild;wildramona@gmx.de;;;;;;XYCPRYX3;841975500351515;6;;;0;0
|
||||
49613372;Anton;Adelberger;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8367.jpg;Nein;Catharina;Adelberger;catharina.adelberger@web.de;;;;;;GCSBWRRG;255252947890362;5;;;0;0
|
||||
49655260;Ludwig;Baumgartner;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Franziska;Baumgartner;franziwild@gmx.de;;;;;;JJDBSW2Y;1346656807317;0;;;0;0
|
||||
49652607;Josip;Bungic;;;;Bienen;;;;;;;;;;;;;Nein;Ja;Nein;;Nein;Mirela;"Marijan Bungic";m.bungic@web.de;;;;;;XR3LFNTW;896957772773017;1;;;0;0
|
||||
50064781;Havin;Cicek;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8198.jpg;Nein;Familie;Cicek;uelke.ardak@hotmail.de;;;;;;8X8GYWR3;847511991706072;1;;;0;0
|
||||
50055747;Mattea;Fusarri;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8151.jpg;Nein;Nadia;Fusarri;nadia.fusarri@gmx.de;;;;;;ZRGWQM3W;439731985455440;3;;;0;0
|
||||
50247238;Maliya;Gildner;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8341.jpg;Nein;Alisa;Gildner;gildner31@gmail.com;;;;;;KRTVJ4M5;910013114016383;2;;;0;0
|
||||
49825283;Kilian;Hartl;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8410.jpg;Nein;Familie;Schreibauer;a.schreibauer@gmail.com;;;;;;NM92G8PK;534850382393461;3;;;0;0
|
||||
50153154;"Elara Carolina";Hintermaier;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8289.jpg;Nein;Adriana;Hintermaier;adri.shunka@gmail.com;;;;;;N6G67PPZ;233647866343524;1;;;0;0
|
||||
49700913;Luka;Loncar;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8435.jpg;Nein;Szilvia;Palinkas;silvijapalinkas@yahoo.com;;;;;;7FGK48GQ;345687401851686;5;;;0;1
|
||||
50056989;Elias;Minksz;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Carolin;Dirndorfer;c.dirndorfer@gmx.de;;;;;;GZ3DQSPL;632143387747513;0;;;0;0
|
||||
49770856;Anna;Nguyen;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8219.jpg;Nein;Thi;"Hien Minh Nguyen";nthm30121996@gmail.com;;Anna;Nguyen;ging318@gmail.com;;CM9CMLBJ;122574286373832;2;;;0;0
|
||||
50180008;Ilia;Nickl;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8390.jpg;Nein;Familie;Nickl;cela990@hotmail.com;;;;;;KHPY6LQV;652142151577775;9;;;0;0
|
||||
49575500;Mika;Rubinstein;;;;Bienen;;;;;;;;;Mia;Rubinstein;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8548.jpg;Nein;Familie;Rubinstein;n.d.rubinstein@googlemail.com;;;;;;K7PX4J8Y;415300008608215;2;;;0;0
|
||||
49652538;Alina;Schillinger;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8244.jpg;Nein;Familie;Schillinger;schneggeno1@web.de;;;;;;X27P5L9Q;180935518874486;3;;;0;0
|
||||
49663277;Malia;Schlesinger;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8126.jpg;Nein;Familie;Schlesinger;stefanie2011@gmx.net;;;;;;6672SN99;377539049099605;2;;;0;0
|
||||
50257156;Marie;Schöberl;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8265.jpg;Nein;Michaela;Schöberl;michaela.schoeberl@gmx.de;;;;;;BKZWFCS4;504469516218803;2;;;0;0
|
||||
50057519;Letizia;Stachanczyk;;;;Bienen;;;;;;;;;Leonardo;Stachanczyk;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Familie;Stachanczyk;Suzanna.Stachanczyk@web.de;;;;;;C7GX6BM2;268376387434609;0;;;0;0
|
||||
49919594;Ela;Torres;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8313.jpg;Nein;Familie;Torres;ftorrestapia@me.com;;;;;;YGL954RX;63682236385188;4;;;0;0
|
||||
49837810;Maximilian;Weber;;;;Bienen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8522.jpg;Nein;Familie;Weber;mail.weber.melanie@googlemail.com;;;;;;4QS8GPWR;7954462978689;3;;;0;0
|
||||
50006492;Musa;Yilmaz;;;;Bienen;;;;;;;;;Ömer;Yilmaz;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_8466.jpg;Nein;Familie;Yilmaz;merve-ymz@hotmail.com;;;;;;82YTD8FK;912359706713774;4;;;0;0
|
||||
49652523;Nina;Zhang;;;;Bienen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8174.jpg;Nein;Hua;Zhang;zhanghua0411@hotmail.com;;;;;;DTWR882P;201986576299456;3;;;0;1
|
||||
49663112;Elias;Bonifati;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6891.jpg;Nein;Familie;Misiano;bonifati@hotmail.de;;;;;;BVMZFPHK;582378219071480;5;;;0;0
|
||||
49652608;Mihael;Bungic;;;;Fische;;;;;;;;;;;;;Nein;Ja;Nein;;Nein;Mirela;"Marijan Bungic";m.bungic@web.de;;;;;;2664S6D3;848993713584313;1;;;0;0
|
||||
50248018;Alina;Catak;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6424.jpg;Nein;Catak;Admir;catakadmir@gmail.com;;;;;;ML42KL42;532022002681433;7;;;0;1
|
||||
50258123;Jakov;Ceko;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7294.jpg;Nein;Familie;Ceko;brankoceko91@gmail.com;;;;;;CVBYPKBV;550993218062367;0;;;0;0
|
||||
50258100;Luka;Ceko;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7043.jpg;Nein;Familie;Ceko;brankoceko91@gmail.com;;;;;;DJKP3CBY;120492680122927;0;;;0;0
|
||||
50222105;"Saide Mira";Cildir;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6617.jpg;Nein;Hasret;Cildir;hasretcildir@web.de;;;;;;M9B773FR;568587367870302;1;;;0;0
|
||||
50218962;Valentin;Gabauer;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7004.jpg;Nein;Sabine;Gabauer;sabine.gabauer@gmx.de;;;;;;QXWD3ZNS;377128045906117;2;;;0;1
|
||||
50079276;Anika;Gaßner;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6555.jpg;Nein;Familie;Karyakina;veronika20@hotmail.de;;;;;;3BSPHDHW;851569123151027;1;;;0;0
|
||||
50211546;Kilian;Glück;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6960.jpg;Nein;Familie;Glück;katja_glueck@web.de;;;;;;2LGTH3VN;783740741149373;2;;;0;0
|
||||
50282221;Lisa;Gumberger;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6470.jpg;Nein;Sarah;Gumberger;sarah.gumberger@gmx.de;;;;;;2PHHTXT9;383948464807600;3;;;0;1
|
||||
49616818;Nadine;Hamed;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6648.jpg;Nein;;Nadine;sarasadaby@gmail.com;;;;;;V3KTTVNV;319851445592837;3;;;0;1
|
||||
50208499;Noela;Islami;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6524.jpg;Nein;Familie;Islami;Zineta.islami@gmx.de;;;;;;CK5B7WWN;410922852620998;2;;;0;0
|
||||
49901895;Miroslav;Karpenko;;;;Fische;;;;;;;;;Salomia;Karpenko;;;Ja;Ja;Nein;IMG_6848.jpg;Nein;Familie;Karpenko;denis.k88@web.de;;;;;;922GC8BH;915901506033102;1;;;0;0
|
||||
50221939;Merjem;Kaukovic;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6502.jpg;Nein;Edita;Porcic-Kaukovic;editta1996@hotmail.com;;;;;;KGVKW8HZ;305550439054156;1;;;0;0
|
||||
50288355;Frauke;Klinge;;;;Fische;;;;;;;;;;;;;Nein;Ja;Nein;;Nein;Frauke;Klinge;fs.klinge@t-online.de;;;;;;K4GCTQJG;948438313552204;0;;;0;0
|
||||
49653461;Max;Krämer;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6778.jpg;Nein;Michael;Krämer;m.k326@web.de;;;;;;PF5DCT5N;929652051737843;2;;;0;1
|
||||
49663714;Casper;Mettig;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";;Nein;Familie;Roder;stephanie.roder@gmail.com;;;;;;4L2ZBL3M;296176221032781;6;;;0;0
|
||||
50208680;Emre;Mujic;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6927.jpg;Nein;Amra;Mujic;amra.mujic95@gmail.com;;;;;;QVBTGS73;16951665504680;2;;;0;0
|
||||
49635375;Mila;Nickl;;;;Fische;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6685.jpg;Nein;Familie;Nickl;cela990@hotmail.com;;;;;;S4Z3HPZY;736840096508400;8;;;0;0
|
||||
49575499;Mia;Rubinstein;;;;Fische;;;;;;;;;Mika;Rubinstein;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6443.jpg;Nein;Familie;Rubinstein;n.d.rubinstein@googlemail.com;;;;;;BGGHXNLC;908242966462743;3;;;0;0
|
||||
49786711;Zoe;Scholpp;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6721.jpg;Nein;Sabrina;Scholpp;sabrinasch1107@gmail.com;;;;;;CXMT4R9T;585861217320080;2;;;0;0
|
||||
49755578;Valerie;Schultze;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6743.jpg;Nein;Anita;Schultze;anitajuliane.schultze@gmail.com;;;;;;4WVF24SR;499960849175102;2;;;0;0
|
||||
50057518;Leonardo;Stachanczyk;;;;Fische;;;;;;;;;Letizia;Stachanczyk;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7072.jpg;Nein;Familie;Stachanczyk;Suzanna.Stachanczyk@web.de;;;;;;BZC28W7T;900613948231467;7;;;0;0
|
||||
50211898;Maya;Watanabe;;;;Fische;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6591.jpg;Nein;Barbara;Watanabe;barbara.j@live.de;;;;;;ZPVJ8R5Q;341636130475078;1;;;0;0
|
||||
50006491;Ömer;Yilmaz;;;;Fische;;;;;;;;;Musa;Yilmaz;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_6809.jpg;Nein;Familie;Yilmaz;merve-ymz@hotmail.com;;;;;;C4NB42PX;659038103936299;4;;;0;0
|
||||
50078572;Aurelia;Adelsberger;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7493.jpg;Nein;Familie;Adelsberger;barbara.adelsberger@yahoo.de;;Christian;Godelmann;floke.com@gmail.com;;3NVRB2BM;230676178020824;1;;;0;0
|
||||
50220084;Eymen;Baldir;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_6406.jpg;Nein;Seda;Baldir;seda.ay@icloud.com;;;;;;JDG5CDT8;381447885366279;1;;;0;0
|
||||
49602297;Magdalena;Bauer;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7463.jpg;Nein;Familie;Bauer;bonprix29@yahoo.de;;;;;;V7W5ZPVY;411274063112493;8;;;0;0
|
||||
49992146;Zoe;Cajic;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7414.jpg;Nein;Amar;Cajic;amar.cajic@gmail.com;;;;;;JMZD8SNN;53184897942750;3;;;0;0
|
||||
50057147;Mario;Cakic;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7182.jpg;Nein;Lucija;Zivkovic;lucija.zivkovic16@gmail.com;;;;;;D8SZH5XL;508565079338980;3;;;0;1
|
||||
50211538;Eray;Dogan;;;;Spatzen;;;;;;;;;Gökhan;Dogan;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7380.jpg;Nein;Familie;Dogan;goeksel_dogan@web.de;;;;;;LMLH64KS;61030672835452;2;;;0;0
|
||||
49552513;Antonia;Freiwald;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7643.jpg;Nein;Stephanie;Freiwald;stephanie.freiwald@gmx.de;;;;;;4BV76XQS;224897626646913;1;;;0;1
|
||||
49601982;Heidi;Götzberger;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7618.jpg;Nein;Familie;Götzberger;franziska.lanzinger@t-online.de;;;;;;KSH3Y552;141723213815881;2;;;0;0
|
||||
50063666;Una;Hodzic;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7561.jpg;Nein;Hodžić;Aldin;menager21@hotmail.com;;;;;;9W4CYMRX;170368609363861;4;;;0;1
|
||||
49603438;Liara;Honisch;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7519.jpg;Nein;Familie;Karayilan;yasemin.karayilan@yahoo.de;;;;;;MSNCXQ77;787504756287604;2;;;0;0
|
||||
49623482;Matteo;Katterfeld;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7347.jpg;Nein;Familie;Ketterfeld;madlen.katterfeld@gmx.de;;;;;;YJ9MM349;888691047601122;6;;;0;0
|
||||
49654260;Christoph;Klyszcz;;;;Spatzen;;;;;;;;;Jan;Klyszcz;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7320.jpg;Nein;Familie;Klyszcz;klyszcz.ewa92@gmail.com;;;;;;V3KSRPDM;389595058391936;6;;;0;0
|
||||
50056841;Ludwig;Lacen;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7246.jpg;Nein;Michael;Lacen;michael.lacen@gmx.de;;;;;;GJFNWHMY;205672235649590;2;;;0;1
|
||||
50056690;Emilia;Rodriguez;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7591.jpg;Nein;Daniela;Rodriguez;daniela-hinz-82@gmx.de;;;;;;9LQLW7YV;289213156745302;1;;;0;1
|
||||
49652595;Vaiana;Slaiman;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;;Nein;;Slaiman;hadeer94hasan@web.de;;;;;;YB24BVQR;230552964517174;0;;;0;0
|
||||
49838169;Raphael;Weber;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7214.jpg;Nein;Familie;Weber;mail.weber.melanie@googlemail.com;;;;;;W3VBKM3W;362639250675953;3;;;0;0
|
||||
49906413;Ludwig;Welz;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;"Familien- / Geschwisterfotos";IMG_7149.jpg;Nein;Familie;Welz;eva_welz@gmx.de;;;;;;VPJYZ48P;785597492180163;3;;;0;0
|
||||
49726920;Amy;Wieters;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7443.jpg;Nein;Janine;Wieters;janine28@gmx.de;;;;;;69KFXLBD;921506261142206;4;;;0;1
|
||||
50453287;Familie;Adelsberger;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0728.jpg;Nein;Familie;Adelsberger;barbara.adelsberger@yahoo.de;;;;;;JVXV9T2M;916908422224646;1;;;0;1
|
||||
50451311;Familie;"Al Khadher";;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0153.jpg;Nein;Familie;"Al Khadher";Husseinalkhadher8@gmail.com;;;;;;4VTSN5J6;437618998198555;2;;;0;1
|
||||
50453454;Familie;Bauer;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0353.jpg;Nein;Familie;Bauer;bonprix29@yahoo.de;;;;;;N8DZBDLW;169896993687826;2;;;0;0
|
||||
50491788;Familie;Baumgartner;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0451.jpg;Nein;Franziska;Baumgartner;franziwild@gmx.de;;;;;;ZRH36VRS;847462383118786;2;;;0;1
|
||||
50463803;Amla;Bobo;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7689.jpg;Nein;Xhulia;Xhelci;xhuliaxhelci@gmail.com;;;;;;C35CQ55V;950839783885570;1;;;0;0
|
||||
50451304;Familie;Ceko;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0099.jpg;Nein;Familie;Ceko;brankoceko91@gmail.com;;;;;;4NZXSHTW;798398153397116;2;;;0;0
|
||||
50453898;Familie;Cicek;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_2693.jpg;Nein;Familie;Cicek;uelke.ardak@hotmail.de;;;;;;YC9KVV76;290706892284805;3;;;0;1
|
||||
50453136;Familie;Dogan;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_9830.jpg;Nein;Familie;Dogan;goeksel_dogan@web.de;;;;;;GDBRDW6K;918773718877810;2;;;0;0
|
||||
50453529;Familie;Gabauer;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0286.jpg;Nein;Familie;Gabauer;luzia.gabauer@web.de;;;;;;SQHNHMH6;49585341392454;6;;;0;1
|
||||
50452028;Familie;Glück;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0406.jpg;Nein;Familie;Glück;katja_glueck@web.de;;;;;;7WTXJNDC;628871407778415;2;;;0;0
|
||||
50453448;Familie;Götzberger;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1262.jpg;Nein;Familie;Götzberger;franziska.lanzinger@t-online.de;;;;;;MPHRG7SP;954770009741299;2;;;0;1
|
||||
50453434;Familie;Islami;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1330.jpg;Nein;Familie;Islami;Zineta.islami@gmx.de;;;;;;KF7CNCYZ;620178179159158;2;;;0;0
|
||||
50452019;Familie;Karayilan;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0200.jpg;Nein;Familie;Karayilan;yasemin.karayilan@yahoo.de;;;;;;6H73JV6B;765446752804075;1;;;0;0
|
||||
50453439;Familie;Karpenko;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_2825.jpg;Nein;Familie;Karpenko;denis.k88@web.de;;;;;;LR87SQ8C;963707649418838;1;;;0;0
|
||||
50453495;Familie;Karyakina;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0800.jpg;Nein;Familie;Karyakina;veronika20@hotmail.de;;;;;;RYK4BQLQ;638219110542782;1;;;0;0
|
||||
50453488;Familie;Ketterfeld;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1211.jpg;Nein;Familie;Ketterfeld;madlen.katterfeld@gmx.de;;;;;;PLX9G4V3;117752011222601;6;;;0;1
|
||||
50453446;Familie;Klyszcz;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_2740.jpg;Nein;Familie;Klyszcz;klyszcz.ewa92@gmail.com;;;;;;LZR8WFP9;874820410323668;9;;;0;1
|
||||
50452151;Familie;Misiano;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1577.jpg;Nein;Familie;Misiano;bonifati@hotmail.de;;;;;;7ZW9V666;394112462489259;5;;;0;1
|
||||
50451284;Familie;Nickl;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1054.jpg;Nein;Familie;Nickl;cela990@hotmail.com;;;;;;35CH589Q;438824910404667;8;;;0;1
|
||||
50452230;Familie;Roder;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1138.jpg;Nein;Familie;Roder;stephanie.roder@gmail.com;;;;;;848D3SWY;745487201848290;6;;;0;0
|
||||
50452913;Familie;Rubinstein;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0041.jpg;Nein;Familie;Rubinstein;n.d.rubinstein@googlemail.com;;;;;;G9H8YFC4;180523151134386;1;;;0;1
|
||||
50453768;Familie;Schillinger;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1404.jpg;Nein;Familie;Schillinger;schneggeno1@web.de;;;;;;SQJSP49C;593384265020703;3;;;0;1
|
||||
50452236;Familie;Schlesinger;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1512.jpg;Nein;Familie;Schlesinger;stefanie2011@gmx.net;;;;;;946G6HJH;269413107409936;3;;;0;1
|
||||
50453894;Familie;Schmid;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0625.jpg;Nein;Familie;Schmid;izuther@googlemail.com;;;;;;W7W8P32C;486167508950250;4;;;0;1
|
||||
50452248;Familie;Schreibauer;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0671.jpg;Nein;Familie;Schreibauer;a.schreibauer@gmail.com;;;;;;97R4TRBC;130440825414681;3;;;0;0
|
||||
50452273;Familie;Stachanczyk;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_9974.jpg;Nein;Familie;Stachanczyk;Suzanna.Stachanczyk@web.de;;;;;;C334SSSL;733864213043388;5;;;0;0
|
||||
50453485;Familie;Torres;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0967.jpg;Nein;Familie;Torres;ftorrestapia@me.com;;;;;;PCQ4CNV9;553742663210606;4;;;0;0
|
||||
50452252;Familie;Tuldi;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_2788.jpg;Nein;Familie;Tuldi;olga_tuldi@yahoo.de;;;;;;B99BYYYF;657381798122682;11;;;0;0
|
||||
50452022;Familie;Weber;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0984.jpg;Nein;Familie;Weber;mail.weber.melanie@googlemail.com;;;;;;7954G4C5;820357028620317;3;;;0;1
|
||||
50451320;Familie;Welz;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_1459.jpg;Nein;Familie;Welz;eva_welz@gmx.de;;;;;;69N5WYFK;952025141929986;3;;;0;1
|
||||
50452419;Familie;Wild;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0539.jpg;Nein;Familie;Wild;wildramona@gmx.de;;;;;;DVXKKJCZ;789239059675168;9;;;0;1
|
||||
50452462;Familie;Wolf;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_0841.jpg;Nein;Familie;Wolf;anjamichi77@gmail.com;;;;;;FXPLQYH9;784676508389646;1;;;0;0
|
||||
50410050;Jonas;Wolf;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_8489.jpg;Nein;Familie;Wolf;anjamichi77@gmail.com;;;;;;Q52NYB4N;18810570193338;0;;;0;0
|
||||
50453882;Familie;Yilmaz;;;;;;;;;;;;;;;;;Ja;Ja;Nein;IMG_9907.jpg;Nein;Familie;Yilmaz;merve-ymz@hotmail.com;;;;;;TXK86QSB;467856804734432;4;;;0;0
|
||||
49655787;Joseph;Wild;;;;Spatzen;;;;;;;;;;;;;Ja;Ja;Nein;IMG_7119.jpg;Nein;Familie;Wild;wildramona@gmx.de;;;;;;8C56662R;226166391326912;5;;;0;0
|
||||
|
BIN
ARCHIVE_vor_migration/Fotograf.de/logo_kinderfotos_erding.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
16
GEMINI.md
@@ -58,6 +58,22 @@ Das System läuft stabil und ist für den Produktivbetrieb vorbereitet. Wesentli
|
||||
### 5. DuckDNS & DNS Monitor
|
||||
* **Erfolgreich reaktiviert:** Der DynDNS-Service läuft und aktualisiert die IP, die Netzwerk-Konnektivität ist stabil.
|
||||
|
||||
### 6. Fotograf.de Scraper (In Development)
|
||||
* **Architektur:** Ein neuer, eigenständiger Microservice wurde unter `/fotograf-de-scraper` angelegt. FastAPI-Backend (Python/Selenium) + React-Frontend (TypeScript/Vite/Tailwind).
|
||||
* **Status (20. März 2026):**
|
||||
* **Grundgerüst & Login:** Erfolgreich implementiert. Der Selenium-Bot loggt sich stabil ein.
|
||||
* **Auftragsliste:** Das Abrufen und Cachen der Aufträge (`/config_jobs/index`) im Frontend funktioniert.
|
||||
* **Feature 1 (Teilnehmerliste PDF): ERFOLGREICH.** Der Scraper navigiert zum Auftrag, lädt die CSV-Anmeldeliste versteckt herunter, formatiert sie via WeasyPrint/Jinja2 (Logik aus List-Generator übernommen) und liefert das finale PDF an den Browser aus.
|
||||
* **Frontend UI:** Erste Version eines kachelbasierten Dashboards mit Detail-Modal implementiert. (Benötigt weiteres Feintuning nach User-Feedback).
|
||||
* **Kritischer Blocker (Infrastruktur):**
|
||||
* **Datenbank-Persistenz fehlerhaft:** Trotz Umstellung der `docker-compose.yml` auf lokale Bind-Mounts (`./company-explorer/data:/data`, etc.) sind die SQLite-Datenbanken nach einem Container-Rebuild (`--force-recreate`) leer.
|
||||
* *Verdacht:* Rechteprobleme auf der Synology Diskstation oder Initialisierungsskripte im Container, die die DB bei jedem Start überschreiben. **Muss offline vom User auf Host-Ebene geprüft werden.**
|
||||
* **Nächste Schritte (Für die nächste Session):**
|
||||
1. **Infrastruktur:** Verifizierung, dass das DB-Persistenz-Problem gelöst ist.
|
||||
2. **Feature 4 (Statistik):** Reaktivierung der bestehenden Skript-Logik (`process_statistics_mode`) zur Auswertung der Verkaufszahlen. Integration in einen neuen API-Endpunkt und Anzeige im Frontend-Modal.
|
||||
3. **Feature 3 (Nachfass-Emails):** Reaktivierung der bestehenden Logik (`process_reminder_mode`).
|
||||
4. **Feature 2 (QR-Karten):** Backend-Endpunkt zur PDF-Manipulation (Overlay von Text) basierend auf CSV/Calendly-Daten.
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow & Conventions
|
||||
|
||||
25
check_db_links.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "/app/fotograf-de-scraper/backend/data/fotograf_jobs.db"
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found at {db_path}")
|
||||
else:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check candidates missing links for the current job
|
||||
job_id = "576228454"
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM job_participants
|
||||
WHERE job_id = ?
|
||||
AND has_orders = 0
|
||||
AND digital_package_ordered = 0
|
||||
AND logins <= 5
|
||||
AND quick_login_url IS NULL
|
||||
""", (job_id,))
|
||||
missing = cursor.fetchone()[0]
|
||||
print(f"Missing links for candidates in job {job_id}: {missing}")
|
||||
|
||||
conn.close()
|
||||
9
check_tables.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import sqlite3
|
||||
|
||||
db_path = "/app/fotograf-de-scraper/backend/data/fotograf_jobs.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = cursor.fetchall()
|
||||
print(f"Tables: {[t[0] for t in tables]}")
|
||||
conn.close()
|
||||
@@ -133,18 +133,18 @@
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<!-- B2B Marketing Assistant -->
|
||||
<!-- B2B Marketing Assistant (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">🚀</span>
|
||||
<h2>B2B Marketing Assistant</h2>
|
||||
<p>
|
||||
KI-gestützte Analyse von Unternehmens-Websites zur Erstellung von Personas, Pain-Points und Marketing-Botschaften.
|
||||
</p>
|
||||
<!-- WICHTIG: Relativer Link für Reverse Proxy -->
|
||||
<a href="/b2b/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- General Market Intelligence -->
|
||||
<!-- General Market Intelligence (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">📊</span>
|
||||
<h2>Market Intelligence</h2>
|
||||
@@ -152,22 +152,22 @@
|
||||
Allgemeine Marktanalyse und Recherche-Tool.
|
||||
Nutzt Web-Scraping und KI für tiefe Einblicke.
|
||||
</p>
|
||||
<!-- WICHTIG: Relativer Link für Reverse Proxy -->
|
||||
<a href="/market/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- GTM Architect -->
|
||||
<!-- GTM Architect (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">🏛️</span>
|
||||
<h2>GTM Architect</h2>
|
||||
<p>
|
||||
Entwickelt eine komplette Go-to-Market-Strategie für neue technische Produkte, von der Analyse bis zum Sales-Kit.
|
||||
</p>
|
||||
<!-- WICHTIG: Relativer Link für Reverse Proxy -->
|
||||
<a href="/gtm/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Content Engine -->
|
||||
<!-- Content Engine (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">✍️</span>
|
||||
<h2>Content Engine</h2>
|
||||
@@ -176,19 +176,19 @@
|
||||
</p>
|
||||
<a href="/content/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Company Explorer (Robotics) -->
|
||||
<!-- Company Explorer -->
|
||||
<div class="card">
|
||||
<span class="card-icon">🤖</span>
|
||||
<h2>Company Explorer</h2>
|
||||
<p>
|
||||
Das zentrale CRM-Data-Mining Tool. Importieren, Deduplizieren und Anreichern von Firmenlisten mit Fokus auf Robotik-Potential.
|
||||
</p>
|
||||
<!-- Jetzt direkt zum Frontend -->
|
||||
<a href="/ce/" class="btn">Starten →</a>
|
||||
</div>
|
||||
|
||||
<!-- Competitor Analysis Agent -->
|
||||
<!-- Competitor Analysis Agent (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">⚔️</span>
|
||||
<h2>Competitor Analysis</h2>
|
||||
@@ -197,8 +197,9 @@
|
||||
</p>
|
||||
<a href="/competitor/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Lead Engine: TradingTwins -->
|
||||
<!-- Lead Engine: TradingTwins (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">📈</span>
|
||||
<h2>Lead Engine: TradingTwins</h2>
|
||||
@@ -207,6 +208,7 @@
|
||||
</p>
|
||||
<a href="/lead/" class="btn" target="_blank">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Meeting Assistant (Transcription) -->
|
||||
<div class="card">
|
||||
@@ -218,7 +220,7 @@
|
||||
<a href="/tr/" class="btn">Starten →</a>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap Tool -->
|
||||
<!-- Heatmap Tool (Inactive)
|
||||
<div class="card">
|
||||
<span class="card-icon">🗺️</span>
|
||||
<h2>Heatmap Tool</h2>
|
||||
@@ -227,10 +229,21 @@
|
||||
</p>
|
||||
<a href="/heatmap/" class="btn">Starten →</a>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Fotograf.de Scraper -->
|
||||
<div class="card">
|
||||
<span class="card-icon">📸</span>
|
||||
<h2>Fotograf.de ERP</h2>
|
||||
<p>
|
||||
Automatisierter Workflow zum Download und Formatieren der Anmeldelisten von fotograf.de als sortiertes PDF.
|
||||
</p>
|
||||
<a href="/fotograf-de/" class="btn">Starten →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
© 2025 Local AI Suite | Secured Access
|
||||
© 2026 Local AI Suite | Secured Access
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
128
dev_session.py
@@ -394,6 +394,9 @@ def select_project(token: str) -> Optional[Tuple[Dict, Optional[str]]]:
|
||||
print("Keine Projekte in der Datenbank gefunden.")
|
||||
return None, None
|
||||
|
||||
# Sortiere Projekte alphabetisch nach Titel
|
||||
projects.sort(key=lambda p: get_page_title(p).lower())
|
||||
|
||||
print("\nAn welchem Projekt möchtest du arbeiten?")
|
||||
for i, project in enumerate(projects):
|
||||
print(f"[{i+1}] {get_page_title(project)}")
|
||||
@@ -428,6 +431,7 @@ def select_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]
|
||||
print(f"[{i+1}] {get_page_title(task)}")
|
||||
|
||||
print(f"[{len(tasks)+1}] Neuen Task für dieses Projekt erstellen")
|
||||
print("[0] Zurück zur Projektauswahl")
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -436,6 +440,8 @@ def select_task(token: str, project_id: str, tasks_db_id: str) -> Optional[Dict]
|
||||
return tasks[choice - 1]
|
||||
elif choice == len(tasks) + 1:
|
||||
return {"id": "new_task"} # Signal
|
||||
elif choice == 0:
|
||||
return {"id": "go_back"} # Signal
|
||||
else:
|
||||
print("Ungültige Auswahl.")
|
||||
except ValueError:
|
||||
@@ -450,34 +456,6 @@ BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
|
||||
# --- Git Summary Generation ---
|
||||
|
||||
def generate_git_summary() -> Tuple[str, str]:
|
||||
"""Generiert eine Zusammenfassung der Git-Änderungen und Commit-Nachrichten seit dem letzten Push zum Main-Branch."""
|
||||
try:
|
||||
# Finde den aktuellen Main-Branch Namen (master oder main)
|
||||
try:
|
||||
main_branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode("utf-8").strip()
|
||||
if main_branch not in ["main", "master"]:
|
||||
# Versuche, den Remote-Tracking-Branch für main/master zu finden
|
||||
result = subprocess.run(["git", "branch", "-r"], capture_output=True, text=True)
|
||||
if "origin/main" in result.stdout:
|
||||
main_branch = "origin/main"
|
||||
elif "origin/master" in result.stdout:
|
||||
main_branch = "origin/master"
|
||||
else:
|
||||
print("Warnung: Konnte keinen 'main' oder 'master' Branch finden. Git-Zusammenfassung wird möglicherweise unvollständig sein.")
|
||||
main_branch = "HEAD~1" # Fallback zum letzten Commit, falls kein Main-Branch gefunden wird
|
||||
except subprocess.CalledProcessError:
|
||||
main_branch = "HEAD~1" # Fallback, falls gar kein Branch gefunden wird
|
||||
|
||||
# Git log --pretty
|
||||
commit_log_cmd = ["git", "log", "--pretty=format:- %s", f"{main_branch}...HEAD"]
|
||||
commit_messages = subprocess.check_output(commit_log_cmd).decode("utf-8").strip()
|
||||
|
||||
return "", commit_messages
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ FEHLER beim Generieren der Git-Zusammenfassung: {e}")
|
||||
return "", ""
|
||||
|
||||
def git_push_with_retry() -> bool:
|
||||
"""Versucht, Änderungen zu pushen, und führt bei einem non-fast-forward-Fehler einen Rebase und erneuten Push durch."""
|
||||
print("\n--- Führe git push aus ---")
|
||||
@@ -527,7 +505,9 @@ def report_status_to_notion(
|
||||
session_data = json.load(f)
|
||||
task_id = session_data.get("task_id")
|
||||
token = session_data.get("token")
|
||||
readme_path = session_data.get("readme_path", "readme.md") # Lade den Readme-Pfad
|
||||
readme_path = session_data.get("readme_path") # Lade den Readme-Pfad
|
||||
if readme_path is None:
|
||||
readme_path = "readme.md"
|
||||
|
||||
if not (task_id and token):
|
||||
print("❌ FEHLER: Session-Daten unvollständig. Kann keinen Statusbericht erstellen.")
|
||||
@@ -560,22 +540,13 @@ def report_status_to_notion(
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ FEHLER beim Abrufen der Task-Details für die Zeiterfassung: {e}")
|
||||
|
||||
print(f"--- Erstelle automatischen Statusbericht für Task {task_id} ---")
|
||||
print(f"--- Erstelle Statusbericht für Task {task_id} ---")
|
||||
|
||||
# Git-Zusammenfassung generieren
|
||||
print("Generiere Git-Zusammenfassung...")
|
||||
_, commit_log = generate_git_summary()
|
||||
actual_commit_messages = commit_log
|
||||
|
||||
# Summary automatisch aus Commit-Nachrichten erstellen
|
||||
actual_summary = summary_override or "Keine Zusammenfassung angegeben."
|
||||
if not summary_override:
|
||||
_, commit_log = generate_git_summary()
|
||||
summary_from_commits = commit_log.replace("- ", "✦ ").strip()
|
||||
if summary_from_commits:
|
||||
actual_summary = summary_from_commits
|
||||
else:
|
||||
actual_summary = "Keine neuen Commits in dieser Session."
|
||||
|
||||
# Bessere Lesbarkeit für Notion durch explizite Zeilenumbrüche vor nummerierten Listen
|
||||
# Formatiert " 1. " zu einem Zeilenumbruch und einer Einrückung
|
||||
formatted_summary = re.sub(r'\s+(\d+\.)\s', r'\n\n \1 ', actual_summary)
|
||||
|
||||
# Kommentar zusammenstellen
|
||||
report_lines = []
|
||||
@@ -584,7 +555,7 @@ def report_status_to_notion(
|
||||
report_lines.append(f"Investierte Zeit in dieser Session: {elapsed_hhmm}")
|
||||
|
||||
report_lines.append("\nArbeitszusammenfassung:")
|
||||
report_lines.append(actual_summary)
|
||||
report_lines.append(formatted_summary)
|
||||
|
||||
report_content = "\n".join(report_lines)
|
||||
|
||||
@@ -598,6 +569,19 @@ def report_status_to_notion(
|
||||
# Notion aktualisieren (nur Kommentar/Block, kein Status)
|
||||
append_blocks_to_notion_page(token, task_id, notion_blocks)
|
||||
|
||||
# Update der Readme.md Datei
|
||||
if readme_path and os.path.exists(readme_path):
|
||||
print(f"Hänge Status-Update an {readme_path} an...")
|
||||
try:
|
||||
with open(readme_path, "a", encoding="utf-8") as rf:
|
||||
rf.write(f"\n\n## 🤖 Status-Update ({timestamp} Berlin Time)\n")
|
||||
rf.write(f"```yaml\n{report_content}\n```\n")
|
||||
print(f"✅ Status-Update an '{readme_path}' angehängt.")
|
||||
except Exception as e:
|
||||
print(f"❌ FEHLER beim Anhängen an '{readme_path}': {e}")
|
||||
else:
|
||||
print(f"⚠️ Readme-Pfad '{readme_path}' nicht gefunden. Überspringe lokales Doku-Update.")
|
||||
|
||||
# --- Doku & Git Operationen ---
|
||||
print("\n--- Führe Git-Operationen aus ---")
|
||||
try:
|
||||
@@ -652,7 +636,8 @@ def generate_cli_context(project_title: str, task_title: str, task_id: str, read
|
||||
description_part = ""
|
||||
if task_description:
|
||||
description_part = (
|
||||
f"\n**Aufgabenbeschreibung:**\n"
|
||||
f"\n**Aufgabenbeschreibung (inkl. Historie):**\n"
|
||||
f"*(Hinweis: Bei längeren Tasks befinden sich die aktuellsten und relevantesten Status-Updates am Ende dieser Beschreibung. Ursprüngliche Probleme sind möglicherweise bereits gelöst. Priorisiere immer die jüngsten Informationen!)*\n"
|
||||
f"```\n{task_description}\n```\n"
|
||||
)
|
||||
|
||||
@@ -748,30 +733,37 @@ def start_interactive_session():
|
||||
print("Kein Token angegeben. Abbruch.")
|
||||
return
|
||||
|
||||
selected_project, readme_path = select_project(token)
|
||||
if not selected_project:
|
||||
return
|
||||
|
||||
project_title = get_page_title(selected_project)
|
||||
print(f"\nProjekt '{project_title}' ausgewählt.")
|
||||
|
||||
tasks_db_id = find_database_by_title(token, "Tasks [UT]")
|
||||
if not tasks_db_id:
|
||||
return
|
||||
|
||||
user_choice = select_task(token, selected_project["id"], tasks_db_id)
|
||||
|
||||
if not user_choice:
|
||||
print("Kein Task ausgewählt. Abbruch.")
|
||||
return
|
||||
|
||||
selected_task = None
|
||||
if user_choice.get("id") == "new_task":
|
||||
selected_task = create_new_notion_task(token, selected_project["id"], tasks_db_id)
|
||||
if not selected_task:
|
||||
while True:
|
||||
selected_project, readme_path = select_project(token)
|
||||
if not selected_project:
|
||||
return
|
||||
else:
|
||||
selected_task = user_choice
|
||||
|
||||
project_title = get_page_title(selected_project)
|
||||
print(f"\nProjekt '{project_title}' ausgewählt.")
|
||||
|
||||
tasks_db_id = find_database_by_title(token, "Tasks [UT]")
|
||||
if not tasks_db_id:
|
||||
return
|
||||
|
||||
user_choice = select_task(token, selected_project["id"], tasks_db_id)
|
||||
|
||||
if not user_choice:
|
||||
print("Kein Task ausgewählt. Abbruch.")
|
||||
return
|
||||
|
||||
if user_choice.get("id") == "go_back":
|
||||
print("\nGehe zurück zur Projektauswahl...")
|
||||
continue
|
||||
|
||||
selected_task = None
|
||||
if user_choice.get("id") == "new_task":
|
||||
selected_task = create_new_notion_task(token, selected_project["id"], tasks_db_id)
|
||||
if not selected_task:
|
||||
return
|
||||
else:
|
||||
selected_task = user_choice
|
||||
|
||||
break # Exit the loop once a valid task is selected
|
||||
|
||||
task_title = get_page_title(selected_task)
|
||||
task_id = selected_task["id"]
|
||||
|
||||
58
docker-compose.ce-v2.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
# TEMPORARY DOCKER-COMPOSE FOR STARTING COMPANY-EXPLORER - V2
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80"
|
||||
volumes:
|
||||
- ./nginx-proxy-ce.conf:/etc/nginx/nginx.conf:ro # Use the cleaned config
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
|
||||
# --- DASHBOARD ---
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
container_name: dashboard
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
|
||||
# --- APPS ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
DATABASE_URL: "sqlite:////data/companies_v3_fixed_2.db"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
- ./company-explorer:/app
|
||||
- explorer_db_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
explorer_db_data: {}
|
||||
66
docker-compose.ce.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
# TEMPORARY DOCKER-COMPOSE FOR STARTING COMPANY-EXPLORER
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80"
|
||||
volumes:
|
||||
- ./nginx-proxy-clean.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
|
||||
# --- DASHBOARD ---
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
container_name: dashboard
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
|
||||
# --- APPS ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
DATABASE_URL: "sqlite:////data/companies_v3_fixed_2.db"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
- ./company-explorer:/app
|
||||
- explorer_db_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
connector_db_data: {}
|
||||
explorer_db_data: {}
|
||||
lead_engine_data: {}
|
||||
gtm_architect_data: {}
|
||||
b2b_marketing_data: {}
|
||||
transcription_uploads: {}
|
||||
content_engine_data: {}
|
||||
competitor_analysis_data: {}
|
||||
market_intel_data: {}
|
||||
96
docker-compose.minimal.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80"
|
||||
volumes:
|
||||
- ./nginx-proxy.minimal.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
lead-engine:
|
||||
condition: service_started
|
||||
transcription-tool:
|
||||
condition: service_started
|
||||
|
||||
# --- APPS ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
DATABASE_URL: "sqlite:////data/companies_v3_fixed_2.db"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
- ./company-explorer:/app
|
||||
- explorer_db_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
lead-engine:
|
||||
build:
|
||||
context: ./lead-engine
|
||||
dockerfile: Dockerfile
|
||||
container_name: lead-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8501:8501" # UI (Streamlit)
|
||||
- "8004:8004" # API / Monitor
|
||||
- "8099:8004" # Direct Test Port
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API: "${SERP_API}"
|
||||
INFO_Application_ID: "${INFO_Application_ID}"
|
||||
INFO_Tenant_ID: "${INFO_Tenant_ID}"
|
||||
INFO_Secret: "${INFO_Secret}"
|
||||
CAL_APPID: "${CAL_APPID}"
|
||||
CAL_SECRET: "${CAL_SECRET}"
|
||||
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
|
||||
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
|
||||
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
|
||||
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
|
||||
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
|
||||
volumes:
|
||||
- ./lead-engine:/app
|
||||
- lead_engine_data:/app/data
|
||||
|
||||
transcription-tool:
|
||||
build:
|
||||
context: ./transcription-tool
|
||||
dockerfile: Dockerfile
|
||||
container_name: transcription-tool
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
volumes:
|
||||
- transcription_uploads:/app/uploads
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
|
||||
volumes:
|
||||
explorer_db_data: {}
|
||||
lead_engine_data: {}
|
||||
transcription_uploads: {}
|
||||
11
docker-compose.test-override.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
lead-engine:
|
||||
condition: service_started
|
||||
89
docker-compose.test.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- GATEKEEPER (NGINX) ---
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: gateway_proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:80"
|
||||
volumes:
|
||||
- ./nginx-proxy-test.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
dashboard:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
lead-engine:
|
||||
condition: service_started
|
||||
|
||||
# --- DASHBOARD (Required by Nginx) ---
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
container_name: dashboard
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
|
||||
# --- COMPANY-EXPLORER (Required by Lead-Engine) ---
|
||||
company-explorer:
|
||||
build:
|
||||
context: ./company-explorer
|
||||
dockerfile: Dockerfile
|
||||
container_name: company-explorer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
API_USER: "admin"
|
||||
API_PASSWORD: "gemini"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
DATABASE_URL: "sqlite:////data/companies_v3_fixed_2.db"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
- ./company-explorer:/app
|
||||
- explorer_db_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# --- LEAD-ENGINE (Our Webhook Service) ---
|
||||
lead-engine:
|
||||
build:
|
||||
context: ./lead-engine
|
||||
dockerfile: Dockerfile
|
||||
container_name: lead-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8501:8501" # UI (Streamlit)
|
||||
- "8004:8004" # API / Monitor
|
||||
- "8099:8004" # Direct Test Port
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API: "${SERP_API}"
|
||||
INFO_Application_ID: "${INFO_Application_ID}"
|
||||
INFO_Tenant_ID: "${INFO_Tenant_ID}"
|
||||
INFO_Secret: "${INFO_Secret}"
|
||||
CAL_APPID: "${CAL_APPID}"
|
||||
CAL_SECRET: "${CAL_SECRET}"
|
||||
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
|
||||
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
|
||||
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
|
||||
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
|
||||
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
|
||||
volumes:
|
||||
- ./lead-engine:/app
|
||||
- lead_engine_data:/app/data
|
||||
|
||||
volumes:
|
||||
explorer_db_data: {}
|
||||
lead_engine_data: {}
|
||||
20
docker-compose.tr.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
transcription-tool:
|
||||
build:
|
||||
context: ./transcription-tool
|
||||
dockerfile: Dockerfile
|
||||
container_name: transcription-tool-standalone
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
volumes:
|
||||
- transcription_uploads:/app/uploads
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
|
||||
volumes:
|
||||
transcription_uploads: {}
|
||||
@@ -19,23 +19,25 @@ services:
|
||||
condition: service_started
|
||||
company-explorer:
|
||||
condition: service_healthy
|
||||
connector-superoffice:
|
||||
condition: service_healthy
|
||||
lead-engine:
|
||||
condition: service_started
|
||||
gtm-architect:
|
||||
condition: service_started
|
||||
b2b-marketing-assistant:
|
||||
condition: service_started
|
||||
# connector-superoffice:
|
||||
# condition: service_healthy
|
||||
# lead-engine:
|
||||
# condition: service_started
|
||||
# gtm-architect:
|
||||
# condition: service_started
|
||||
# b2b-marketing-assistant:
|
||||
# condition: service_started
|
||||
transcription-tool:
|
||||
condition: service_started
|
||||
heatmap-frontend:
|
||||
condition: service_started
|
||||
competitor-analysis:
|
||||
condition: service_started
|
||||
content-engine:
|
||||
condition: service_started
|
||||
market-intelligence:
|
||||
# heatmap-frontend:
|
||||
# condition: service_started
|
||||
# competitor-analysis:
|
||||
# condition: service_started
|
||||
# content-engine:
|
||||
# condition: service_started
|
||||
# market-intelligence:
|
||||
# condition: service_started
|
||||
fotograf-de-scraper-frontend:
|
||||
condition: service_started
|
||||
|
||||
# --- DASHBOARD ---
|
||||
@@ -47,60 +49,60 @@ services:
|
||||
- ./dashboard:/usr/share/nginx/html:ro
|
||||
|
||||
# --- APPS ---
|
||||
market-intelligence:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: general-market-intelligence/Dockerfile.fullstack
|
||||
container_name: market-intelligence
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8098:3001"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API_KEY: "${SERP_API}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
volumes:
|
||||
- market_intel_data:/data
|
||||
- ./Log_from_docker:/app/Log
|
||||
# market-intelligence:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: general-market-intelligence/Dockerfile.fullstack
|
||||
# container_name: market-intelligence
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8098:3001"
|
||||
# environment:
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SERP_API_KEY: "${SERP_API}"
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# volumes:
|
||||
# - market_intel_data:/data
|
||||
# - ./Log_from_docker:/app/Log
|
||||
|
||||
content-engine:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: content-engine/Dockerfile
|
||||
container_name: content-engine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8093:3000"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
GTM_DB_PATH: "/gtm_data/gtm_projects.db"
|
||||
CONTENT_DB_PATH: "/data/content_engine.db"
|
||||
volumes:
|
||||
- content_engine_data:/data
|
||||
- gtm_architect_data:/gtm_data:ro
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3006"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
# content-engine:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: content-engine/Dockerfile
|
||||
# container_name: content-engine
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8093:3000"
|
||||
# environment:
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# GTM_DB_PATH: "/gtm_data/gtm_projects.db"
|
||||
# CONTENT_DB_PATH: "/data/content_engine.db"
|
||||
# volumes:
|
||||
# - content_engine_data:/data
|
||||
# - gtm_architect_data:/gtm_data:ro
|
||||
# - ./Log_from_docker:/app/logs_debug
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:3006"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# start_period: 30s
|
||||
|
||||
competitor-analysis:
|
||||
build:
|
||||
context: ./competitor-analysis-app
|
||||
dockerfile: Dockerfile
|
||||
container_name: competitor-analysis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8097:3000"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
volumes:
|
||||
- competitor_analysis_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
# competitor-analysis:
|
||||
# build:
|
||||
# context: ./competitor-analysis-app
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: competitor-analysis
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8097:3000"
|
||||
# environment:
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# volumes:
|
||||
# - competitor_analysis_data:/data
|
||||
# - ./Log_from_docker:/app/logs_debug
|
||||
|
||||
transcription-tool:
|
||||
build:
|
||||
@@ -117,58 +119,58 @@ services:
|
||||
- transcription_uploads:/app/uploads
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
|
||||
heatmap-backend:
|
||||
build:
|
||||
context: ./heatmap-tool/backend
|
||||
container_name: heatmap-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8002:8000"
|
||||
environment:
|
||||
ORS_API_KEY: "${ORS_API_KEY}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# heatmap-backend:
|
||||
# build:
|
||||
# context: ./heatmap-tool/backend
|
||||
# container_name: heatmap-backend
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8002:8000"
|
||||
# environment:
|
||||
# ORS_API_KEY: "${ORS_API_KEY}"
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
|
||||
heatmap-frontend:
|
||||
build:
|
||||
context: ./heatmap-tool/frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: heatmap-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:80"
|
||||
depends_on:
|
||||
- heatmap-backend
|
||||
# heatmap-frontend:
|
||||
# build:
|
||||
# context: ./heatmap-tool/frontend
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: heatmap-frontend
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8096:80"
|
||||
# depends_on:
|
||||
# - heatmap-backend
|
||||
|
||||
b2b-marketing-assistant:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: b2b-marketing-assistant/Dockerfile
|
||||
container_name: b2b-marketing-assistant
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8092:3002"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
volumes:
|
||||
- b2b_marketing_data:/data
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
# b2b-marketing-assistant:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: b2b-marketing-assistant/Dockerfile
|
||||
# container_name: b2b-marketing-assistant
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8092:3002"
|
||||
# environment:
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# volumes:
|
||||
# - b2b_marketing_data:/data
|
||||
# - ./Log_from_docker:/app/logs_debug
|
||||
|
||||
gtm-architect:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gtm-architect/Dockerfile
|
||||
container_name: gtm-architect
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8094:80"
|
||||
environment:
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
VITE_API_BASE_URL: "/gtm/api"
|
||||
GTM_DB_PATH: "/data/gtm_projects.db"
|
||||
volumes:
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
- gtm_architect_data:/data
|
||||
# gtm-architect:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: gtm-architect/Dockerfile
|
||||
# container_name: gtm-architect
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8094:80"
|
||||
# environment:
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# VITE_API_BASE_URL: "/gtm/api"
|
||||
# GTM_DB_PATH: "/data/gtm_projects.db"
|
||||
# volumes:
|
||||
# - ./Log_from_docker:/app/logs_debug
|
||||
# - gtm_architect_data:/data
|
||||
|
||||
company-explorer:
|
||||
build:
|
||||
@@ -188,7 +190,7 @@ services:
|
||||
NOTION_TOKEN: "${NOTION_API_KEY}"
|
||||
volumes:
|
||||
- ./company-explorer:/app
|
||||
- explorer_db_data:/data
|
||||
- ./company-explorer/data:/data # Local bind mount for guaranteed persistence
|
||||
- ./Log_from_docker:/app/logs_debug
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
@@ -197,69 +199,92 @@ services:
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
connector-superoffice:
|
||||
# connector-superoffice:
|
||||
# build:
|
||||
# context: ./connector-superoffice
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: connector-superoffice
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8003:8000"
|
||||
# volumes:
|
||||
# - ./connector-superoffice:/app
|
||||
# - ./connector-superoffice/data:/data # Persistent local DB storage
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SO_CLIENT_ID: "${SO_CLIENT_ID}"
|
||||
# SO_CLIENT_SECRET: "${SO_CLIENT_SECRET}"
|
||||
# SO_REFRESH_TOKEN: "${SO_REFRESH_TOKEN}"
|
||||
# SO_ENVIRONMENT: "${SO_ENVIRONMENT}"
|
||||
# SO_CONTEXT_IDENTIFIER: "${SO_CONTEXT_IDENTIFIER}"
|
||||
# WEBHOOK_TOKEN: "${WEBHOOK_TOKEN}"
|
||||
# WEBHOOK_SECRET: "${WEBHOOK_SECRET}"
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
# start_period: 30s
|
||||
|
||||
# lead-engine:
|
||||
# build:
|
||||
# context: ./lead-engine
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: lead-engine
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8501:8501" # UI (Streamlit)
|
||||
# - "8004:8004" # API / Monitor
|
||||
# - "8099:8004" # Direct Test Port
|
||||
# environment:
|
||||
# PYTHONUNBUFFERED: "1"
|
||||
# GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
# SERP_API: "${SERP_API}"
|
||||
# INFO_Application_ID: "${INFO_Application_ID}"
|
||||
# INFO_Tenant_ID: "${INFO_Tenant_ID}"
|
||||
# INFO_Secret: "${INFO_Secret}"
|
||||
# CAL_APPID: "${CAL_APPID}"
|
||||
# CAL_SECRET: "${CAL_SECRET}"
|
||||
# CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
|
||||
# TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
|
||||
# FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
|
||||
# WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
|
||||
# MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
|
||||
# volumes:
|
||||
# - ./lead-engine:/app
|
||||
# - ./lead-engine/data:/app/data # Local persistent database
|
||||
|
||||
fotograf-de-scraper-backend:
|
||||
build:
|
||||
context: ./connector-superoffice
|
||||
context: ./fotograf-de-scraper/backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: connector-superoffice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8003:8000"
|
||||
volumes:
|
||||
- ./connector-superoffice:/app
|
||||
- connector_db_data:/data
|
||||
container_name: fotograf-de-scraper-backend
|
||||
env_file:
|
||||
- ./fotograf-de-scraper/.env
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SO_CLIENT_ID: "${SO_CLIENT_ID}"
|
||||
SO_CLIENT_SECRET: "${SO_CLIENT_SECRET}"
|
||||
SO_REFRESH_TOKEN: "${SO_REFRESH_TOKEN}"
|
||||
SO_ENVIRONMENT: "${SO_ENVIRONMENT}"
|
||||
SO_CONTEXT_IDENTIFIER: "${SO_CONTEXT_IDENTIFIER}"
|
||||
WEBHOOK_TOKEN: "${WEBHOOK_TOKEN}"
|
||||
WEBHOOK_SECRET: "${WEBHOOK_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
- TZ=Europe/Berlin
|
||||
ports:
|
||||
- "8002:8000"
|
||||
volumes:
|
||||
- ./fotograf-de-scraper/backend:/app
|
||||
- ./fotograf-de-scraper/backend/data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
lead-engine:
|
||||
fotograf-de-scraper-frontend:
|
||||
build:
|
||||
context: ./lead-engine
|
||||
context: ./fotograf-de-scraper/frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: lead-engine
|
||||
restart: unless-stopped
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://192.168.178.6:8002"
|
||||
container_name: fotograf-de-scraper-frontend
|
||||
ports:
|
||||
- "8501:8501" # UI (Streamlit)
|
||||
- "8004:8004" # API / Monitor
|
||||
- "8099:8004" # Direct Test Port
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY}"
|
||||
SERP_API: "${SERP_API}"
|
||||
INFO_Application_ID: "${INFO_Application_ID}"
|
||||
INFO_Tenant_ID: "${INFO_Tenant_ID}"
|
||||
INFO_Secret: "${INFO_Secret}"
|
||||
CAL_APPID: "${CAL_APPID}"
|
||||
CAL_SECRET: "${CAL_SECRET}"
|
||||
CAL_TENNANT_ID: "${CAL_TENNANT_ID}"
|
||||
TEAMS_WEBHOOK_URL: "${TEAMS_WEBHOOK_URL}"
|
||||
FEEDBACK_SERVER_BASE_URL: "${FEEDBACK_SERVER_BASE_URL}"
|
||||
WORDPRESS_BOOKING_URL: "${WORDPRESS_BOOKING_URL}"
|
||||
MS_BOOKINGS_URL: "${MS_BOOKINGS_URL}"
|
||||
volumes:
|
||||
- ./lead-engine:/app
|
||||
- lead_engine_data:/app/data
|
||||
|
||||
# --- INFRASTRUCTURE SERVICES ---
|
||||
|
||||
- "3009:80"
|
||||
depends_on:
|
||||
- fotograf-de-scraper-backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
connector_db_data: {}
|
||||
explorer_db_data: {}
|
||||
lead_engine_data: {}
|
||||
gtm_architect_data: {}
|
||||
b2b_marketing_data: {}
|
||||
transcription_uploads: {}
|
||||
|
||||
70
docs/Praesentation/Executive_Briefing_Guide.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 🎤 Executive Briefing Guide: Sales Dashboard "Intelligence & yield"
|
||||
|
||||
Dieses Dokument dient als strategischer Leitfaden und Sprecherskript für die Vorstandspräsentation. Es enthält alle Hintergrundinformationen und Datenpunkte, um die Präsentation auf einem externen System ohne Dashboard-Zugriff zu finalisieren.
|
||||
|
||||
---
|
||||
|
||||
## 1. Strategischer Kontext (Das Mindset)
|
||||
|
||||
**Kern-Metapher:** "Vom Wiegen wird die Sau nicht fetter."
|
||||
Wir präsentieren dem Vorstand keine "bunte Statistik". Wir präsentieren ein **aktives Steuerungsinstrument**.
|
||||
* **Problem:** SuperOffice (SO) ist ein passives Archiv. Daten liegen dort in Silos, Korrelationen (z.B. "Welche Aktivität führt zum Abschluss?") erfordern Tage in Excel.
|
||||
* **Lösung:** Das Dashboard macht sich "die Hände schmutzig". Es taucht in die Datentiefe ab, verbindet CRM-Aktivitäten mit echten Angebotspositionen (SKUs) und liefert Antworten auf Knopfdruck.
|
||||
* **Ziel:** Wir messen nicht des Messens wegen, sondern um eine Basis für strategische Entscheidungen zu legen und den Erfolg von Maßnahmen (wie der Vor-Ort-Fokussierung) im Nachhinein hart zu belegen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprecherskript (Phasen-Leitfaden)
|
||||
|
||||
### Phase 1: Die Vision (Intro)
|
||||
> "Herr Vorstand, in SuperOffice verwalten wir Kontakte. In diesem Dashboard steuern wir den Erfolg. Wir haben aufgehört zu raten und angefangen zu wissen. Unser Motto: Vom Wiegen allein wird die Sau nicht fetter – wir messen hier, um den Vertrieb proaktiv zum Yield zu führen."
|
||||
|
||||
### Phase 2: Risk Management (Clean Pipeline)
|
||||
> "In SuperOffice sieht unsere Pipeline oft gewaltig aus – 5,2 Mio. Euro. Aber das ist die 'Brutto-Hoffnung'. Unser Dashboard erkennt automatisch den 'Duplicate Bloat' – also Angebotsvarianten für denselben Kunden. Wir ziehen den Vorhang beiseite und sehen die 'Clean Pipeline' von 3,6 Mio. Euro. Das ist die reale Wahrheit, auf der wir Investitionen planen können."
|
||||
* **Key Fact:** ~31% der Pipeline in SO sind oft nur redundante Varianten (Bloat).
|
||||
|
||||
### Phase 3: Die Winning DNA (Yield-Beleg)
|
||||
> "Wir wissen jetzt exakt, was funktioniert. Wer sich die Hände schmutzig macht und in die Daten eintaucht, findet Gold: Ein einziger Vor-Ort-Termin steigert die Abschlusswahrscheinlichkeit um 159%. Wir steuern unsere Sales-Manager jetzt nicht mehr nach 'Gefühl', sondern schicken sie dorthin, wo der Yield bewiesen ist."
|
||||
* **Key Fact:** Win-Rate steigt von ~9% auf ~24% bei Demos.
|
||||
* **Key Fact:** Follow-Up Speed von 37 Tagen auf 10 Tage gesenkt.
|
||||
|
||||
### Phase 4: Action Hub (Der 20% Hebel)
|
||||
> "Das Dashboard ist kein Rückspiegel, es ist ein Gaspedal. Über den 'Magic Wand' generiert der Manager mit einem Klick eine Rückrufbitte, die die komplette CRM-Historie kennt. Wir haben den ROI gemessen: Jede fünfte Nachricht führt zu einer direkten Kundenreaktion. Das ist Effizienz, die man auf dem Konto sieht."
|
||||
* **Key Fact:** >20% Reaktionsquote auf automatisierte Dashboard-Mails.
|
||||
|
||||
### Phase 5: The Machine (Scalability)
|
||||
> "Abschließend geben wir Ihnen den Regler in die Hand. 'The Machine' übersetzt Ihre strategischen Monatsziele (z.B. 300k €) live in wöchentliche Lead-Vorgaben für jeden einzelnen Manager – individuell berechnet auf deren persönlicher Hit-Rate. So skalieren wir Wachstum vorhersagbar."
|
||||
|
||||
---
|
||||
|
||||
## 3. Daten-Backup (für die manuelle Gestaltung)
|
||||
|
||||
Falls du auf dem Zielsystem Grafiken nachbauen willst, hier die harten Werte aus dem Backend:
|
||||
|
||||
| Bereich | Wert (Beispiel-Snapshot) | Herkunft / Logik |
|
||||
| :--- | :--- | :--- |
|
||||
| **Gross Pipeline** | 5.164.155 € | Alle offenen Angebote in SO (Hardware). |
|
||||
| **Duplicate Bloat** | - 1.593.787 € | Differenz aus Gross vs. Netto (pro Firma). |
|
||||
| **Clean Pipeline** | 3.570.368 € | Höchstes Einzelangebot pro Firma. |
|
||||
| **Win-Rate (mit Demo)** | 23,8 % | Korrelation 'Termin Typ 148' ➔ 'Won'. |
|
||||
| **Win-Rate (ohne Demo)** | 9,2 % | Deals ohne Vor-Ort-Aktivität. |
|
||||
| **Median Follow-Up** | 10 Tage | Zeit zw. Angebot und nächster Aktion. |
|
||||
| **CRM White Space** | 67 % | 10.000+ Accounts ohne jegliche Historie. |
|
||||
| **Reaktionsquote** | > 20 % | Analysiert via 'analyze_dashboard_roi_fixed.py'. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Visual Guide (Was man im Dashboard sieht)
|
||||
|
||||
Da du keinen Zugriff hast, hier die Beschreibung der UI-Elemente für Mocks:
|
||||
|
||||
1. **Header:** Dunkelblau (`#081734`), Neon-Cyan Akzente. Titel: "Sales Center Analytics".
|
||||
2. **KPI-Karten:** 5 Karten nebeneinander (Volumen, Stagnation, Forecast, Cycle Time, Rote Liste).
|
||||
3. **Pipeline Funnel:** Ein horizontaler Trichter. Jede Stufe (10%, 20%, ... 98%) ist ein Balken, dessen Breite das Volumen widerspiegelt.
|
||||
4. **Manager-Table:** Zeigt Win-Rates und Cycle Times. Wichtig: Der "Expert Mode" zeigt hier auch den Deckungsbeitrag I (DB I) und die geschätzten Reisekosten/Stunden.
|
||||
5. **Rote Liste:** Eine knallrote Tabelle oben, die "Deals in Gefahr" zeigt (Datum abgelaufen oder Stagnation > 14 Tage).
|
||||
6. **Target Simulator:** Ein großer Schieberegler mit zwei "Tacho-Anzeigen" für Leads/Woche.
|
||||
7. **Toby:** Der kleine Roboter-Avatar sitzt meist unten rechts in einer Sprechblase.
|
||||
|
||||
---
|
||||
*Dokument erstellt am 01. Juni 2026 für Task [37288f42]*
|
||||
1
docs/Praesentation/GIT_TEST.txt
Normal file
@@ -0,0 +1 @@
|
||||
Git Push Test - Mon Jun 1 16:58:35 UTC 2026
|
||||
1590
docs/Praesentation/MIGRATION_PLAN (1).md
Normal file
384
docs/Praesentation/index.html
Normal file
@@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<!-- Updated: 16:26:23 -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sales Intelligence Dashboard: Executive Briefing</title>
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Space+Grotesk:wght@300;500;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brandDark: '#081734',
|
||||
brandLight: '#DDEEFE',
|
||||
brandCyan: '#00E5FF',
|
||||
brandGold: '#FFD42C',
|
||||
brandSlate: '#1E293B'
|
||||
},
|
||||
fontFamily: {
|
||||
'head': ['Space Grotesk', 'sans-serif'],
|
||||
'body': ['Inter', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #081734;
|
||||
color: #DDEEFE;
|
||||
font-family: 'Inter', sans-serif;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: 'Space Grotesk', sans-serif; font-weight: 700; }
|
||||
|
||||
.glass-card {
|
||||
background: rgba(221, 238, 254, 0.02);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.section-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
/* Toby Animation */
|
||||
.toby-float { animation: float 6s ease-in-out infinite; }
|
||||
@keyframes float { 0% { transform: translateY(0px) rotate(0deg); } 50% { transform: translateY(-15px) rotate(1deg); } 100% { transform: translateY(0px) rotate(0deg); } }
|
||||
|
||||
#toby-fixed {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
width: 160px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#toby-bubble {
|
||||
background: #FFF;
|
||||
color: #081734;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
max-width: 200px;
|
||||
}
|
||||
#toby-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: #FFF transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* Custom Progress Bar */
|
||||
#progress-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
z-index: 1000;
|
||||
}
|
||||
#progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #00E5FF;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #FFF 20%, #00E5FF 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-value { font-size: 4.5rem; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
|
||||
.content-width { max-width: 1100px; width: 100%; }
|
||||
.reveal { opacity: 0; transform: translateY(25px); }
|
||||
|
||||
/* Dashboard Elements Mocks */
|
||||
.mock-kpi {
|
||||
border-left: 4px solid #00E5FF;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
|
||||
<div id="progress-container"><div id="progress-bar"></div></div>
|
||||
|
||||
<!-- Toby Fixed -->
|
||||
<div id="toby-fixed" class="toby-float">
|
||||
<div id="toby-bubble">Präzision statt Vermutung.</div>
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAc2CAYAAABaJTzmAAAACXBIWXMAABsRAAAbEQEEnGAvAAAEvmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSfvu78nIGlkPSdXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQnPz4KPHg6eG1wbWV0YSB4bWxuczp4PSdhZG9iZTpuczptZXRhLyc+CjxyZGY6UkRGIHhtbG5zOnJkZj0naHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyc+CgogPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9JycKICB4bWxuczpBdHRyaWI9J2h0dHA6Ly9ucy5hdHRyaWJ1dGlvbi5jb20vYWRzLzEuMC8nPgogIDxBdHRyaWI6QWRzPgogICA8cmRmOlNlcT4KICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0nUmVzb3VyY2UnPgogICAgIDxBdHRyaWI6Q3JlYXRlZD4yMDI2LTAxLTEzPC9BdHRyaWI6Q3JlYXRlZD4KICAgICA8QXR0cmliOkV4dExtZD4zZjE1OTM5Ny04MWM3LTQyM2ItOWViMy02ODYyYzAxZjM4NGI8L0F0dHJpYjpFeHRJZD4KICAgICA8QXR0cmliOkZiSWQ+NTI1MjY1OTE0MTc5NTgwPC9BdHRyaWI6RmJJZD4KICAgICA8QXR0cmliOlRvdWNoVHlwZT4yPC9BdHRyaWI6VG91Y2hUeXBlPgogICAgPC9yZGY6bGk+CiAgIDwvJmRmOlNlcT4KICB8L0F0dHJpYjpBZHM+CiA8L3JkZjpEZXNjcmlwdGlvbj4KCiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0nJwogIHhtbG5zOmRjPSdodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyc+CiAgPGRjOnRpdGxlPgogICA8cmRmOkFsdD4KICAgIDxyZGY6bGkgeG1sOmxhbmc9J3gtZGVmYXVsdCc+Um9ib1BsYW5ldF9BdmF0YXJfaGVsbGJsYXUuYWkgLSAxPC9yZGY6bGk+CiAgIDwvcmRmOkFsdD4KICA8L2RjOnRpdGxlPgogPC9yZGY6RGVzY3JpcHRpb24+CgogPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9JycKICB4bWxuczpwZGY9J2h0dHA6Ly9ucy5hZG9iZS5jb20vcGRmLzEuMy8nPgogIDxwZGY6QXV0aG9yPldhY2tsZXJHcm91cDwvcGRmOkF1dGhvcm4+CiA8L3JkZjpEZXNjcmlwdGlvbj4KCiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0nJwogIHhtbG5zOnhtcD0naHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyc+CiAgPHhtcDpDcmVhdG9yVG9vbD5DYW52YSBkb2M9REFHLVN1Y0tTeXMgdXNlcj1VQUVUOG1KY3lhSSBicmFuZD1CQUVUOGd0OERsTSB0ZW1wbGF0ZT08L3htcDpDcmVhdG9yVG9vbD4KIDwvcmRmOkRlc2NyaXB0aW9uPgo8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSdyJz8+qsA63gAACAASURBVHic7N15uG5z/fj/zx+/6/urvqU5aVCSSFGhyJjMZAghY6aMoQxJEioaKBkjkTEJERWJFAopY6nOWvc+xzmpnNa695n4616v73qvc/o0knOcc9733vvxvK7HtU/lCnvf+17v4V7v9T//I0mSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS... [truncated]" alt="Toby" class="w-full opacity-80">
|
||||
</div>
|
||||
|
||||
<!-- 1. Intro: The Intelligence Cockpit -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width text-center">
|
||||
<h4 class="text-brandCyan uppercase tracking-[0.5em] mb-8 reveal font-head text-xs">Executive Summary</h4>
|
||||
<h1 class="text-7xl md:text-8xl font-bold mb-10 reveal leading-[1.1] tracking-tight">Sales Intelligence <br> <span class="text-gradient">Cockpit</span></h1>
|
||||
<p class="text-xl md:text-2xl text-brandLight/40 max-w-2xl mx-auto mb-16 reveal font-body font-light">
|
||||
Transparenz auf Knopfdruck. <br>
|
||||
Vom statischen Archiv zum aktiven Steuerungs-Hub.
|
||||
</p>
|
||||
<div class="flex justify-center gap-6 reveal">
|
||||
<div class="h-[1px] w-12 bg-brandCyan/40 self-center"></div>
|
||||
<span class="text-brandCyan font-mono uppercase text-xs tracking-widest">30 Tage Live-Betrieb</span>
|
||||
<div class="h-[1px] w-12 bg-brandCyan/40 self-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. The Efficiency: Adieu Excel -->
|
||||
<section class="section-screen bg-brandSlate/10">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal">
|
||||
<h2 class="text-5xl mb-8">Echte Daten statt manueller Berichte.</h2>
|
||||
<p class="text-lg text-brandLight/60 mb-10 font-light leading-relaxed">
|
||||
Bisher kostete die Aufbereitung der Pipeline eine Stunde pro Woche. Das Dashboard liefert diese Erkenntnisse in <strong>Echtzeit</strong> – fehlerfrei und interaktiv.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="mock-kpi">
|
||||
<p class="text-3xl font-bold text-white">-1h</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-brandLight/50 mt-1">Manueller Aufwand / Woche</p>
|
||||
</div>
|
||||
<div class="mock-kpi">
|
||||
<p class="text-3xl font-bold text-brandCyan">Live</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-brandLight/50 mt-1">Pipeline-Truth 24/7</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-12 reveal">
|
||||
<h4 class="text-brandCyan uppercase text-[10px] tracking-widest font-bold mb-6 border-b border-white/5 pb-4">Vorteil: Daten-Integrität</h4>
|
||||
<p class="text-xl text-white font-light leading-relaxed mb-6">
|
||||
"Automatisierte Erkennung von Dubletten und Varianten – eine Transparenz, die SuperOffice in dieser Form nicht bietet."
|
||||
</p>
|
||||
<div class="w-16 h-1 bg-brandCyan"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Transparency: The Winning DNA -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width">
|
||||
<div class="text-center mb-20 reveal">
|
||||
<h2 class="text-5xl mb-6">Die Winning DNA</h2>
|
||||
<p class="text-xl text-brandLight/40 font-light">Tiefe Einblicke in die Mechanik unseres Erfolgs.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 reveal">
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Kanäle</p>
|
||||
<h4 class="text-lg mb-4">Winning Channels</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Präzise Auswertung: Welche Lead-Quelle konvertiert wirklich?</p>
|
||||
</div>
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Analyse</p>
|
||||
<h4 class="text-lg mb-4">Absage-Gründe</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Warum verlieren wir Deals? Daten statt Vermutungen.</p>
|
||||
</div>
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Performance</p>
|
||||
<h4 class="text-lg mb-4">Time to Sale</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Abschlussquoten & Kontakthäufigkeit pro Sales Manager.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. Proactivity: The Red List -->
|
||||
<section class="section-screen bg-white/[0.01]">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal relative order-2 md:order-1">
|
||||
<div class="glass-card p-1 relative overflow-hidden border-red-500/20">
|
||||
<div class="bg-red-500/10 p-10">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h4 class="text-red-500 font-bold uppercase text-xs tracking-widest">Rote Liste: Deals in Gefahr</h4>
|
||||
<span class="bg-red-500 text-white text-[10px] px-2 py-1 rounded">Aktion Erforderlich</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reveal order-1 md:order-2">
|
||||
<h2 class="text-5xl mb-8 leading-tight">Wissen, wo <br> <span class="text-red-500">es brennt.</span></h2>
|
||||
<p class="text-lg text-brandLight/60 font-light leading-relaxed mb-8">
|
||||
Überschrittene Verkaufsdaten oder Stagnation > 14 Tage werden sofort markiert. Das Dashboard fungiert als <strong>Frühwarnsystem</strong> für die Pipeline.
|
||||
</p>
|
||||
<div class="flex items-center gap-4 text-brandCyan">
|
||||
<span class="text-xl">➔</span>
|
||||
<p class="text-sm uppercase tracking-widest font-bold">Prävention von Deal-Verlusten</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Action Hub: From Insight to Interaction -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal">
|
||||
<h4 class="text-brandCyan font-mono uppercase tracking-widest text-xs mb-6 font-bold">The Action Hub</h4>
|
||||
<h2 class="text-6xl mb-8 leading-tight">Vom Insight <br> zur <span class="text-gradient">Tat.</span></h2>
|
||||
<p class="text-xl text-brandLight/70 font-light mb-10 leading-relaxed">
|
||||
Das Dashboard ist kein Rückspiegel. Mit einem Klick zum Account, zum Sale oder zur automatisierten <strong>Rückrufbitte</strong>.
|
||||
</p>
|
||||
<div class="glass-card p-10 bg-brandCyan/5 border-brandCyan/20">
|
||||
<div class="stat-value text-white mb-2">>20%</div>
|
||||
<p class="text-xs uppercase tracking-widest font-bold text-brandCyan">Response-Rate</p>
|
||||
<p class="text-sm text-brandLight/60 mt-4">Belegter Erfolg: Jede 5. Nachricht führt zu einer direkten Reaktion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<div class="glass-card p-12 border-brandCyan/30">
|
||||
<h4 class="text-xs uppercase tracking-widest font-bold mb-6 border-b border-white/5 pb-4">Einfachheit gewinnt</h4>
|
||||
<p class="text-lg text-brandLight/80 font-light leading-relaxed mb-8">
|
||||
"Was in SuperOffice 5-10 Klicks braucht, liegt hier auf der Oberfläche. Das Team arbeitet lieber im Dashboard, weil es Zeit spart und Übersicht schafft."
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-brandCyan/20"></div>
|
||||
<p class="text-xs text-brandLight/40 uppercase tracking-widest">Feedback aus dem Innendienst</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. Infrastructure: Scalable Asset -->
|
||||
<section class="section-screen bg-brandCyan/5">
|
||||
<div class="content-width text-center">
|
||||
<h2 class="text-5xl md:text-7xl mb-12 reveal font-head tracking-tighter uppercase">Scalable <br> Asset</h2>
|
||||
<p class="text-xl md:text-2xl text-brandLight/70 max-w-3xl mx-auto mb-16 reveal font-light leading-relaxed">
|
||||
Dieses Dashboard ist kein isoliertes Tool. Es fußt auf einem robusten <strong>API-Connector</strong>, der die Grundlage für alle zukünftigen Sales-Automatisierungen bildet.
|
||||
</p>
|
||||
<div class="h-[1px] w-24 bg-brandCyan mx-auto opacity-30 reveal"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. Conclusion -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width text-center">
|
||||
<h2 class="text-6xl mb-12 reveal font-head">Bereit für die <br> <span class="text-gradient">nächste Stufe.</span></h2>
|
||||
<p class="text-brandLight/40 uppercase tracking-[0.3em] text-xs reveal">Vielen Dank für Ihre Aufmerksamkeit.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
||||
<script>
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const tobyFixed = document.getElementById('toby-fixed');
|
||||
const tobyBubble = document.getElementById('toby-bubble');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
const tobyMessages = [
|
||||
"Willkommen im Cockpit.",
|
||||
"Live-Daten schlagen Excel.",
|
||||
"Die Erfolgs-DNA im Blick.",
|
||||
"Frühwarnsystem aktiviert.",
|
||||
"Action: Jede 5. Mail ein Treffer.",
|
||||
"Bereit zum Skalieren.",
|
||||
"Das ist erst der Anfang."
|
||||
];
|
||||
|
||||
function updateToby(index) {
|
||||
if (tobyMessages[index]) {
|
||||
tobyBubble.innerHTML = tobyMessages[index];
|
||||
gsap.fromTo(tobyBubble, { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.4, ease: "back.out(1.7)" });
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal Animations
|
||||
gsap.utils.toArray('.reveal').forEach((el, i) => {
|
||||
gsap.to(el, {
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none reverse"
|
||||
},
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.8,
|
||||
ease: "power2.out"
|
||||
});
|
||||
});
|
||||
|
||||
// Toby Visibility & Updates
|
||||
ScrollTrigger.create({
|
||||
trigger: "body",
|
||||
start: "100px top",
|
||||
onEnter: () => gsap.to(tobyFixed, { opacity: 1, duration: 0.5 }),
|
||||
onLeaveBack: () => gsap.to(tobyFixed, { opacity: 0, duration: 0.5 })
|
||||
});
|
||||
|
||||
const sections = gsap.utils.toArray('section');
|
||||
sections.forEach((section, i) => {
|
||||
ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: "top center",
|
||||
onEnter: () => updateToby(i),
|
||||
onEnterBack: () => updateToby(i)
|
||||
});
|
||||
});
|
||||
|
||||
// Progress Bar
|
||||
gsap.to(progressBar, {
|
||||
width: "100%",
|
||||
ease: "none",
|
||||
scrollTrigger: { scrub: 0.3 }
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
386
docs/Praesentation/offline_demo (20).html
Normal file
591
docs/Praesentation/offline_demo (5).html
Normal file
384
docs/Praesentation/sales_dashboard_v2.html
Normal file
@@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<!-- Updated: 16:26:23 -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sales Intelligence Dashboard: Executive Briefing</title>
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Space+Grotesk:wght@300;500;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brandDark: '#081734',
|
||||
brandLight: '#DDEEFE',
|
||||
brandCyan: '#00E5FF',
|
||||
brandGold: '#FFD42C',
|
||||
brandSlate: '#1E293B'
|
||||
},
|
||||
fontFamily: {
|
||||
'head': ['Space Grotesk', 'sans-serif'],
|
||||
'body': ['Inter', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #081734;
|
||||
color: #DDEEFE;
|
||||
font-family: 'Inter', sans-serif;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: 'Space Grotesk', sans-serif; font-weight: 700; }
|
||||
|
||||
.glass-card {
|
||||
background: rgba(221, 238, 254, 0.02);
|
||||
backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.section-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
/* Toby Animation */
|
||||
.toby-float { animation: float 6s ease-in-out infinite; }
|
||||
@keyframes float { 0% { transform: translateY(0px) rotate(0deg); } 50% { transform: translateY(-15px) rotate(1deg); } 100% { transform: translateY(0px) rotate(0deg); } }
|
||||
|
||||
#toby-fixed {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
width: 160px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#toby-bubble {
|
||||
background: #FFF;
|
||||
color: #081734;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
max-width: 200px;
|
||||
}
|
||||
#toby-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: #FFF transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* Custom Progress Bar */
|
||||
#progress-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
z-index: 1000;
|
||||
}
|
||||
#progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #00E5FF;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #FFF 20%, #00E5FF 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-value { font-size: 4.5rem; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
|
||||
.content-width { max-width: 1100px; width: 100%; }
|
||||
.reveal { opacity: 0; transform: translateY(25px); }
|
||||
|
||||
/* Dashboard Elements Mocks */
|
||||
.mock-kpi {
|
||||
border-left: 4px solid #00E5FF;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
|
||||
<div id="progress-container"><div id="progress-bar"></div></div>
|
||||
|
||||
<!-- Toby Fixed -->
|
||||
<div id="toby-fixed" class="toby-float">
|
||||
<div id="toby-bubble">Präzision statt Vermutung.</div>
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAc2CAYAAABaJTzmAAAACXBIWXMAABsRAAAbEQEEnGAvAAAEvmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSfvu78nIGlkPSdXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQnPz4KPHg6eG1wbWV0YSB4bWxuczp4PSdhZG9iZTpuczptZXRhLyc+CjxyZGY6UkRGIHhtbG5zOnJkZj0naHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyc+CgogPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9JycKICB4bWxuczpBdHRyaWI9J2h0dHA6Ly9ucy5hdHRyaWJ1dGlvbi5jb20vYWRzLzEuMC8nPgogIDxBdHRyaWI6QWRzPgogICA8cmRmOlNlcT4KICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0nUmVzb3VyY2UnPgogICAgIDxBdHRyaWI6Q3JlYXRlZD4yMDI2LTAxLTEzPC9BdHRyaWI6Q3JlYXRlZD4KICAgICA8QXR0cmliOkV4dExtZD4zZjE1OTM5Ny04MWM3LTQyM2ItOWViMy02ODYyYzAxZjM4NGI8L0F0dHJpYjpFeHRJZD4KICAgICA8QXR0cmliOkZiSWQ+NTI1MjY1OTE0MTc5NTgwPC9BdHRyaWI6RmJJZD4KICAgICA8QXR0cmliOlRvdWNoVHlwZT4yPC9BdHRyaWI6VG91Y2hUeXBlPgogICAgPC9yZGY6bGk+CiAgIDwvJmRmOlNlcT4KICB8L0F0dHJpYjpBZHM+CiA8L3JkZjpEZXNjcmlwdGlvbj4KCiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0nJwogIHhtbG5zOmRjPSdodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyc+CiAgPGRjOnRpdGxlPgogICA8cmRmOkFsdD4KICAgIDxyZGY6bGkgeG1sOmxhbmc9J3gtZGVmYXVsdCc+Um9ib1BsYW5ldF9BdmF0YXJfaGVsbGJsYXUuYWkgLSAxPC9yZGY6bGk+CiAgIDwvcmRmOkFsdD4KICA8L2RjOnRpdGxlPgogPC9yZGY6RGVzY3JpcHRpb24+CgogPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9JycKICB4bWxuczpwZGY9J2h0dHA6Ly9ucy5hZG9iZS5jb20vcGRmLzEuMy8nPgogIDxwZGY6QXV0aG9yPldhY2tsZXJHcm91cDwvcGRmOkF1dGhvcm4+CiA8L3JkZjpEZXNjcmlwdGlvbj4KCiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0nJwogIHhtbG5zOnhtcD0naHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyc+CiAgPHhtcDpDcmVhdG9yVG9vbD5DYW52YSBkb2M9REFHLVN1Y0tTeXMgdXNlcj1VQUVUOG1KY3lhSSBicmFuZD1CQUVUOGd0OERsTSB0ZW1wbGF0ZT08L3htcDpDcmVhdG9yVG9vbD4KIDwvcmRmOkRlc2NyaXB0aW9uPgo8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSdyJz8+qsA63gAACAASURBVHic7N15uG5z/fj/zx+/6/urvqU5aVCSSFGhyJjMZAghY6aMoQxJEioaKBkjkTEJERWJFAopY6nOWvc+xzmpnNa695n4616v73qvc/o0knOcc9733vvxvK7HtU/lCnvf+17v4V7v9T//I0mSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS... [truncated]" alt="Toby" class="w-full opacity-80">
|
||||
</div>
|
||||
|
||||
<!-- 1. Intro: The Intelligence Cockpit -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width text-center">
|
||||
<h4 class="text-brandCyan uppercase tracking-[0.5em] mb-8 reveal font-head text-xs">Executive Summary</h4>
|
||||
<h1 class="text-7xl md:text-8xl font-bold mb-10 reveal leading-[1.1] tracking-tight">Sales Intelligence <br> <span class="text-gradient">Cockpit</span></h1>
|
||||
<p class="text-xl md:text-2xl text-brandLight/40 max-w-2xl mx-auto mb-16 reveal font-body font-light">
|
||||
Transparenz auf Knopfdruck. <br>
|
||||
Vom statischen Archiv zum aktiven Steuerungs-Hub.
|
||||
</p>
|
||||
<div class="flex justify-center gap-6 reveal">
|
||||
<div class="h-[1px] w-12 bg-brandCyan/40 self-center"></div>
|
||||
<span class="text-brandCyan font-mono uppercase text-xs tracking-widest">30 Tage Live-Betrieb</span>
|
||||
<div class="h-[1px] w-12 bg-brandCyan/40 self-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. The Efficiency: Adieu Excel -->
|
||||
<section class="section-screen bg-brandSlate/10">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal">
|
||||
<h2 class="text-5xl mb-8">Echte Daten statt manueller Berichte.</h2>
|
||||
<p class="text-lg text-brandLight/60 mb-10 font-light leading-relaxed">
|
||||
Bisher kostete die Aufbereitung der Pipeline eine Stunde pro Woche. Das Dashboard liefert diese Erkenntnisse in <strong>Echtzeit</strong> – fehlerfrei und interaktiv.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="mock-kpi">
|
||||
<p class="text-3xl font-bold text-white">-1h</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-brandLight/50 mt-1">Manueller Aufwand / Woche</p>
|
||||
</div>
|
||||
<div class="mock-kpi">
|
||||
<p class="text-3xl font-bold text-brandCyan">Live</p>
|
||||
<p class="text-[10px] uppercase tracking-widest text-brandLight/50 mt-1">Pipeline-Truth 24/7</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-12 reveal">
|
||||
<h4 class="text-brandCyan uppercase text-[10px] tracking-widest font-bold mb-6 border-b border-white/5 pb-4">Vorteil: Daten-Integrität</h4>
|
||||
<p class="text-xl text-white font-light leading-relaxed mb-6">
|
||||
"Automatisierte Erkennung von Dubletten und Varianten – eine Transparenz, die SuperOffice in dieser Form nicht bietet."
|
||||
</p>
|
||||
<div class="w-16 h-1 bg-brandCyan"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Transparency: The Winning DNA -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width">
|
||||
<div class="text-center mb-20 reveal">
|
||||
<h2 class="text-5xl mb-6">Die Winning DNA</h2>
|
||||
<p class="text-xl text-brandLight/40 font-light">Tiefe Einblicke in die Mechanik unseres Erfolgs.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 reveal">
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Kanäle</p>
|
||||
<h4 class="text-lg mb-4">Winning Channels</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Präzise Auswertung: Welche Lead-Quelle konvertiert wirklich?</p>
|
||||
</div>
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Analyse</p>
|
||||
<h4 class="text-lg mb-4">Absage-Gründe</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Warum verlieren wir Deals? Daten statt Vermutungen.</p>
|
||||
</div>
|
||||
<div class="glass-card p-10 hover:bg-brandCyan/[0.03] transition-all">
|
||||
<p class="text-brandCyan font-mono text-[10px] uppercase mb-4">Performance</p>
|
||||
<h4 class="text-lg mb-4">Time to Sale</h4>
|
||||
<p class="text-sm text-brandLight/50 font-light leading-relaxed">Abschlussquoten & Kontakthäufigkeit pro Sales Manager.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. Proactivity: The Red List -->
|
||||
<section class="section-screen bg-white/[0.01]">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal relative order-2 md:order-1">
|
||||
<div class="glass-card p-1 relative overflow-hidden border-red-500/20">
|
||||
<div class="bg-red-500/10 p-10">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h4 class="text-red-500 font-bold uppercase text-xs tracking-widest">Rote Liste: Deals in Gefahr</h4>
|
||||
<span class="bg-red-500 text-white text-[10px] px-2 py-1 rounded">Aktion Erforderlich</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
<div class="h-12 bg-white/5 rounded border border-white/5 flex items-center px-4 justify-between">
|
||||
<div class="w-1/2 h-2 bg-white/20 rounded"></div>
|
||||
<div class="w-12 h-2 bg-red-500/40 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reveal order-1 md:order-2">
|
||||
<h2 class="text-5xl mb-8 leading-tight">Wissen, wo <br> <span class="text-red-500">es brennt.</span></h2>
|
||||
<p class="text-lg text-brandLight/60 font-light leading-relaxed mb-8">
|
||||
Überschrittene Verkaufsdaten oder Stagnation > 14 Tage werden sofort markiert. Das Dashboard fungiert als <strong>Frühwarnsystem</strong> für die Pipeline.
|
||||
</p>
|
||||
<div class="flex items-center gap-4 text-brandCyan">
|
||||
<span class="text-xl">➔</span>
|
||||
<p class="text-sm uppercase tracking-widest font-bold">Prävention von Deal-Verlusten</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Action Hub: From Insight to Interaction -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-20 items-center">
|
||||
<div class="reveal">
|
||||
<h4 class="text-brandCyan font-mono uppercase tracking-widest text-xs mb-6 font-bold">The Action Hub</h4>
|
||||
<h2 class="text-6xl mb-8 leading-tight">Vom Insight <br> zur <span class="text-gradient">Tat.</span></h2>
|
||||
<p class="text-xl text-brandLight/70 font-light mb-10 leading-relaxed">
|
||||
Das Dashboard ist kein Rückspiegel. Mit einem Klick zum Account, zum Sale oder zur automatisierten <strong>Rückrufbitte</strong>.
|
||||
</p>
|
||||
<div class="glass-card p-10 bg-brandCyan/5 border-brandCyan/20">
|
||||
<div class="stat-value text-white mb-2">>20%</div>
|
||||
<p class="text-xs uppercase tracking-widest font-bold text-brandCyan">Response-Rate</p>
|
||||
<p class="text-sm text-brandLight/60 mt-4">Belegter Erfolg: Jede 5. Nachricht führt zu einer direkten Reaktion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<div class="glass-card p-12 border-brandCyan/30">
|
||||
<h4 class="text-xs uppercase tracking-widest font-bold mb-6 border-b border-white/5 pb-4">Einfachheit gewinnt</h4>
|
||||
<p class="text-lg text-brandLight/80 font-light leading-relaxed mb-8">
|
||||
"Was in SuperOffice 5-10 Klicks braucht, liegt hier auf der Oberfläche. Das Team arbeitet lieber im Dashboard, weil es Zeit spart und Übersicht schafft."
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-brandCyan/20"></div>
|
||||
<p class="text-xs text-brandLight/40 uppercase tracking-widest">Feedback aus dem Innendienst</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. Infrastructure: Scalable Asset -->
|
||||
<section class="section-screen bg-brandCyan/5">
|
||||
<div class="content-width text-center">
|
||||
<h2 class="text-5xl md:text-7xl mb-12 reveal font-head tracking-tighter uppercase">Scalable <br> Asset</h2>
|
||||
<p class="text-xl md:text-2xl text-brandLight/70 max-w-3xl mx-auto mb-16 reveal font-light leading-relaxed">
|
||||
Dieses Dashboard ist kein isoliertes Tool. Es fußt auf einem robusten <strong>API-Connector</strong>, der die Grundlage für alle zukünftigen Sales-Automatisierungen bildet.
|
||||
</p>
|
||||
<div class="h-[1px] w-24 bg-brandCyan mx-auto opacity-30 reveal"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. Conclusion -->
|
||||
<section class="section-screen">
|
||||
<div class="content-width text-center">
|
||||
<h2 class="text-6xl mb-12 reveal font-head">Bereit für die <br> <span class="text-gradient">nächste Stufe.</span></h2>
|
||||
<p class="text-brandLight/40 uppercase tracking-[0.3em] text-xs reveal">Vielen Dank für Ihre Aufmerksamkeit.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
||||
<script>
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const tobyFixed = document.getElementById('toby-fixed');
|
||||
const tobyBubble = document.getElementById('toby-bubble');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
const tobyMessages = [
|
||||
"Willkommen im Cockpit.",
|
||||
"Live-Daten schlagen Excel.",
|
||||
"Die Erfolgs-DNA im Blick.",
|
||||
"Frühwarnsystem aktiviert.",
|
||||
"Action: Jede 5. Mail ein Treffer.",
|
||||
"Bereit zum Skalieren.",
|
||||
"Das ist erst der Anfang."
|
||||
];
|
||||
|
||||
function updateToby(index) {
|
||||
if (tobyMessages[index]) {
|
||||
tobyBubble.innerHTML = tobyMessages[index];
|
||||
gsap.fromTo(tobyBubble, { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.4, ease: "back.out(1.7)" });
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal Animations
|
||||
gsap.utils.toArray('.reveal').forEach((el, i) => {
|
||||
gsap.to(el, {
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none reverse"
|
||||
},
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.8,
|
||||
ease: "power2.out"
|
||||
});
|
||||
});
|
||||
|
||||
// Toby Visibility & Updates
|
||||
ScrollTrigger.create({
|
||||
trigger: "body",
|
||||
start: "100px top",
|
||||
onEnter: () => gsap.to(tobyFixed, { opacity: 1, duration: 0.5 }),
|
||||
onLeaveBack: () => gsap.to(tobyFixed, { opacity: 0, duration: 0.5 })
|
||||
});
|
||||
|
||||
const sections = gsap.utils.toArray('section');
|
||||
sections.forEach((section, i) => {
|
||||
ScrollTrigger.create({
|
||||
trigger: section,
|
||||
start: "top center",
|
||||
onEnter: () => updateToby(i),
|
||||
onEnterBack: () => updateToby(i)
|
||||
});
|
||||
});
|
||||
|
||||
// Progress Bar
|
||||
gsap.to(progressBar, {
|
||||
width: "100%",
|
||||
ease: "none",
|
||||
scrollTrigger: { scrub: 0.3 }
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
error_get_jobs_list_error_20260320_162957.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
error_get_jobs_list_error_20260320_163832.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
error_login_error_20260320_145427.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
error_login_error_20260320_150231.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
error_login_error_20260320_151103.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
error_login_error_20260320_151426.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
71
fotograf-de-scraper/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Fotograf.de Scraper & Management UI
|
||||
|
||||
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards, Shooting Schedule, **SQLite Data Sync**, **Gmail API Integration** & **Automated Release Requests**)
|
||||
|
||||
Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft.
|
||||
|
||||
## 🏗️ Architektur
|
||||
|
||||
Der Service besteht aus zwei Hauptkomponenten:
|
||||
|
||||
1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):**
|
||||
* **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`.
|
||||
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, OAuth-Tokens (`GmailToken`), Gutscheincodes (`DiscountCode`), Teilnehmerdaten (`ReleaseParticipant`), **Auftragsteilnehmer (`JobParticipant`)** und die **Versand-Historie (`ReleaseHistory`)**.
|
||||
* **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten).
|
||||
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** sowie an die **Gmail API** für direkten E-Mail-Versand und automatisierte Webhook-Antworten.
|
||||
|
||||
2. **Frontend (TypeScript / React / Vite / TailwindCSS):**
|
||||
* **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule).
|
||||
* **Arbeitsfluss:** Tools sind in der Detailansicht eines Auftrags in logische Phasen (Vorbereitung, Follow-Up, Statistik) unterteilt.
|
||||
|
||||
## ✨ Core Features
|
||||
|
||||
### 🚀 Performance-Optimierung (SQLite Sync)
|
||||
Statt wie früher jedes Mal mühsam durch alle Foto-Alben zu "crawlen", nutzt das System nun eine intelligente Synchronisierung:
|
||||
* **One-Click Sync:** Über den Button "Daten von Fotograf.de abgleichen" lädt das System die detaillierte Namensliste (CSV) herunter.
|
||||
* **Lokale Datenbank:** Alle relevanten Infos (E-Mail der Eltern, Login-Zahlen, Bestellstatus, Zugangscodes) werden in der Tabelle `job_participants` gespeichert.
|
||||
* **Blitzschnelle Analyse:** Nachfass-Mails und Statistiken werden nun in Sekunden (statt Minuten) direkt aus der Datenbank generiert.
|
||||
|
||||
### Feature 1: Teilnehmerlisten (Vollständig)
|
||||
Automatisierter Workflow zum Download und Formatieren der Anmeldelisten von `fotograf.de` als sortiertes PDF inkl. "Kinderfotos Erding" Branding.
|
||||
|
||||
### Feature 2: Shooting-Planung (QR-Karten & Terminliste) (Vollständig)
|
||||
Spezielles Modul für Familien-Mini-Shootings:
|
||||
* **QR-Karten-Andruck:** Präzises Overlay von Name, Kinderanzahl und Uhrzeit inkl. automatischer **Einwilligungs-Checkbox (☑)** aus Calendly-Daten.
|
||||
* **Termin-Übersichtsliste:** Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt inkl. Lückenfüller.
|
||||
|
||||
### Feature 3: Nachfass-E-Mails & Gmail Direkt-Versand (Optimiert)
|
||||
Identifizierung von Nicht-Käufern (0-1 Logins, keine Bestellung) basierend auf den synchronisierten Datenbank-Daten.
|
||||
* **Vorschau-Modus:** Ermöglicht das Durchklicken der personalisierten E-Mails an jeden Empfänger vor dem eigentlichen Versand.
|
||||
* **Quick-Login Automation:** Komfortabler "One-Click" Login-Link. Das System nutzt bevorzugt den via 'Link Magic' gesammelten Direkt-Link (`/gc/xyz`) oder fällt sicher auf die generische Anmeldung (`/login/ZUGANGSCODE`) inkl. automatischer Code-Übergabe zurück.
|
||||
|
||||
### Feature 4: Verkaufs-Statistiken (Optimiert)
|
||||
Detaillierte Analyse des Kaufverhaltens pro Gruppe/Klasse basierend auf den lokalen Datenbank-Einträgen.
|
||||
|
||||
### Feature 5: Geschwisterliste (Einrichtungsintern) (Vollständig)
|
||||
Tool zur Identifizierung von Geschwistergruppen innerhalb einer Einrichtung inkl. Cross-Check mit Calendly-Buchungen und speziellen Geschwister-QR-Karten.
|
||||
* **Flexibilität:** Optionaler Modus "Ohne Nachmittags-Shooting", um die Liste auch ohne Calendly-Abgleich (rein einrichtungsintern) zu generieren.
|
||||
|
||||
### Feature 6: Freigabeanfragen & Gutschein-Automation (Vollständig)
|
||||
Vollautomatisierter DSGVO-Workflow zur Einholung von Veröffentlichungsgenehmigungen:
|
||||
* **Schlanker Versand:** Manuelle Eingabe von Empfängern (E-Mail, Vorname, Kindernamen) mit **E-Mail-Vorschau**.
|
||||
* **Versand-Planung:** Einstellbare Versandzeit (Berlin Timezone) via Hintergrund-Tasks.
|
||||
* **Webhook-Integration:** Direkte Anbindung an **Google Forms**. Bei Absenden des Freigabe-Formulars wird automatisch ein Gutscheincode reserviert und eine Dankes-E-Mail versendet.
|
||||
* **Antwort-Übersicht:** Tabelle aller eingegangenen Freigaben inkl. zugewiesenem Code und Zeitstempel.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technische Details & Sicherheit
|
||||
* **BCC-Kontrolle:** Jede vom System versendete E-Mail sendet automatisch eine Blindkopie (BCC) an `kontakt@kinderfotos-erding.de`.
|
||||
* **Versand-Historie:** Alle Aussendungen (Anzahl Empfänger, Zeitpunkt) werden in der Tabelle `release_history` protokolliert.
|
||||
* **Sicherer Test-Modus:** Über `DEV_MODE_EMAIL_RECIPIENT` können alle E-Mails global an eine Test-Adresse umgeleitet werden.
|
||||
* **Zeitzonen:** Durchgängige Verwendung von `Europe/Berlin`.
|
||||
* **Gmail OAuth:** Persistente Speicherung der Refresh-Tokens in der Datenbank.
|
||||
|
||||
## 🚀 Deployment & Konfiguration
|
||||
|
||||
Der Service wird über die Haupt-`docker-compose.yml` des Projekts verwaltet.
|
||||
|
||||
### URLs
|
||||
* **Frontend:** `https://floke-ai.duckdns.org/fotograf-de/`
|
||||
* **Webhook für Google Forms:** `https://floke-ai.duckdns.org/fotograf-de-api/api/publish-request/webhook`
|
||||
56
fotograf-de-scraper/backend/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for Chrome and other tools
|
||||
# Using a multi-stage build or a more specific base image could optimize this
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
chromium-driver \
|
||||
chromium \
|
||||
wget \
|
||||
unzip \
|
||||
fonts-liberation \
|
||||
fontconfig \
|
||||
libappindicator3-1 \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm-dev \
|
||||
libgbm-dev \
|
||||
libglvnd0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libxkbcommon0 \
|
||||
libxshmfence-dev \
|
||||
xdg-utils \
|
||||
build-essential \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Chromium as default browser for Selenium
|
||||
ENV CHROME_BIN /usr/bin/chromium
|
||||
ENV CHROME_PATH /usr/bin/chromium
|
||||
|
||||
# Copy the requirements file and install Python dependencies
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the application code
|
||||
COPY . .
|
||||
|
||||
# Create directory for error screenshots
|
||||
RUN mkdir -p /app/errors && chmod 777 /app/errors
|
||||
|
||||
# Expose the port FastAPI will run on
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the application with DEBUG logging
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "debug"]
|
||||
BIN
fotograf-de-scraper/backend/assets/OpenSans-Regular.ttf
Normal file
BIN
fotograf-de-scraper/backend/assets/logo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
fotograf-de-scraper/backend/data/blank.pdf
Normal file
87
fotograf-de-scraper/backend/database.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
import datetime
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////app/data/fotograf_jobs.db")
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
url = Column(String)
|
||||
status = Column(String)
|
||||
date = Column(String)
|
||||
shooting_type = Column(String)
|
||||
account_type = Column(String, index=True) # 'kiga' or 'schule'
|
||||
last_updated = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
class GmailToken(Base):
|
||||
__tablename__ = "gmail_tokens"
|
||||
id = Column(Integer, primary_key=True)
|
||||
token_json = Column(String) # Stores the full credentials JSON
|
||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
class DiscountCode(Base):
|
||||
__tablename__ = "discount_codes"
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String, unique=True, index=True)
|
||||
is_used = Column(Integer, default=0) # 0 for false, 1 for true
|
||||
assigned_to_email = Column(String, nullable=True)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
|
||||
class ReleaseParticipant(Base):
|
||||
__tablename__ = "release_participants"
|
||||
email = Column(String, primary_key=True)
|
||||
first_name = Column(String)
|
||||
last_updated = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
class ReleaseHistory(Base):
|
||||
__tablename__ = "release_history"
|
||||
id = Column(Integer, primary_key=True)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
recipient_count = Column(Integer)
|
||||
scheduled_time = Column(String, nullable=True)
|
||||
|
||||
class ReminderHistory(Base):
|
||||
__tablename__ = "reminder_history"
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_id = Column(String, index=True)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
recipient_count = Column(Integer)
|
||||
max_logins = Column(Integer)
|
||||
recipients_json = Column(String) # JSON list of emails/names/children
|
||||
scheduled_time = Column(String, nullable=True)
|
||||
|
||||
class JobParticipant(Base):
|
||||
__tablename__ = "job_participants"
|
||||
id = Column(Integer, primary_key=True)
|
||||
job_id = Column(String, index=True)
|
||||
child_id = Column(String, nullable=True)
|
||||
vorname_kind = Column(String, nullable=True)
|
||||
nachname_kind = Column(String, nullable=True)
|
||||
vorname_eltern = Column(String, nullable=True)
|
||||
nachname_eltern = Column(String, nullable=True)
|
||||
email_eltern = Column(String, nullable=True)
|
||||
zugangscode = Column(String, index=True)
|
||||
gruppe = Column(String, nullable=True)
|
||||
logins = Column(Integer, default=0)
|
||||
has_orders = Column(Integer, default=0) # 0 for false, 1 for true
|
||||
digital_package_ordered = Column(Integer, default=0) # 0 for false, 1 for true
|
||||
quick_login_url = Column(String, nullable=True)
|
||||
last_synced = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
68
fotograf-de-scraper/backend/debug_scraper.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
# We import directly from main to reuse the already configured functions
|
||||
from main import setup_driver, login, get_jobs_list
|
||||
|
||||
def run_debug():
|
||||
"""
|
||||
Runs the scraper logic directly for debugging purposes inside the container.
|
||||
"""
|
||||
load_dotenv()
|
||||
print("--- Starting Standalone Scraper Debug ---")
|
||||
|
||||
# --- Configuration ---
|
||||
# Change this to 'schule' to test the other account
|
||||
ACCOUNT_TO_TEST = "kiga"
|
||||
|
||||
username = os.getenv(f"{ACCOUNT_TO_TEST.upper()}_USER")
|
||||
password = os.getenv(f"{ACCOUNT_TO_TEST.upper()}_PW")
|
||||
|
||||
if not username or not password:
|
||||
print(f"!!! FATAL ERROR: Credentials for {ACCOUNT_TO_TEST} not found in .env file.")
|
||||
print("Please ensure KIGA_USER, KIGA_PW, etc. are set correctly.")
|
||||
return
|
||||
|
||||
print(f"Attempting to log in with user: {username}")
|
||||
|
||||
driver = None
|
||||
try:
|
||||
driver = setup_driver()
|
||||
if not driver:
|
||||
print("!!! FATAL ERROR: WebDriver initialization failed.")
|
||||
return
|
||||
|
||||
# Perform the login
|
||||
if login(driver, username, password):
|
||||
print("\n✅ LOGIN SUCCESSFUL!")
|
||||
print("-----------------------------------------")
|
||||
print("Now attempting to fetch jobs from the dashboard...")
|
||||
|
||||
# Fetch the jobs
|
||||
jobs = get_jobs_list(driver)
|
||||
|
||||
if jobs:
|
||||
print(f"\n✅ SUCCESS: Found {len(jobs)} jobs!")
|
||||
for i, job in enumerate(jobs):
|
||||
print(f" {i+1}. Name: {job['name']}")
|
||||
print(f" Status: {job['status']}")
|
||||
print(f" Date: {job['date']}")
|
||||
else:
|
||||
print("\n⚠️ WARNING: Login seemed successful, but no jobs were found on the dashboard.")
|
||||
print("This could be due to incorrect page selectors for the job list.")
|
||||
|
||||
else:
|
||||
print("\n❌ LOGIN FAILED.")
|
||||
print("Please check credentials in .env and the login selectors in main.py.")
|
||||
print("A screenshot of the error might have been saved if the scraper has permission.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n\n!!! AN UNEXPECTED ERROR OCCURRED: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if driver:
|
||||
print("\n--- Debug script finished. Closing WebDriver. ---")
|
||||
driver.quit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_debug()
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 56 KiB |
140
fotograf-de-scraper/backend/gmail_service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from googleapiclient.discovery import build
|
||||
from google.auth.transport.requests import Request
|
||||
from sqlalchemy.orm import Session
|
||||
from database import GmailToken
|
||||
import base64
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
logger = logging.getLogger("gmail-service")
|
||||
|
||||
# Scopes required for sending emails
|
||||
SCOPES = ['https://www.googleapis.com/auth/gmail.send']
|
||||
|
||||
class GmailService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.client_id = os.getenv("google_fotograf_client_id")
|
||||
self.client_secret = os.getenv("google_fotograf_secret")
|
||||
|
||||
# Redirect URI - must match what was configured in Google Console
|
||||
# We try to detect the public URL, fallback to duckdns
|
||||
self.redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback")
|
||||
|
||||
def _get_client_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"web": {
|
||||
"client_id": self.client_id,
|
||||
"project_id": "fotograf-tool",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": self.client_secret,
|
||||
"redirect_uris": [self.redirect_uri]
|
||||
}
|
||||
}
|
||||
|
||||
def get_auth_url(self) -> str:
|
||||
flow = Flow.from_client_config(
|
||||
self._get_client_config(),
|
||||
scopes=SCOPES,
|
||||
redirect_uri=self.redirect_uri
|
||||
)
|
||||
auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline')
|
||||
return auth_url
|
||||
|
||||
def handle_callback(self, code: str):
|
||||
flow = Flow.from_client_config(
|
||||
self._get_client_config(),
|
||||
scopes=SCOPES,
|
||||
redirect_uri=self.redirect_uri
|
||||
)
|
||||
flow.fetch_token(code=code)
|
||||
credentials = flow.credentials
|
||||
self._save_token(credentials)
|
||||
return credentials
|
||||
|
||||
def _save_token(self, credentials):
|
||||
token_data = {
|
||||
'token': credentials.token,
|
||||
'refresh_token': credentials.refresh_token,
|
||||
'token_uri': credentials.token_uri,
|
||||
'client_id': credentials.client_id,
|
||||
'client_secret': credentials.client_secret,
|
||||
'scopes': credentials.scopes
|
||||
}
|
||||
|
||||
db_token = self.db.query(GmailToken).first()
|
||||
if not db_token:
|
||||
db_token = GmailToken(token_json=json.dumps(token_data))
|
||||
self.db.add(db_token)
|
||||
else:
|
||||
db_token.token_json = json.dumps(token_data)
|
||||
|
||||
self.db.commit()
|
||||
logger.info("Gmail OAuth token saved to database.")
|
||||
|
||||
def get_credentials(self) -> Optional[Credentials]:
|
||||
db_token = self.db.query(GmailToken).first()
|
||||
if not db_token:
|
||||
return None
|
||||
|
||||
token_data = json.loads(db_token.token_json)
|
||||
creds = Credentials.from_authorized_user_info(token_data, SCOPES)
|
||||
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
logger.info("Gmail token expired, refreshing...")
|
||||
creds.refresh(Request())
|
||||
self._save_token(creds)
|
||||
|
||||
return creds
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
try:
|
||||
creds = self.get_credentials()
|
||||
return creds is not None and creds.valid
|
||||
except Exception as e:
|
||||
logger.error(f"Auth check failed: {e}")
|
||||
return False
|
||||
|
||||
def send_email(self, to: str, subject: str, body_html: str) -> bool:
|
||||
creds = self.get_credentials()
|
||||
if not creds:
|
||||
logger.error("Cannot send email: Not authenticated.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# DEV MODE OVERRIDE
|
||||
dev_email = os.getenv("DEV_MODE_EMAIL_RECIPIENT")
|
||||
original_to = to
|
||||
if dev_email:
|
||||
logger.warning(f"⚠️ DEV MODE ACTIVE: Redirecting email originally intended for {original_to} to {dev_email}")
|
||||
to = dev_email
|
||||
|
||||
service = build('gmail', 'v1', credentials=creds)
|
||||
message = MIMEText(body_html, 'html')
|
||||
message['to'] = to
|
||||
message['subject'] = subject
|
||||
message['bcc'] = 'kontakt@kinderfotos-erding.de'
|
||||
|
||||
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
|
||||
send_result = service.users().messages().send(
|
||||
userId='me',
|
||||
body={'raw': raw_message}
|
||||
).execute()
|
||||
|
||||
if dev_email:
|
||||
logger.info(f"Test-Email sent to {to} (Original target: {original_to}). Message ID: {send_result['id']}")
|
||||
else:
|
||||
logger.info(f"Email sent to {to}. Message ID: {send_result['id']}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to}: {e}")
|
||||
return False
|
||||
49
fotograf-de-scraper/backend/inspect_orders.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import Job
|
||||
from main import setup_driver, login
|
||||
import time
|
||||
|
||||
load_dotenv()
|
||||
|
||||
engine = create_engine("sqlite:////app/data/fotograf_jobs.db")
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
# Get latest job
|
||||
job = db.query(Job).order_by(Job.last_updated.desc()).first()
|
||||
if not job:
|
||||
print("No jobs found in database.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Using Job ID: {job.id} ({job.name}), Account: {job.account_type}")
|
||||
|
||||
username = os.getenv(f"{job.account_type.upper()}_USER")
|
||||
password = os.getenv(f"{job.account_type.upper()}_PW")
|
||||
|
||||
driver = setup_driver()
|
||||
if not driver:
|
||||
print("Failed to init driver")
|
||||
sys.exit(1)
|
||||
|
||||
if not login(driver, username, password):
|
||||
print("Login failed")
|
||||
driver.quit()
|
||||
sys.exit(1)
|
||||
|
||||
orders_url = f"https://app.fotograf.de/config_jobs_orders/index/{job.id}/customer_orders"
|
||||
print(f"Navigating to {orders_url}")
|
||||
driver.get(orders_url)
|
||||
time.sleep(5) # wait for page to load
|
||||
|
||||
html = driver.page_source
|
||||
with open("orders_page.html", "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
driver.save_screenshot("orders_page.png")
|
||||
print("Saved orders_page.html and orders_page.png")
|
||||
|
||||
driver.quit()
|
||||
1713
fotograf-de-scraper/backend/main.py
Normal file
18
fotograf-de-scraper/backend/migrate_db.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "/app/data/fotograf_jobs.db"
|
||||
if not os.path.exists(db_path):
|
||||
db_path = "fotograf-de-scraper/backend/data/fotograf_jobs.db"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE job_participants ADD COLUMN digital_package_ordered INTEGER DEFAULT 0;")
|
||||
print("Column 'digital_package_ordered' added successfully.")
|
||||
except sqlite3.OperationalError:
|
||||
print("Column 'digital_package_ordered' already exists.")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
12
fotograf-de-scraper/backend/orders_page.html
Normal file
BIN
fotograf-de-scraper/backend/orders_page.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
229
fotograf-de-scraper/backend/publish_request_api.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db, DiscountCode, ReleaseParticipant, ReleaseHistory
|
||||
import datetime
|
||||
import logging
|
||||
from gmail_service import GmailService
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
router = APIRouter(prefix="/api/publish-request", tags=["publish-request"])
|
||||
logger = logging.getLogger("publish-request")
|
||||
|
||||
# Timezone for Berlin
|
||||
TZ_BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
# Official Project Signature
|
||||
SIGNATURE_HTML = """
|
||||
<br><br>
|
||||
<span style="color: #888;">--</span><br>
|
||||
<div dir="ltr">
|
||||
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin-top: 5px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="220" valign="top" style="padding-right: 15px;">
|
||||
<img width="200" src="https://lh3.googleusercontent.com/d/1K7RODOqKE2e1nRJ3D4dEWdjthoTMyXUq" alt="Kinderfotos Erding Logo" style="display: block;">
|
||||
</td>
|
||||
<td valign="bottom" style="padding-left: 15px; border-left: 1px solid #ddd; font-family: sans-serif; font-size: 13px; color: #333; line-height: 1.5;">
|
||||
<p style="margin: 0;"><b>Kinderfotos Erding</b> | <a href="http://www.kinderfotos-erding.de/" target="_blank" style="color: #1155cc; text-decoration: none;">www.kinderfotos-erding.de</a></p>
|
||||
<p style="margin: 0; color: #666;">Gartenstr. 10 | 85445 Oberding | 08122-8470867</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
class CodesUpload(BaseModel):
|
||||
codes: str # comma separated
|
||||
|
||||
class SendReleaseRequest(BaseModel):
|
||||
emails: List[Dict[str, str]]
|
||||
scheduled_time: Optional[str] = None # e.g. "10:00"
|
||||
participants: Optional[List[Dict[str, str]]] = None # [{email, first_name}]
|
||||
|
||||
async def delayed_send(emails: List[Dict[str, str]], scheduled_time: str, db_session_factory):
|
||||
try:
|
||||
# Calculate delay using Berlin Timezone
|
||||
now = datetime.datetime.now(TZ_BERLIN)
|
||||
target_h, target_m = map(int, scheduled_time.split(":"))
|
||||
target_time = now.replace(hour=target_h, minute=target_m, second=0, microsecond=0)
|
||||
|
||||
if target_time < now:
|
||||
target_time += datetime.timedelta(days=1)
|
||||
|
||||
delay_seconds = (target_time - now).total_seconds()
|
||||
logger.info(f"Scheduling {len(emails)} emails for {scheduled_time} Berlin Time (in {delay_seconds} seconds)")
|
||||
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
# We need a fresh DB session for the background task
|
||||
db = db_session_factory()
|
||||
try:
|
||||
service = GmailService(db)
|
||||
success_count = 0
|
||||
for email_data in emails:
|
||||
if service.send_email(email_data["to"], email_data["subject"], email_data["body"]):
|
||||
success_count += 1
|
||||
await asyncio.sleep(1) # Rate limiting
|
||||
|
||||
logger.info(f"Scheduled send complete: {success_count}/{len(emails)} success.")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.exception("Error in delayed_send background task")
|
||||
|
||||
@router.post("/send")
|
||||
async def send_requests(data: SendReleaseRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
# Store participant names for later (webhook)
|
||||
if data.participants:
|
||||
for p in data.participants:
|
||||
email = p.get("email", "").strip().lower()
|
||||
first_name = p.get("first_name", "").strip()
|
||||
if email and first_name:
|
||||
existing = db.query(ReleaseParticipant).filter(ReleaseParticipant.email == email).first()
|
||||
if existing:
|
||||
existing.first_name = first_name
|
||||
else:
|
||||
db.add(ReleaseParticipant(email=email, first_name=first_name))
|
||||
db.commit()
|
||||
|
||||
if data.scheduled_time:
|
||||
# Pass a way to get a new session to the background task
|
||||
from database import SessionLocal
|
||||
|
||||
# Log to history
|
||||
db.add(ReleaseHistory(recipient_count=len(data.emails), scheduled_time=data.scheduled_time))
|
||||
db.commit()
|
||||
|
||||
background_tasks.add_task(delayed_send, data.emails, data.scheduled_time, SessionLocal)
|
||||
return {"status": "scheduled", "message": f"Versand für {data.scheduled_time} geplant."}
|
||||
|
||||
# Log immediate send to history
|
||||
db.add(ReleaseHistory(recipient_count=len(data.emails), scheduled_time="Sofort"))
|
||||
db.commit()
|
||||
|
||||
# Immediate send
|
||||
service = GmailService(db)
|
||||
success = 0
|
||||
failed = []
|
||||
for email_data in data.emails:
|
||||
if service.send_email(email_data["to"], email_data["subject"], email_data["body"]):
|
||||
success += 1
|
||||
else:
|
||||
failed.append(email_data["to"])
|
||||
|
||||
return {"status": "success", "success": success, "failed": failed}
|
||||
|
||||
@router.get("/history")
|
||||
def get_history(db: Session = Depends(get_db)):
|
||||
history = db.query(ReleaseHistory).order_by(ReleaseHistory.timestamp.desc()).all()
|
||||
return [{"id": h.id, "timestamp": h.timestamp.isoformat(), "recipient_count": h.recipient_count, "scheduled_time": h.scheduled_time} for h in history]
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(db: Session = Depends(get_db)):
|
||||
total = db.query(DiscountCode).count()
|
||||
used = db.query(DiscountCode).filter(DiscountCode.is_used == 1).count()
|
||||
available = total - used
|
||||
return {"total": total, "used": used, "available": available}
|
||||
|
||||
@router.get("/responses")
|
||||
def get_responses(db: Session = Depends(get_db)):
|
||||
responses = db.query(DiscountCode).filter(DiscountCode.is_used == 1).all()
|
||||
return [{"email": r.assigned_to_email, "code": r.code, "used_at": r.used_at.isoformat()} for r in responses]
|
||||
|
||||
@router.post("/codes")
|
||||
def upload_codes(data: CodesUpload, db: Session = Depends(get_db)):
|
||||
codes_list = [c.strip() for c in data.codes.split(",") if c.strip()]
|
||||
added = 0
|
||||
for code in set(codes_list):
|
||||
existing = db.query(DiscountCode).filter(DiscountCode.code == code).first()
|
||||
if not existing:
|
||||
new_code = DiscountCode(code=code, is_used=0)
|
||||
db.add(new_code)
|
||||
added += 1
|
||||
db.commit()
|
||||
return {"status": "success", "added": added}
|
||||
|
||||
class WebhookData(BaseModel):
|
||||
email: str
|
||||
|
||||
@router.post("/webhook")
|
||||
async def handle_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
# Try to parse JSON from Google Forms webhook
|
||||
try:
|
||||
data = await request.json()
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||
|
||||
# We expect {"email": "..."} or similar from the Google Apps Script
|
||||
email = data.get("email") or data.get("Email")
|
||||
if not email:
|
||||
logger.error(f"Webhook received without email: {data}")
|
||||
return {"status": "error", "message": "Email not found in webhook payload"}
|
||||
|
||||
email = email.strip().lower()
|
||||
|
||||
# Check if this email already got a code
|
||||
already_assigned = db.query(DiscountCode).filter(DiscountCode.assigned_to_email == email).first()
|
||||
if already_assigned:
|
||||
logger.info(f"Email {email} already received code {already_assigned.code}")
|
||||
return {"status": "success", "message": "Already sent"}
|
||||
|
||||
# Get a free code
|
||||
free_code = db.query(DiscountCode).filter(DiscountCode.is_used == 0).first()
|
||||
if not free_code:
|
||||
logger.error("NO FREE DISCOUNT CODES LEFT!")
|
||||
return {"status": "error", "message": "No codes available"}
|
||||
|
||||
# Look up participant name
|
||||
participant = db.query(ReleaseParticipant).filter(ReleaseParticipant.email == email).first()
|
||||
first_name = participant.first_name if participant else "Ihr Lieben"
|
||||
|
||||
# Mark as used
|
||||
free_code.is_used = 1
|
||||
free_code.assigned_to_email = email
|
||||
free_code.used_at = datetime.datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Send Thank You Email with GmailService
|
||||
service = GmailService(db)
|
||||
subject = "Dankeschön für Eure Freigabe & Euer Rabattcode"
|
||||
|
||||
# Image provided by user
|
||||
INSTRUCTIONS_IMAGE_URL = "https://mail.google.com/mail/u/2?ui=2&ik=719adaa3c5&attid=0.1&permmsgid=msg-a:r7482671925923393616&th=196e322c399dbc7f&view=fimg&fur=ip&permmsgid=msg-a:r7482671925923393616&sz=s0-l75-ft&attbid=ANGjdJ9_U6ayMFgwbupt4HalTKO867IHx6N70eNbPfQmTLNzRXilJxI-n8a1gjM8xVcP5HEOgaVxfp3FnJPzTYEEYhK4gSU-Il_0a6OtzFYscp55_W4iyxuxjyPvK4&disp=emb&realattid=ii_maspzxv50&zw"
|
||||
|
||||
body_html = f"""
|
||||
<p>Hallo {first_name},</p>
|
||||
<p>Vielen Dank nochmal für die Freigabe zur Veröffentlichung, das ist super nett von Euch!</p>
|
||||
<p>Hier ist euer Gutscheincode über 25 Euro: <strong style="font-size: 18px; color: #4F46E5;">{free_code.code}</strong></p>
|
||||
<p>Um den Gutschein einzugeben, musst du auf den Preis des Warenkorbs drücken (über dem Button zur Kasse gehen):</p>
|
||||
<p><img src="{INSTRUCTIONS_IMAGE_URL}" alt="Anleitung Gutschein einlösen" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;"></p>
|
||||
<p>Liebe Grüße,<br>das Team von Kinderfotos Erding</p>
|
||||
{SIGNATURE_HTML}
|
||||
"""
|
||||
|
||||
try:
|
||||
success = service.send_email(email, subject, body_html)
|
||||
if success:
|
||||
logger.info(f"Successfully sent code {free_code.code} to {email}")
|
||||
return {"status": "success", "message": "Email sent"}
|
||||
else:
|
||||
logger.error(f"Failed to send email to {email}")
|
||||
free_code.is_used = 0
|
||||
free_code.assigned_to_email = None
|
||||
free_code.used_at = None
|
||||
db.commit()
|
||||
return {"status": "error", "message": "Failed to send email"}
|
||||
except Exception as e:
|
||||
logger.exception("Error sending webhook email")
|
||||
free_code.is_used = 0
|
||||
free_code.assigned_to_email = None
|
||||
free_code.used_at = None
|
||||
db.commit()
|
||||
return {"status": "error", "message": str(e)}
|
||||
353
fotograf-de-scraper/backend/qr_generator.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import os
|
||||
import requests
|
||||
import io
|
||||
import datetime
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("qr-card-generator")
|
||||
|
||||
def get_calendly_event_types(api_token: str):
|
||||
"""
|
||||
Fetches available event types for the current user.
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# 1. Get current user info
|
||||
user_url = "https://api.calendly.com/users/me"
|
||||
user_response = requests.get(user_url, headers=headers)
|
||||
if not user_response.ok:
|
||||
raise Exception(f"Calendly API Error: {user_response.status_code}")
|
||||
|
||||
user_data = user_response.json()
|
||||
user_uri = user_data['resource']['uri']
|
||||
|
||||
# 2. Get event types
|
||||
event_types_url = "https://api.calendly.com/event_types"
|
||||
params = {
|
||||
'user': user_uri
|
||||
}
|
||||
|
||||
types_response = requests.get(event_types_url, headers=headers, params=params)
|
||||
if not types_response.ok:
|
||||
raise Exception(f"Calendly API Error: {types_response.status_code}")
|
||||
|
||||
types_data = types_response.json()
|
||||
return types_data['collection']
|
||||
|
||||
def get_calendly_events_raw(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None):
|
||||
"""
|
||||
Debug function to fetch raw Calendly data without formatting.
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# Defaults: current time to +2 years
|
||||
if not start_time:
|
||||
start_time = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
if not end_time:
|
||||
end_time = (datetime.datetime.utcnow() + datetime.timedelta(days=730)).isoformat() + "Z"
|
||||
|
||||
# 1. Get current user info to get the user URI
|
||||
user_url = "https://api.calendly.com/users/me"
|
||||
user_response = requests.get(user_url, headers=headers)
|
||||
if not user_response.ok:
|
||||
raise Exception(f"Calendly API Error: {user_response.status_code}")
|
||||
|
||||
user_data = user_response.json()
|
||||
user_uri = user_data['resource']['uri']
|
||||
|
||||
# 2. Get events for the user
|
||||
events_url = "https://api.calendly.com/scheduled_events"
|
||||
params = {
|
||||
'user': user_uri,
|
||||
'status': 'active',
|
||||
'min_start_time': start_time,
|
||||
'max_start_time': end_time,
|
||||
'count': 100
|
||||
}
|
||||
|
||||
all_events = []
|
||||
url = events_url
|
||||
|
||||
while url:
|
||||
if url == events_url:
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
else:
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if not response.ok:
|
||||
raise Exception(f"Calendly API Error: {response.status_code} - {response.text}")
|
||||
|
||||
data = response.json()
|
||||
all_events.extend(data.get('collection', []))
|
||||
|
||||
pagination = data.get('pagination', {})
|
||||
url = pagination.get('next_page') # Use the full URL provided by Calendly
|
||||
|
||||
raw_results = []
|
||||
|
||||
# 3. Get invitees
|
||||
for event in all_events:
|
||||
event_name = event.get('name', '')
|
||||
# Filter by event type if provided
|
||||
if event_type_name and event_type_name.lower() not in event_name.lower():
|
||||
continue
|
||||
|
||||
event_uri = event['uri']
|
||||
event_uuid = event_uri.split('/')[-1]
|
||||
invitees_url = f"https://api.calendly.com/scheduled_events/{event_uuid}/invitees"
|
||||
|
||||
invitees_response = requests.get(invitees_url, headers=headers)
|
||||
if not invitees_response.ok:
|
||||
continue
|
||||
|
||||
invitees_data = invitees_response.json()
|
||||
|
||||
for invitee in invitees_data['collection']:
|
||||
raw_results.append({
|
||||
"event_name": event_name,
|
||||
"start_time": event['start_time'],
|
||||
"invitee_name": invitee['name'],
|
||||
"invitee_email": invitee['email'],
|
||||
"questions_and_answers": invitee.get('questions_and_answers', [])
|
||||
})
|
||||
|
||||
return raw_results
|
||||
|
||||
def get_calendly_events(api_token: str, start_time: str = None, end_time: str = None, event_type_name: str = None):
|
||||
"""
|
||||
Fetches events from Calendly API for the current user within a time range.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
raw_data = get_calendly_events_raw(api_token, start_time, end_time, event_type_name)
|
||||
formatted_data = []
|
||||
|
||||
# Calculate midnight today in Berlin time for filtering
|
||||
now_berlin = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
midnight_today = now_berlin.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for item in raw_data:
|
||||
# Parse start time from UTC
|
||||
start_dt = datetime.datetime.fromisoformat(item['start_time'].replace('Z', '+00:00'))
|
||||
# Convert to Europe/Berlin (CET/CEST)
|
||||
start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin"))
|
||||
|
||||
# Filter out past events
|
||||
if start_dt < midnight_today:
|
||||
logger.debug(f"Skipping past event: {item['invitee_name']} at {start_dt}")
|
||||
continue
|
||||
|
||||
logger.info(f"Processing event: {item['invitee_name']} at {start_dt}")
|
||||
# Format as HH:MM
|
||||
time_str = start_dt.strftime('%H:%M')
|
||||
|
||||
name = item['invitee_name']
|
||||
|
||||
# Extract specific answers from the Calendly form
|
||||
num_children = ""
|
||||
additional_notes = ""
|
||||
has_consent = False
|
||||
questions_and_answers = item.get('questions_and_answers', [])
|
||||
|
||||
|
||||
for q_a in questions_and_answers:
|
||||
q_text = q_a.get('question', '').lower()
|
||||
a_text = q_a.get('answer', '')
|
||||
|
||||
# Flexible matching for number of children
|
||||
if any(kw in q_text for kw in ["wie viele kinder", "anzahl kinder", "wieviele kinder"]):
|
||||
num_children = a_text
|
||||
elif "nachricht" in q_text or "anmerkung" in q_text:
|
||||
additional_notes = a_text
|
||||
elif "veröffentlichen" in q_text or "bilder" in q_text:
|
||||
if "ja" in a_text.lower():
|
||||
has_consent = True
|
||||
|
||||
|
||||
# Construct the final string: "Name, X Kinder // HH:MM Uhr ☑"
|
||||
final_text = f"{name}"
|
||||
if num_children:
|
||||
final_text += f", {num_children}"
|
||||
|
||||
final_text += f" // {time_str} Uhr"
|
||||
|
||||
if additional_notes:
|
||||
final_text += f" ({additional_notes})"
|
||||
|
||||
if has_consent:
|
||||
final_text += " ☑"
|
||||
|
||||
formatted_data.append(final_text)
|
||||
|
||||
|
||||
logger.info(f"Processed {len(formatted_data)} invitees.")
|
||||
return formatted_data
|
||||
|
||||
|
||||
def overlay_text_on_pdf(base_pdf_path: str, output_pdf_path: str, texts: list):
|
||||
"""
|
||||
Target:
|
||||
Element 1: X: 72mm, Y: 22mm + 9mm = 31mm
|
||||
Element 2: X: 72mm, Y: 171mm + 9mm = 180mm
|
||||
"""
|
||||
|
||||
# Convert mm to points (1 mm = 2.83465 points)
|
||||
mm_to_pt = 2.83465
|
||||
|
||||
# A4 dimensions in points (approx 595.27 x 841.89)
|
||||
page_width, page_height = A4
|
||||
|
||||
# User coordinates are from top-left.
|
||||
# ReportLab uses bottom-left as (0,0).
|
||||
# Element 1 (Top): X = 72mm, Y = 31mm (from top) -> Y = page_height - 31mm
|
||||
# Element 2 (Bottom): X = 72mm, Y = 180mm (from top) -> Y = page_height - 180mm
|
||||
|
||||
x_pos = 72 * mm_to_pt
|
||||
y_pos_1 = page_height - (31 * mm_to_pt)
|
||||
y_pos_2 = page_height - (180 * mm_to_pt)
|
||||
|
||||
reader = PdfReader(base_pdf_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
total_pages = len(reader.pages)
|
||||
max_capacity = total_pages * 2
|
||||
|
||||
if len(texts) > max_capacity:
|
||||
logger.warning(f"Not enough pages in base PDF. Have {len(texts)} invitees but only space for {max_capacity}. Truncating.")
|
||||
texts = texts[:max_capacity]
|
||||
|
||||
# We need to process pairs of texts for each page
|
||||
text_pairs = [texts[i:i+2] for i in range(0, len(texts), 2)]
|
||||
|
||||
|
||||
# Load OpenSans font to support UTF-8 extended characters
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
font_path = os.path.join(os.path.dirname(__file__), "assets", "OpenSans-Regular.ttf")
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', font_path))
|
||||
|
||||
for page_idx, pair in enumerate(text_pairs):
|
||||
if page_idx >= total_pages:
|
||||
break # Safety first
|
||||
|
||||
# Create a new blank page in memory to draw the text
|
||||
packet = io.BytesIO()
|
||||
can = canvas.Canvas(packet, pagesize=A4)
|
||||
|
||||
# Draw the text.
|
||||
def draw_text_with_checkbox(can, x, y, text):
|
||||
can.setFont("OpenSans", 12)
|
||||
if text.endswith(" ☑"):
|
||||
clean_text = text[:-2] # remove the checkmark part
|
||||
can.drawString(x, y, clean_text)
|
||||
|
||||
# Calculate width to place the checkbox right after the text
|
||||
text_width = can.stringWidth(clean_text, "OpenSans", 12)
|
||||
box_x = x + text_width + 8
|
||||
|
||||
size = 10
|
||||
can.rect(box_x, y - 1, size, size)
|
||||
can.setLineWidth(1.5)
|
||||
can.line(box_x + 2, y + 3, box_x + 4.5, y + 0.5)
|
||||
can.line(box_x + 4.5, y + 0.5, box_x + 8.5, y + 7)
|
||||
can.setLineWidth(1)
|
||||
else:
|
||||
can.drawString(x, y, text)
|
||||
|
||||
if len(pair) > 0:
|
||||
|
||||
draw_text_with_checkbox(can, x_pos, y_pos_1, pair[0])
|
||||
if len(pair) > 1:
|
||||
draw_text_with_checkbox(can, x_pos, y_pos_2, pair[1])
|
||||
|
||||
can.save()
|
||||
packet.seek(0)
|
||||
|
||||
# Read the text PDF we just created
|
||||
new_pdf = PdfReader(packet)
|
||||
text_page = new_pdf.pages[0]
|
||||
|
||||
# Get the specific page from the original PDF
|
||||
page_to_merge = reader.pages[page_idx]
|
||||
page_to_merge.merge_page(text_page)
|
||||
|
||||
writer.add_page(page_to_merge)
|
||||
|
||||
# If there are pages left in the base PDF that we didn't use, append them too?
|
||||
# Usually you'd want to keep them or discard them. We'll discard unused pages for now
|
||||
# to avoid empty cards, or you can change this loop to include them.
|
||||
|
||||
with open(output_pdf_path, "wb") as output_file:
|
||||
writer.write(output_file)
|
||||
|
||||
logger.info(f"Successfully generated overlaid PDF at {output_pdf_path}")
|
||||
|
||||
|
||||
def generate_siblings_qr_overlay(base_pdf_path: str, output_pdf_path: str, families: list):
|
||||
import io
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import os
|
||||
|
||||
font_path = os.path.join(os.path.dirname(__file__), "assets", "OpenSans-Regular.ttf")
|
||||
if os.path.exists(font_path):
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', font_path))
|
||||
font_name = 'OpenSans'
|
||||
else:
|
||||
font_name = 'Helvetica'
|
||||
|
||||
mm_to_pt = 2.83465
|
||||
page_width, page_height = A4
|
||||
x_pos = 72 * mm_to_pt
|
||||
y_pos_1 = page_height - (31 * mm_to_pt)
|
||||
y_pos_2 = page_height - (180 * mm_to_pt)
|
||||
|
||||
reader = PdfReader(base_pdf_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
family_idx = 0
|
||||
total_families = len(families)
|
||||
|
||||
for i in range(len(reader.pages)):
|
||||
page = reader.pages[i]
|
||||
|
||||
if family_idx < total_families:
|
||||
packet = io.BytesIO()
|
||||
c = canvas.Canvas(packet, pagesize=A4)
|
||||
c.setFont(font_name, 11)
|
||||
|
||||
# First card on the page
|
||||
if family_idx < total_families:
|
||||
text_top = f"Geschwisterbilder Familie {families[family_idx]['nachname']}"
|
||||
c.drawString(x_pos, y_pos_1, text_top)
|
||||
family_idx += 1
|
||||
|
||||
# Second card on the page
|
||||
if family_idx < total_families:
|
||||
text_bottom = f"Geschwisterbilder Familie {families[family_idx]['nachname']}"
|
||||
c.drawString(x_pos, y_pos_2, text_bottom)
|
||||
family_idx += 1
|
||||
|
||||
c.save()
|
||||
packet.seek(0)
|
||||
overlay_pdf = PdfReader(packet)
|
||||
page.merge_page(overlay_pdf.pages[0])
|
||||
|
||||
writer.add_page(page)
|
||||
|
||||
with open(output_pdf_path, "wb") as output_file:
|
||||
writer.write(output_file)
|
||||
17
fotograf-de-scraper/backend/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn==0.30.1
|
||||
python-dotenv==1.0.1
|
||||
selenium==4.22.0
|
||||
webdriver-manager==4.0.1
|
||||
pandas==2.2.2
|
||||
weasyprint==62.1
|
||||
jinja2==3.1.4
|
||||
pydyf==0.10.0
|
||||
sqlalchemy==2.0.31
|
||||
requests==2.31.0
|
||||
reportlab==4.0.9
|
||||
PyPDF2==3.0.1
|
||||
tzdata
|
||||
google-api-python-client==2.122.0
|
||||
google-auth-httplib2==0.2.0
|
||||
google-auth-oauthlib==1.2.0
|
||||
183
fotograf-de-scraper/backend/siblings_logic.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import pandas as pd
|
||||
import os
|
||||
import logging
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from collections import defaultdict
|
||||
from main import get_berlin_now_str, get_logo_base64
|
||||
from weasyprint import HTML
|
||||
|
||||
logger = logging.getLogger("fotograf-scraper")
|
||||
|
||||
def generate_siblings_pdf_from_csv(csv_path: str, institution: str, calendly_events: list, list_type: str, output_path: str):
|
||||
logger.info(f"Generating Siblings PDF for {institution} from {csv_path}")
|
||||
df = None
|
||||
for sep in [";", ","]:
|
||||
try:
|
||||
test_df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig", nrows=5)
|
||||
if len(test_df.columns) > 1:
|
||||
df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig")
|
||||
break
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
if df is None:
|
||||
try:
|
||||
df = pd.read_csv(csv_path, sep=";", encoding="latin1")
|
||||
except:
|
||||
raise Exception("CSV konnte nicht gelesen werden.")
|
||||
|
||||
df.columns = df.columns.str.strip().str.replace('"', "")
|
||||
|
||||
# Identify Email Column
|
||||
email_col = next((c for c in df.columns if "email" in c.lower()), None)
|
||||
if not email_col:
|
||||
email_col = next((c for c in df.columns if "e-mail" in c.lower()), None)
|
||||
|
||||
if not email_col:
|
||||
logger.warning("No email column found. Siblings logic cannot run.")
|
||||
families = []
|
||||
else:
|
||||
# Columns mappings
|
||||
group_col = next((c for c in df.columns if c.lower() in ["gruppe", "klasse", "group", "class"]), None)
|
||||
lastname_col = next((c for c in df.columns if "nachname" in c.lower()), None)
|
||||
firstname_col = next((c for c in df.columns if "vorname" in c.lower()), None)
|
||||
wunsch_col = next((c for c in df.columns if "familie" in c.lower() or "geschwister" in c.lower() and "fotos" in c.lower()), None)
|
||||
if not wunsch_col:
|
||||
wunsch_col = next((c for c in df.columns if "familie / geschwister" in c.lower()), None)
|
||||
|
||||
# Build Calendly Dictionary for fast lookup (Email -> Time)
|
||||
from zoneinfo import ZoneInfo
|
||||
import datetime
|
||||
calendly_map = {}
|
||||
now_berlin = datetime.datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
midnight_today = now_berlin.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for event in calendly_events:
|
||||
try:
|
||||
start_dt = datetime.datetime.fromisoformat(event['start_time'].replace('Z', '+00:00'))
|
||||
start_dt = start_dt.astimezone(ZoneInfo("Europe/Berlin"))
|
||||
calendly_map[event['invitee_email'].lower().strip()] = start_dt.strftime("%d.%m. %H:%M")
|
||||
except:
|
||||
pass
|
||||
|
||||
families_dict = defaultdict(list)
|
||||
df = df.fillna("")
|
||||
|
||||
# Group by email
|
||||
for _, row in df.iterrows():
|
||||
email = str(row[email_col]).strip().lower()
|
||||
if email and "@" in email:
|
||||
families_dict[email].append(row)
|
||||
|
||||
families = []
|
||||
for email, rows in families_dict.items():
|
||||
if len(rows) > 1: # SIBLINGS DETECTED
|
||||
family_last_name = str(rows[0][lastname_col]).strip() if lastname_col else "Unbekannt"
|
||||
|
||||
children = []
|
||||
for r in rows:
|
||||
child_first = str(r[firstname_col]).strip() if firstname_col else ""
|
||||
child_group = str(r[group_col]).strip() if group_col else ""
|
||||
children.append({"vorname": child_first, "gruppe": child_group})
|
||||
|
||||
# Check fotograf wunsch
|
||||
fotograf_wunsch = False
|
||||
if wunsch_col:
|
||||
for r in rows:
|
||||
val = str(r[wunsch_col]).lower()
|
||||
if "ja" in val or "familien" in val or "geschwister" in val:
|
||||
fotograf_wunsch = True
|
||||
break
|
||||
|
||||
calendly_time = calendly_map.get(email, None)
|
||||
|
||||
families.append({
|
||||
"nachname": family_last_name,
|
||||
"children": children,
|
||||
"fotograf_wunsch": fotograf_wunsch,
|
||||
"calendly_time": calendly_time
|
||||
})
|
||||
|
||||
# Sort by last name
|
||||
families.sort(key=lambda x: x["nachname"])
|
||||
|
||||
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
env = Environment(loader=FileSystemLoader(template_dir))
|
||||
template = env.get_template("siblings_list.html")
|
||||
|
||||
current_time = get_berlin_now_str()
|
||||
logo_base64 = get_logo_base64()
|
||||
|
||||
render_context = {
|
||||
"institution": institution,
|
||||
"current_time": current_time,
|
||||
"logo_base64": logo_base64,
|
||||
"families": families
|
||||
}
|
||||
|
||||
html_out = template.render(render_context)
|
||||
pdf = HTML(string=html_out).write_pdf()
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(pdf)
|
||||
logger.info(f"Siblings PDF saved to {output_path}")
|
||||
|
||||
def get_sibling_families_from_csv(csv_path: str, calendly_events: list = None) -> list:
|
||||
df = None
|
||||
for sep in [";", ","]:
|
||||
try:
|
||||
test_df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig", nrows=5)
|
||||
if len(test_df.columns) > 1:
|
||||
df = pd.read_csv(csv_path, sep=sep, encoding="utf-8-sig")
|
||||
break
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
if df is None:
|
||||
try:
|
||||
df = pd.read_csv(csv_path, sep=";", encoding="latin1")
|
||||
except:
|
||||
raise Exception("CSV konnte nicht gelesen werden.")
|
||||
|
||||
df.columns = df.columns.str.strip().str.replace('"', "")
|
||||
|
||||
email_col = next((c for c in df.columns if "email" in c.lower()), None)
|
||||
if not email_col:
|
||||
email_col = next((c for c in df.columns if "e-mail" in c.lower()), None)
|
||||
|
||||
if not email_col:
|
||||
return []
|
||||
|
||||
lastname_col = next((c for c in df.columns if "nachname" in c.lower()), None)
|
||||
|
||||
# Build Calendly Email Set for filtering
|
||||
booked_emails = set()
|
||||
if calendly_events:
|
||||
for event in calendly_events:
|
||||
email = event.get('invitee_email', '').lower().strip()
|
||||
if email:
|
||||
booked_emails.add(email)
|
||||
|
||||
families_dict = defaultdict(list)
|
||||
df = df.fillna("")
|
||||
|
||||
for _, row in df.iterrows():
|
||||
email = str(row[email_col]).strip().lower()
|
||||
if email and "@" in email:
|
||||
families_dict[email].append(row)
|
||||
|
||||
families = []
|
||||
for email, rows in families_dict.items():
|
||||
if len(rows) > 1: # SIBLINGS DETECTED
|
||||
# FILTER OUT if they already have an appointment
|
||||
if email in booked_emails:
|
||||
logger.info(f"Family {email} already has Calendly appointment, skipping QR card.")
|
||||
continue
|
||||
|
||||
family_last_name = str(rows[0][lastname_col]).strip() if lastname_col else "Unbekannt"
|
||||
families.append({
|
||||
"nachname": family_last_name
|
||||
})
|
||||
|
||||
families.sort(key=lambda x: x["nachname"])
|
||||
return families
|
||||
202
fotograf-de-scraper/backend/templates/appointment_list.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Terminübersicht</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 20mm;
|
||||
@bottom-right {
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 2px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-text {
|
||||
flex: 1;
|
||||
}
|
||||
.header-logo {
|
||||
width: 150px;
|
||||
text-align: right;
|
||||
}
|
||||
.header-logo img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 16pt;
|
||||
margin: 0 0 5px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14pt;
|
||||
margin: 0 0 10px 0;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
background-color: #ecf0f1;
|
||||
padding: 8px 12px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
font-size: 13pt;
|
||||
border-left: 4px solid #3498db;
|
||||
page-break-before: always;
|
||||
}
|
||||
.first-date-header {
|
||||
page-break-before: avoid;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bdc3c7;
|
||||
padding: 6px 8px; /* Narrower rows */
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.empty-row td {
|
||||
height: 25px; /* Narrower empty rows */
|
||||
color: transparent;
|
||||
}
|
||||
.compressed-row td {
|
||||
background-color: #fcfcfc;
|
||||
color: #7f8c8d !important;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
.time-col { width: 14%; white-space: nowrap; font-weight: bold; }
|
||||
.family-col { width: 33%; }
|
||||
|
||||
.children-col { width: 15%; text-align: center; }
|
||||
.consent-col { width: 20%; text-align: center; }
|
||||
.done-col { width: 18%; text-align: center; }
|
||||
|
||||
.empty-row td {
|
||||
height: 35px; /* ensure enough space for writing */
|
||||
color: transparent; /* visually hide "Empty" text but keep structure if any */
|
||||
}
|
||||
|
||||
|
||||
/* CSS Checkmark (Ja) */
|
||||
.consent-yes {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #333;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
.consent-yes::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid #27ae60;
|
||||
border-width: 0 3px 3px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
/* The checkbox square */
|
||||
.checkbox-square {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid #333;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% for date, slots in grouped_slots.items() %}
|
||||
|
||||
{% if not loop.first %}
|
||||
<div style="page-break-before: always;"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>{{ event_type_name }}</h1>
|
||||
<p>Auftrag: {{ job_name }} | Stand: {{ current_time }}</p>
|
||||
</div>
|
||||
<div class="header-logo">
|
||||
{% if logo_base64 %}
|
||||
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-header first-date-header">{{ date }}</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="time-col">Uhrzeit</th>
|
||||
<th class="family-col">Familie</th>
|
||||
<th class="children-col">Kinder</th>
|
||||
<th class="consent-col">Veröffentlichung</th>
|
||||
<th class="done-col">Erledigt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slot in slots %}
|
||||
{% if slot.is_compressed %}
|
||||
<tr class="compressed-row">
|
||||
<td class="time-col" style="color: #7f8c8d;">{{ slot.time_str }}</td>
|
||||
<td colspan="4">{{ slot.name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="{% if not slot.booked %}empty-row{% endif %}">
|
||||
<td class="time-col" style="color: #333;">{{ slot.time_str }}</td>
|
||||
<td class="family-col">{{ slot.name if slot.booked else '' }}</td>
|
||||
<td class="children-col">{{ slot.children if slot.booked else '' }}</td>
|
||||
<td class="consent-col">
|
||||
|
||||
{% if slot.booked and slot.consent %}
|
||||
<span class="consent-yes"></span>
|
||||
|
||||
{% elif slot.booked %}
|
||||
<!-- nein -->
|
||||
{% else %}
|
||||
<!-- leer -->
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="done-col">
|
||||
{% if slot.booked %}
|
||||
<span class="checkbox-square"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
62
fotograf-de-scraper/backend/templates/school_list.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
@page { size: A4 portrait; margin: 20mm; }
|
||||
body { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.header { margin-bottom: 20px; }
|
||||
.institution-name { font-weight: bold; font-size: 14pt; }
|
||||
.date-info { font-size: 12pt; }
|
||||
.summary { margin-top: 30px; }
|
||||
.summary h2 { font-size: 12pt; font-weight: normal; margin-bottom: 10px; }
|
||||
.summary-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.summary-table td { padding: 4px 0; }
|
||||
.summary-total { margin-top: 10px; border-top: 1px solid black; padding-top: 10px; font-weight: bold; }
|
||||
.class-section { page-break-before: always; }
|
||||
.student-table { width: 100%; border-collapse: collapse; margin-top: 30px; }
|
||||
.student-table th { text-align: left; border-bottom: 1px solid black; padding-bottom: 5px; font-weight: normal; }
|
||||
.student-table td { padding: 5px 0; }
|
||||
.class-summary { margin-top: 30px; font-weight: bold; }
|
||||
.class-note { margin-top: 20px; font-size: 10pt; }
|
||||
.footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; font-size: 10pt; }
|
||||
.footer-left { text-align: left; }
|
||||
.footer-right { text-align: right; }
|
||||
</style></head><body>
|
||||
<div class="header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div class="institution-name">{{ institution }}</div>
|
||||
<div class="date-info">{{ date_info }}</div>
|
||||
</div>
|
||||
{% if logo_base64 %}
|
||||
<div>
|
||||
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo" style="max-height: 60px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="summary"><h2>Übersicht der Anmeldungen:</h2><table class="summary-table">
|
||||
{% for count in class_counts %}
|
||||
<tr><td style="width: 50%;">{{ group_label }} {{ count.name }}</td><td>{{ count.count }} Anmeldungen</td></tr>
|
||||
{% endfor %}
|
||||
</table><div class="summary-total">Gesamt: {{ total_students }} Anmeldungen</div></div>
|
||||
{% for class_info in class_data %}
|
||||
<div class="class-section">
|
||||
<div class="header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div class="institution-name">{{ institution }}</div>
|
||||
<div class="date-info">{{ date_info }}</div>
|
||||
</div>
|
||||
{% if logo_base64 %}
|
||||
<div>
|
||||
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo" style="max-height: 60px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="student-table"><thead><tr><th style="width: 40%">Nachname</th><th style="width: 40%">Vorname</th><th style="width: 20%">{{ group_label }}</th></tr></thead><tbody>
|
||||
{% for student in class_info.students %}
|
||||
<tr><td>{{ student.Nachname }}</td><td>{{ student.Vorname }}</td><td>{{ student[group_column_name] }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
<div class="class-summary">{{ class_info.students|length }} angemeldete {{ person_label_plural }}</div>
|
||||
<div class="class-note">Dies ist die Liste der bereits angemeldeten {{ person_label_plural }}. Bitte die noch fehlenden<br>{{ person_label_plural }} an die Anmeldung erinnern.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="footer"><div class="footer-left">Stand {{ current_time }}</div><div class="footer-right">Kinderfotos Erding<br>Gartenstr. 10 85445 Oberding<br>www.kinderfotos-erding.de<br>08122-8470867</div></div>
|
||||
</body></html>
|
||||
90
fotograf-de-scraper/backend/templates/siblings_list.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@page { size: A4 portrait; margin: 20mm; }
|
||||
body { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.header { margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.institution-name { font-weight: bold; font-size: 16pt; margin-bottom: 5px; }
|
||||
.doc-title { font-size: 14pt; font-weight: bold; color: #4f46e5; margin-bottom: 15px; }
|
||||
.date-info { font-size: 11pt; color: #555; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
|
||||
th { text-align: left; background-color: #f3f4f6; border-bottom: 2px solid #d1d5db; padding: 8px 5px; font-size: 10pt; }
|
||||
td { padding: 8px 5px; border-bottom: 1px solid #e5e7eb; font-size: 10pt; vertical-align: top; }
|
||||
|
||||
.checkbox { width: 20px; height: 20px; border: 1.5px solid #000; border-radius: 3px; display: inline-block; }
|
||||
|
||||
.footer { position: fixed; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; font-size: 9pt; color: #888; }
|
||||
.badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 8.5pt; font-weight: bold; background-color: #e0e7ff; color: #3730a3; margin-left: 5px; }
|
||||
.badge-time { background-color: #d1fae5; color: #065f46; font-size: 10pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<div class="institution-name">{{ institution }}</div>
|
||||
<div class="doc-title">Geschwisterliste (Einrichtungsintern)</div>
|
||||
<div class="date-info">Generiert am: {{ current_time }}</div>
|
||||
</div>
|
||||
{% if logo_base64 %}
|
||||
<div>
|
||||
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Logo" style="max-height: 50px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%">Nachname</th>
|
||||
<th style="width: 35%">Kinder in der Einrichtung (Gruppe)</th>
|
||||
<th style="width: 15%">Wunsch Online</th>
|
||||
<th style="width: 20%">Termin (Calendly)</th>
|
||||
<th style="width: 10%; text-align: center;">Erledigt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for family in families %}
|
||||
<tr>
|
||||
<td style="font-weight: bold;">{{ family.nachname }}</td>
|
||||
<td>
|
||||
{% for child in family.children %}
|
||||
<div style="margin-bottom: 4px;">
|
||||
{{ child.vorname }} <span style="color: #666; font-size: 9pt;">({{ child.gruppe }})</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if family.fotograf_wunsch %}
|
||||
<span style="color: #059669; font-weight: bold;">Ja</span>
|
||||
{% else %}
|
||||
<span style="color: #9ca3af;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if family.calendly_time %}
|
||||
<span class="badge badge-time">{{ family.calendly_time }}</span>
|
||||
{% else %}
|
||||
<span style="color: #9ca3af;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<div class="checkbox"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 20px; color: #666;">Keine internen Geschwisterkinder in dieser Einrichtung gefunden.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<div>Geschwisterliste</div>
|
||||
<div>Kinderfotos Erding | www.kinderfotos-erding.de</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
44
fotograf-de-scraper/backend/test_thank_you_mail.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.append('/app/fotograf-de-scraper/backend')
|
||||
|
||||
from database import SessionLocal, ReleaseParticipant, DiscountCode
|
||||
from gmail_service import GmailService
|
||||
from publish_request_api import SIGNATURE_HTML
|
||||
|
||||
def test_webhook_mail():
|
||||
db = SessionLocal()
|
||||
|
||||
# Simulate data
|
||||
test_email = "floke.com@gmail.com"
|
||||
first_name = "Christian"
|
||||
test_code = "M984AU-TEST"
|
||||
|
||||
# Simulate logic
|
||||
service = GmailService(db)
|
||||
subject = "Dankeschön für Eure Freigabe & Euer Rabattcode"
|
||||
|
||||
INSTRUCTIONS_IMAGE_URL = "https://mail.google.com/mail/u/2?ui=2&ik=719adaa3c5&attid=0.1&permmsgid=msg-a:r7482671925923393616&th=196e322c399dbc7f&view=fimg&fur=ip&permmsgid=msg-a:r7482671925923393616&sz=s0-l75-ft&attbid=ANGjdJ9_U6ayMFgwbupt4HalTKO867IHx6N70eNbPfQmTLNzRXilJxI-n8a1gjM8xVcP5HEOgaVxfp3FnJPzTYmEEyhK4gSU-Il_0a6OtzFYscp55_W4iyxuxjyPvK4&disp=emb&realattid=ii_maspzxv50&zw"
|
||||
|
||||
body_html = f"""
|
||||
<p>Hallo {first_name},</p>
|
||||
<p>Vielen Dank nochmal für die Freigabe zur Veröffentlichung, das ist super nett von Euch!</p>
|
||||
<p>Hier ist euer Gutscheincode über 25 Euro: <strong style="font-size: 18px; color: #4F46E5;">{test_code}</strong></p>
|
||||
<p>Um den Gutschein einzugeben, musst du auf den Preis des Warenkorbs drücken (über dem Button zur Kasse gehen):</p>
|
||||
<p><img src="{INSTRUCTIONS_IMAGE_URL}" alt="Anleitung Gutschein einlösen" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;"></p>
|
||||
<p>Liebe Grüße,<br>das Team von Kinderfotos Erding</p>
|
||||
{SIGNATURE_HTML}
|
||||
"""
|
||||
|
||||
print(f"Sende Test-E-Mail an {test_email}...")
|
||||
success = service.send_email(test_email, subject, body_html)
|
||||
|
||||
if success:
|
||||
print("✅ E-Mail erfolgreich gesendet! Bitte prüfe dein Postfach.")
|
||||
else:
|
||||
print("❌ Fehler beim Senden. (Stelle sicher, dass Gmail Authentifiziert ist).")
|
||||
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_webhook_mail()
|
||||
BIN
fotograf-de-scraper/blank.pdf
Normal file
30
fotograf-de-scraper/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
fotograf-de-scraper-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: fotograf-de-scraper-backend
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- "8002:8000" # Map internal 8000 to external 8002 to avoid conflicts
|
||||
volumes:
|
||||
- ./backend:/app # Mount the backend code for easier development
|
||||
- ./backend/data:/app/data # Persistent data storage
|
||||
restart: unless-stopped
|
||||
|
||||
fotograf-de-scraper-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: fotograf-de-scraper-frontend
|
||||
ports:
|
||||
- "3009:80" # Map internal 80 to external 3009
|
||||
depends_on:
|
||||
- fotograf-de-scraper-backend
|
||||
volumes:
|
||||
- ./frontend:/app # Mount the frontend code for easier development
|
||||
restart: unless-stopped
|
||||
|
||||
40
fotograf-de-scraper/frontend/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# Stage 1: Build the React application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Accept build arguments
|
||||
ARG VITE_API_BASE_URL
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application source code
|
||||
COPY . .
|
||||
|
||||
# Write the build arg to .env.production so Vite picks it up during build
|
||||
RUN echo "VITE_API_BASE_URL=${VITE_API_BASE_URL}" > .env.production
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Remove default Nginx assets
|
||||
RUN rm -rf ./*
|
||||
|
||||
# Copy built assets from the builder stage
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
fotograf-de-scraper/frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
1
fotograf-de-scraper/frontend/dist/assets/index-BnIZj8RP.css
vendored
Normal file
47
fotograf-de-scraper/frontend/dist/assets/index-DnGj5v5p.js
vendored
Normal file
3
fotograf-de-scraper/frontend/dist/favicon.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<text y=".9em" font-size="90">📸</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 113 B |
24
fotograf-de-scraper/frontend/dist/icons.svg
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
14
fotograf-de-scraper/frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/fotograf-de/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fotograf.de ERP</title>
|
||||
<script type="module" crossorigin src="/fotograf-de/assets/index-DnGj5v5p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/fotograf-de/assets/index-BnIZj8RP.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
23
fotograf-de-scraper/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
fotograf-de-scraper/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fotograf.de ERP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/acorn
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../acorn/bin/acorn
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/autoprefixer
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../autoprefixer/bin/autoprefixer
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/baseline-browser-mapping
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../baseline-browser-mapping/dist/cli.cjs
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/browserslist
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../browserslist/cli.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/cssesc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../cssesc/bin/cssesc
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/eslint
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../eslint/bin/eslint.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/jiti
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../jiti/bin/jiti.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/js-yaml
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../js-yaml/bin/js-yaml.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/jsesc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../jsesc/bin/jsesc
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/json5
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../json5/lib/cli.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/nanoid
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../nanoid/bin/nanoid.cjs
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/node-which
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../which/bin/node-which
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/parser
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../@babel/parser/bin/babel-parser.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/resolve
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../resolve/bin/resolve
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/rolldown
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../rolldown/bin/cli.mjs
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/semver
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../semver/bin/semver.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/sucrase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../sucrase/bin/sucrase
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/sucrase-node
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../sucrase/bin/sucrase-node
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/tailwind
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../tailwindcss/lib/cli.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/tailwindcss
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../tailwindcss/lib/cli.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/tsc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../typescript/bin/tsc
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/tsserver
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../typescript/bin/tsserver
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/update-browserslist-db
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../update-browserslist-db/cli.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.bin/vite
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../vite/bin/vite.js
|
||||
1
fotograf-de-scraper/frontend/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["../../src/App.tsx","../../src/main.tsx"],"version":"5.9.3"}
|
||||
1
fotograf-de-scraper/frontend/node_modules/.tmp/tsconfig.node.tsbuildinfo
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["../../vite.config.ts"],"version":"5.9.3"}
|
||||
3
fotograf-de-scraper/frontend/node_modules/.vite/deps/package.json
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
770
fotograf-de-scraper/frontend/node_modules/.vite/deps/react-Na5-BvaJ.js
generated
vendored
Normal file
@@ -0,0 +1,770 @@
|
||||
//#region \0rolldown/runtime.js
|
||||
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
//#endregion
|
||||
//#region node_modules/react/cjs/react.development.js
|
||||
/**
|
||||
* @license React
|
||||
* react.development.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
var require_react_development = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
||||
(function() {
|
||||
function defineDeprecationWarning(methodName, info) {
|
||||
Object.defineProperty(Component.prototype, methodName, { get: function() {
|
||||
console.warn("%s(...) is deprecated in plain JavaScript React classes. %s", info[0], info[1]);
|
||||
} });
|
||||
}
|
||||
function getIteratorFn(maybeIterable) {
|
||||
if (null === maybeIterable || "object" !== typeof maybeIterable) return null;
|
||||
maybeIterable = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable["@@iterator"];
|
||||
return "function" === typeof maybeIterable ? maybeIterable : null;
|
||||
}
|
||||
function warnNoop(publicInstance, callerName) {
|
||||
publicInstance = (publicInstance = publicInstance.constructor) && (publicInstance.displayName || publicInstance.name) || "ReactClass";
|
||||
var warningKey = publicInstance + "." + callerName;
|
||||
didWarnStateUpdateForUnmountedComponent[warningKey] || (console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.", callerName, publicInstance), didWarnStateUpdateForUnmountedComponent[warningKey] = !0);
|
||||
}
|
||||
function Component(props, context, updater) {
|
||||
this.props = props;
|
||||
this.context = context;
|
||||
this.refs = emptyObject;
|
||||
this.updater = updater || ReactNoopUpdateQueue;
|
||||
}
|
||||
function ComponentDummy() {}
|
||||
function PureComponent(props, context, updater) {
|
||||
this.props = props;
|
||||
this.context = context;
|
||||
this.refs = emptyObject;
|
||||
this.updater = updater || ReactNoopUpdateQueue;
|
||||
}
|
||||
function noop() {}
|
||||
function testStringCoercion(value) {
|
||||
return "" + value;
|
||||
}
|
||||
function checkKeyStringCoercion(value) {
|
||||
try {
|
||||
testStringCoercion(value);
|
||||
var JSCompiler_inline_result = !1;
|
||||
} catch (e) {
|
||||
JSCompiler_inline_result = !0;
|
||||
}
|
||||
if (JSCompiler_inline_result) {
|
||||
JSCompiler_inline_result = console;
|
||||
var JSCompiler_temp_const = JSCompiler_inline_result.error;
|
||||
var JSCompiler_inline_result$jscomp$0 = "function" === typeof Symbol && Symbol.toStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object";
|
||||
JSCompiler_temp_const.call(JSCompiler_inline_result, "The provided key is an unsupported type %s. This value must be coerced to a string before using it here.", JSCompiler_inline_result$jscomp$0);
|
||||
return testStringCoercion(value);
|
||||
}
|
||||
}
|
||||
function getComponentNameFromType(type) {
|
||||
if (null == type) return null;
|
||||
if ("function" === typeof type) return type.$$typeof === REACT_CLIENT_REFERENCE ? null : type.displayName || type.name || null;
|
||||
if ("string" === typeof type) return type;
|
||||
switch (type) {
|
||||
case REACT_FRAGMENT_TYPE: return "Fragment";
|
||||
case REACT_PROFILER_TYPE: return "Profiler";
|
||||
case REACT_STRICT_MODE_TYPE: return "StrictMode";
|
||||
case REACT_SUSPENSE_TYPE: return "Suspense";
|
||||
case REACT_SUSPENSE_LIST_TYPE: return "SuspenseList";
|
||||
case REACT_ACTIVITY_TYPE: return "Activity";
|
||||
}
|
||||
if ("object" === typeof type) switch ("number" === typeof type.tag && console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."), type.$$typeof) {
|
||||
case REACT_PORTAL_TYPE: return "Portal";
|
||||
case REACT_CONTEXT_TYPE: return type.displayName || "Context";
|
||||
case REACT_CONSUMER_TYPE: return (type._context.displayName || "Context") + ".Consumer";
|
||||
case REACT_FORWARD_REF_TYPE:
|
||||
var innerType = type.render;
|
||||
type = type.displayName;
|
||||
type || (type = innerType.displayName || innerType.name || "", type = "" !== type ? "ForwardRef(" + type + ")" : "ForwardRef");
|
||||
return type;
|
||||
case REACT_MEMO_TYPE: return innerType = type.displayName || null, null !== innerType ? innerType : getComponentNameFromType(type.type) || "Memo";
|
||||
case REACT_LAZY_TYPE:
|
||||
innerType = type._payload;
|
||||
type = type._init;
|
||||
try {
|
||||
return getComponentNameFromType(type(innerType));
|
||||
} catch (x) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getTaskName(type) {
|
||||
if (type === REACT_FRAGMENT_TYPE) return "<>";
|
||||
if ("object" === typeof type && null !== type && type.$$typeof === REACT_LAZY_TYPE) return "<...>";
|
||||
try {
|
||||
var name = getComponentNameFromType(type);
|
||||
return name ? "<" + name + ">" : "<...>";
|
||||
} catch (x) {
|
||||
return "<...>";
|
||||
}
|
||||
}
|
||||
function getOwner() {
|
||||
var dispatcher = ReactSharedInternals.A;
|
||||
return null === dispatcher ? null : dispatcher.getOwner();
|
||||
}
|
||||
function UnknownOwner() {
|
||||
return Error("react-stack-top-frame");
|
||||
}
|
||||
function hasValidKey(config) {
|
||||
if (hasOwnProperty.call(config, "key")) {
|
||||
var getter = Object.getOwnPropertyDescriptor(config, "key").get;
|
||||
if (getter && getter.isReactWarning) return !1;
|
||||
}
|
||||
return void 0 !== config.key;
|
||||
}
|
||||
function defineKeyPropWarningGetter(props, displayName) {
|
||||
function warnAboutAccessingKey() {
|
||||
specialPropKeyWarningShown || (specialPropKeyWarningShown = !0, console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)", displayName));
|
||||
}
|
||||
warnAboutAccessingKey.isReactWarning = !0;
|
||||
Object.defineProperty(props, "key", {
|
||||
get: warnAboutAccessingKey,
|
||||
configurable: !0
|
||||
});
|
||||
}
|
||||
function elementRefGetterWithDeprecationWarning() {
|
||||
var componentName = getComponentNameFromType(this.type);
|
||||
didWarnAboutElementRef[componentName] || (didWarnAboutElementRef[componentName] = !0, console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release."));
|
||||
componentName = this.props.ref;
|
||||
return void 0 !== componentName ? componentName : null;
|
||||
}
|
||||
function ReactElement(type, key, props, owner, debugStack, debugTask) {
|
||||
var refProp = props.ref;
|
||||
type = {
|
||||
$$typeof: REACT_ELEMENT_TYPE,
|
||||
type,
|
||||
key,
|
||||
props,
|
||||
_owner: owner
|
||||
};
|
||||
null !== (void 0 !== refProp ? refProp : null) ? Object.defineProperty(type, "ref", {
|
||||
enumerable: !1,
|
||||
get: elementRefGetterWithDeprecationWarning
|
||||
}) : Object.defineProperty(type, "ref", {
|
||||
enumerable: !1,
|
||||
value: null
|
||||
});
|
||||
type._store = {};
|
||||
Object.defineProperty(type._store, "validated", {
|
||||
configurable: !1,
|
||||
enumerable: !1,
|
||||
writable: !0,
|
||||
value: 0
|
||||
});
|
||||
Object.defineProperty(type, "_debugInfo", {
|
||||
configurable: !1,
|
||||
enumerable: !1,
|
||||
writable: !0,
|
||||
value: null
|
||||
});
|
||||
Object.defineProperty(type, "_debugStack", {
|
||||
configurable: !1,
|
||||
enumerable: !1,
|
||||
writable: !0,
|
||||
value: debugStack
|
||||
});
|
||||
Object.defineProperty(type, "_debugTask", {
|
||||
configurable: !1,
|
||||
enumerable: !1,
|
||||
writable: !0,
|
||||
value: debugTask
|
||||
});
|
||||
Object.freeze && (Object.freeze(type.props), Object.freeze(type));
|
||||
return type;
|
||||
}
|
||||
function cloneAndReplaceKey(oldElement, newKey) {
|
||||
newKey = ReactElement(oldElement.type, newKey, oldElement.props, oldElement._owner, oldElement._debugStack, oldElement._debugTask);
|
||||
oldElement._store && (newKey._store.validated = oldElement._store.validated);
|
||||
return newKey;
|
||||
}
|
||||
function validateChildKeys(node) {
|
||||
isValidElement(node) ? node._store && (node._store.validated = 1) : "object" === typeof node && null !== node && node.$$typeof === REACT_LAZY_TYPE && ("fulfilled" === node._payload.status ? isValidElement(node._payload.value) && node._payload.value._store && (node._payload.value._store.validated = 1) : node._store && (node._store.validated = 1));
|
||||
}
|
||||
function isValidElement(object) {
|
||||
return "object" === typeof object && null !== object && object.$$typeof === REACT_ELEMENT_TYPE;
|
||||
}
|
||||
function escape(key) {
|
||||
var escaperLookup = {
|
||||
"=": "=0",
|
||||
":": "=2"
|
||||
};
|
||||
return "$" + key.replace(/[=:]/g, function(match) {
|
||||
return escaperLookup[match];
|
||||
});
|
||||
}
|
||||
function getElementKey(element, index) {
|
||||
return "object" === typeof element && null !== element && null != element.key ? (checkKeyStringCoercion(element.key), escape("" + element.key)) : index.toString(36);
|
||||
}
|
||||
function resolveThenable(thenable) {
|
||||
switch (thenable.status) {
|
||||
case "fulfilled": return thenable.value;
|
||||
case "rejected": throw thenable.reason;
|
||||
default: switch ("string" === typeof thenable.status ? thenable.then(noop, noop) : (thenable.status = "pending", thenable.then(function(fulfilledValue) {
|
||||
"pending" === thenable.status && (thenable.status = "fulfilled", thenable.value = fulfilledValue);
|
||||
}, function(error) {
|
||||
"pending" === thenable.status && (thenable.status = "rejected", thenable.reason = error);
|
||||
})), thenable.status) {
|
||||
case "fulfilled": return thenable.value;
|
||||
case "rejected": throw thenable.reason;
|
||||
}
|
||||
}
|
||||
throw thenable;
|
||||
}
|
||||
function mapIntoArray(children, array, escapedPrefix, nameSoFar, callback) {
|
||||
var type = typeof children;
|
||||
if ("undefined" === type || "boolean" === type) children = null;
|
||||
var invokeCallback = !1;
|
||||
if (null === children) invokeCallback = !0;
|
||||
else switch (type) {
|
||||
case "bigint":
|
||||
case "string":
|
||||
case "number":
|
||||
invokeCallback = !0;
|
||||
break;
|
||||
case "object": switch (children.$$typeof) {
|
||||
case REACT_ELEMENT_TYPE:
|
||||
case REACT_PORTAL_TYPE:
|
||||
invokeCallback = !0;
|
||||
break;
|
||||
case REACT_LAZY_TYPE: return invokeCallback = children._init, mapIntoArray(invokeCallback(children._payload), array, escapedPrefix, nameSoFar, callback);
|
||||
}
|
||||
}
|
||||
if (invokeCallback) {
|
||||
invokeCallback = children;
|
||||
callback = callback(invokeCallback);
|
||||
var childKey = "" === nameSoFar ? "." + getElementKey(invokeCallback, 0) : nameSoFar;
|
||||
isArrayImpl(callback) ? (escapedPrefix = "", null != childKey && (escapedPrefix = childKey.replace(userProvidedKeyEscapeRegex, "$&/") + "/"), mapIntoArray(callback, array, escapedPrefix, "", function(c) {
|
||||
return c;
|
||||
})) : null != callback && (isValidElement(callback) && (null != callback.key && (invokeCallback && invokeCallback.key === callback.key || checkKeyStringCoercion(callback.key)), escapedPrefix = cloneAndReplaceKey(callback, escapedPrefix + (null == callback.key || invokeCallback && invokeCallback.key === callback.key ? "" : ("" + callback.key).replace(userProvidedKeyEscapeRegex, "$&/") + "/") + childKey), "" !== nameSoFar && null != invokeCallback && isValidElement(invokeCallback) && null == invokeCallback.key && invokeCallback._store && !invokeCallback._store.validated && (escapedPrefix._store.validated = 2), callback = escapedPrefix), array.push(callback));
|
||||
return 1;
|
||||
}
|
||||
invokeCallback = 0;
|
||||
childKey = "" === nameSoFar ? "." : nameSoFar + ":";
|
||||
if (isArrayImpl(children)) for (var i = 0; i < children.length; i++) nameSoFar = children[i], type = childKey + getElementKey(nameSoFar, i), invokeCallback += mapIntoArray(nameSoFar, array, escapedPrefix, type, callback);
|
||||
else if (i = getIteratorFn(children), "function" === typeof i) for (i === children.entries && (didWarnAboutMaps || console.warn("Using Maps as children is not supported. Use an array of keyed ReactElements instead."), didWarnAboutMaps = !0), children = i.call(children), i = 0; !(nameSoFar = children.next()).done;) nameSoFar = nameSoFar.value, type = childKey + getElementKey(nameSoFar, i++), invokeCallback += mapIntoArray(nameSoFar, array, escapedPrefix, type, callback);
|
||||
else if ("object" === type) {
|
||||
if ("function" === typeof children.then) return mapIntoArray(resolveThenable(children), array, escapedPrefix, nameSoFar, callback);
|
||||
array = String(children);
|
||||
throw Error("Objects are not valid as a React child (found: " + ("[object Object]" === array ? "object with keys {" + Object.keys(children).join(", ") + "}" : array) + "). If you meant to render a collection of children, use an array instead.");
|
||||
}
|
||||
return invokeCallback;
|
||||
}
|
||||
function mapChildren(children, func, context) {
|
||||
if (null == children) return children;
|
||||
var result = [], count = 0;
|
||||
mapIntoArray(children, result, "", "", function(child) {
|
||||
return func.call(context, child, count++);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function lazyInitializer(payload) {
|
||||
if (-1 === payload._status) {
|
||||
var ioInfo = payload._ioInfo;
|
||||
null != ioInfo && (ioInfo.start = ioInfo.end = performance.now());
|
||||
ioInfo = payload._result;
|
||||
var thenable = ioInfo();
|
||||
thenable.then(function(moduleObject) {
|
||||
if (0 === payload._status || -1 === payload._status) {
|
||||
payload._status = 1;
|
||||
payload._result = moduleObject;
|
||||
var _ioInfo = payload._ioInfo;
|
||||
null != _ioInfo && (_ioInfo.end = performance.now());
|
||||
void 0 === thenable.status && (thenable.status = "fulfilled", thenable.value = moduleObject);
|
||||
}
|
||||
}, function(error) {
|
||||
if (0 === payload._status || -1 === payload._status) {
|
||||
payload._status = 2;
|
||||
payload._result = error;
|
||||
var _ioInfo2 = payload._ioInfo;
|
||||
null != _ioInfo2 && (_ioInfo2.end = performance.now());
|
||||
void 0 === thenable.status && (thenable.status = "rejected", thenable.reason = error);
|
||||
}
|
||||
});
|
||||
ioInfo = payload._ioInfo;
|
||||
if (null != ioInfo) {
|
||||
ioInfo.value = thenable;
|
||||
var displayName = thenable.displayName;
|
||||
"string" === typeof displayName && (ioInfo.name = displayName);
|
||||
}
|
||||
-1 === payload._status && (payload._status = 0, payload._result = thenable);
|
||||
}
|
||||
if (1 === payload._status) return ioInfo = payload._result, void 0 === ioInfo && console.error("lazy: Expected the result of a dynamic import() call. Instead received: %s\n\nYour code should look like: \n const MyComponent = lazy(() => import('./MyComponent'))\n\nDid you accidentally put curly braces around the import?", ioInfo), "default" in ioInfo || console.error("lazy: Expected the result of a dynamic import() call. Instead received: %s\n\nYour code should look like: \n const MyComponent = lazy(() => import('./MyComponent'))", ioInfo), ioInfo.default;
|
||||
throw payload._result;
|
||||
}
|
||||
function resolveDispatcher() {
|
||||
var dispatcher = ReactSharedInternals.H;
|
||||
null === dispatcher && console.error("Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.");
|
||||
return dispatcher;
|
||||
}
|
||||
function releaseAsyncTransition() {
|
||||
ReactSharedInternals.asyncTransitions--;
|
||||
}
|
||||
function enqueueTask(task) {
|
||||
if (null === enqueueTaskImpl) try {
|
||||
var requireString = ("require" + Math.random()).slice(0, 7);
|
||||
enqueueTaskImpl = (module && module[requireString]).call(module, "timers").setImmediate;
|
||||
} catch (_err) {
|
||||
enqueueTaskImpl = function(callback) {
|
||||
!1 === didWarnAboutMessageChannel && (didWarnAboutMessageChannel = !0, "undefined" === typeof MessageChannel && console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));
|
||||
var channel = new MessageChannel();
|
||||
channel.port1.onmessage = callback;
|
||||
channel.port2.postMessage(void 0);
|
||||
};
|
||||
}
|
||||
return enqueueTaskImpl(task);
|
||||
}
|
||||
function aggregateErrors(errors) {
|
||||
return 1 < errors.length && "function" === typeof AggregateError ? new AggregateError(errors) : errors[0];
|
||||
}
|
||||
function popActScope(prevActQueue, prevActScopeDepth) {
|
||||
prevActScopeDepth !== actScopeDepth - 1 && console.error("You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. ");
|
||||
actScopeDepth = prevActScopeDepth;
|
||||
}
|
||||
function recursivelyFlushAsyncActWork(returnValue, resolve, reject) {
|
||||
var queue = ReactSharedInternals.actQueue;
|
||||
if (null !== queue) if (0 !== queue.length) try {
|
||||
flushActQueue(queue);
|
||||
enqueueTask(function() {
|
||||
return recursivelyFlushAsyncActWork(returnValue, resolve, reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
ReactSharedInternals.thrownErrors.push(error);
|
||||
}
|
||||
else ReactSharedInternals.actQueue = null;
|
||||
0 < ReactSharedInternals.thrownErrors.length ? (queue = aggregateErrors(ReactSharedInternals.thrownErrors), ReactSharedInternals.thrownErrors.length = 0, reject(queue)) : resolve(returnValue);
|
||||
}
|
||||
function flushActQueue(queue) {
|
||||
if (!isFlushing) {
|
||||
isFlushing = !0;
|
||||
var i = 0;
|
||||
try {
|
||||
for (; i < queue.length; i++) {
|
||||
var callback = queue[i];
|
||||
do {
|
||||
ReactSharedInternals.didUsePromise = !1;
|
||||
var continuation = callback(!1);
|
||||
if (null !== continuation) {
|
||||
if (ReactSharedInternals.didUsePromise) {
|
||||
queue[i] = callback;
|
||||
queue.splice(0, i);
|
||||
return;
|
||||
}
|
||||
callback = continuation;
|
||||
} else break;
|
||||
} while (1);
|
||||
}
|
||||
queue.length = 0;
|
||||
} catch (error) {
|
||||
queue.splice(0, i + 1), ReactSharedInternals.thrownErrors.push(error);
|
||||
} finally {
|
||||
isFlushing = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());
|
||||
var REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element"), REACT_PORTAL_TYPE = Symbol.for("react.portal"), REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"), REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode"), REACT_PROFILER_TYPE = Symbol.for("react.profiler"), REACT_CONSUMER_TYPE = Symbol.for("react.consumer"), REACT_CONTEXT_TYPE = Symbol.for("react.context"), REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"), REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"), REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list"), REACT_MEMO_TYPE = Symbol.for("react.memo"), REACT_LAZY_TYPE = Symbol.for("react.lazy"), REACT_ACTIVITY_TYPE = Symbol.for("react.activity"), MAYBE_ITERATOR_SYMBOL = Symbol.iterator, didWarnStateUpdateForUnmountedComponent = {}, ReactNoopUpdateQueue = {
|
||||
isMounted: function() {
|
||||
return !1;
|
||||
},
|
||||
enqueueForceUpdate: function(publicInstance) {
|
||||
warnNoop(publicInstance, "forceUpdate");
|
||||
},
|
||||
enqueueReplaceState: function(publicInstance) {
|
||||
warnNoop(publicInstance, "replaceState");
|
||||
},
|
||||
enqueueSetState: function(publicInstance) {
|
||||
warnNoop(publicInstance, "setState");
|
||||
}
|
||||
}, assign = Object.assign, emptyObject = {};
|
||||
Object.freeze(emptyObject);
|
||||
Component.prototype.isReactComponent = {};
|
||||
Component.prototype.setState = function(partialState, callback) {
|
||||
if ("object" !== typeof partialState && "function" !== typeof partialState && null != partialState) throw Error("takes an object of state variables to update or a function which returns an object of state variables.");
|
||||
this.updater.enqueueSetState(this, partialState, callback, "setState");
|
||||
};
|
||||
Component.prototype.forceUpdate = function(callback) {
|
||||
this.updater.enqueueForceUpdate(this, callback, "forceUpdate");
|
||||
};
|
||||
var deprecatedAPIs = {
|
||||
isMounted: ["isMounted", "Instead, make sure to clean up subscriptions and pending requests in componentWillUnmount to prevent memory leaks."],
|
||||
replaceState: ["replaceState", "Refactor your code to use setState instead (see https://github.com/facebook/react/issues/3236)."]
|
||||
};
|
||||
for (fnName in deprecatedAPIs) deprecatedAPIs.hasOwnProperty(fnName) && defineDeprecationWarning(fnName, deprecatedAPIs[fnName]);
|
||||
ComponentDummy.prototype = Component.prototype;
|
||||
deprecatedAPIs = PureComponent.prototype = new ComponentDummy();
|
||||
deprecatedAPIs.constructor = PureComponent;
|
||||
assign(deprecatedAPIs, Component.prototype);
|
||||
deprecatedAPIs.isPureReactComponent = !0;
|
||||
var isArrayImpl = Array.isArray, REACT_CLIENT_REFERENCE = Symbol.for("react.client.reference"), ReactSharedInternals = {
|
||||
H: null,
|
||||
A: null,
|
||||
T: null,
|
||||
S: null,
|
||||
actQueue: null,
|
||||
asyncTransitions: 0,
|
||||
isBatchingLegacy: !1,
|
||||
didScheduleLegacyUpdate: !1,
|
||||
didUsePromise: !1,
|
||||
thrownErrors: [],
|
||||
getCurrentStack: null,
|
||||
recentlyCreatedOwnerStacks: 0
|
||||
}, hasOwnProperty = Object.prototype.hasOwnProperty, createTask = console.createTask ? console.createTask : function() {
|
||||
return null;
|
||||
};
|
||||
deprecatedAPIs = { react_stack_bottom_frame: function(callStackForError) {
|
||||
return callStackForError();
|
||||
} };
|
||||
var specialPropKeyWarningShown, didWarnAboutOldJSXRuntime;
|
||||
var didWarnAboutElementRef = {};
|
||||
var unknownOwnerDebugStack = deprecatedAPIs.react_stack_bottom_frame.bind(deprecatedAPIs, UnknownOwner)();
|
||||
var unknownOwnerDebugTask = createTask(getTaskName(UnknownOwner));
|
||||
var didWarnAboutMaps = !1, userProvidedKeyEscapeRegex = /\/+/g, reportGlobalError = "function" === typeof reportError ? reportError : function(error) {
|
||||
if ("object" === typeof window && "function" === typeof window.ErrorEvent) {
|
||||
var event = new window.ErrorEvent("error", {
|
||||
bubbles: !0,
|
||||
cancelable: !0,
|
||||
message: "object" === typeof error && null !== error && "string" === typeof error.message ? String(error.message) : String(error),
|
||||
error
|
||||
});
|
||||
if (!window.dispatchEvent(event)) return;
|
||||
} else if ("object" === typeof process && "function" === typeof process.emit) {
|
||||
process.emit("uncaughtException", error);
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
}, didWarnAboutMessageChannel = !1, enqueueTaskImpl = null, actScopeDepth = 0, didWarnNoAwaitAct = !1, isFlushing = !1, queueSeveralMicrotasks = "function" === typeof queueMicrotask ? function(callback) {
|
||||
queueMicrotask(function() {
|
||||
return queueMicrotask(callback);
|
||||
});
|
||||
} : enqueueTask;
|
||||
deprecatedAPIs = Object.freeze({
|
||||
__proto__: null,
|
||||
c: function(size) {
|
||||
return resolveDispatcher().useMemoCache(size);
|
||||
}
|
||||
});
|
||||
var fnName = {
|
||||
map: mapChildren,
|
||||
forEach: function(children, forEachFunc, forEachContext) {
|
||||
mapChildren(children, function() {
|
||||
forEachFunc.apply(this, arguments);
|
||||
}, forEachContext);
|
||||
},
|
||||
count: function(children) {
|
||||
var n = 0;
|
||||
mapChildren(children, function() {
|
||||
n++;
|
||||
});
|
||||
return n;
|
||||
},
|
||||
toArray: function(children) {
|
||||
return mapChildren(children, function(child) {
|
||||
return child;
|
||||
}) || [];
|
||||
},
|
||||
only: function(children) {
|
||||
if (!isValidElement(children)) throw Error("React.Children.only expected to receive a single React element child.");
|
||||
return children;
|
||||
}
|
||||
};
|
||||
exports.Activity = REACT_ACTIVITY_TYPE;
|
||||
exports.Children = fnName;
|
||||
exports.Component = Component;
|
||||
exports.Fragment = REACT_FRAGMENT_TYPE;
|
||||
exports.Profiler = REACT_PROFILER_TYPE;
|
||||
exports.PureComponent = PureComponent;
|
||||
exports.StrictMode = REACT_STRICT_MODE_TYPE;
|
||||
exports.Suspense = REACT_SUSPENSE_TYPE;
|
||||
exports.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = ReactSharedInternals;
|
||||
exports.__COMPILER_RUNTIME = deprecatedAPIs;
|
||||
exports.act = function(callback) {
|
||||
var prevActQueue = ReactSharedInternals.actQueue, prevActScopeDepth = actScopeDepth;
|
||||
actScopeDepth++;
|
||||
var queue = ReactSharedInternals.actQueue = null !== prevActQueue ? prevActQueue : [], didAwaitActCall = !1;
|
||||
try {
|
||||
var result = callback();
|
||||
} catch (error) {
|
||||
ReactSharedInternals.thrownErrors.push(error);
|
||||
}
|
||||
if (0 < ReactSharedInternals.thrownErrors.length) throw popActScope(prevActQueue, prevActScopeDepth), callback = aggregateErrors(ReactSharedInternals.thrownErrors), ReactSharedInternals.thrownErrors.length = 0, callback;
|
||||
if (null !== result && "object" === typeof result && "function" === typeof result.then) {
|
||||
var thenable = result;
|
||||
queueSeveralMicrotasks(function() {
|
||||
didAwaitActCall || didWarnNoAwaitAct || (didWarnNoAwaitAct = !0, console.error("You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"));
|
||||
});
|
||||
return { then: function(resolve, reject) {
|
||||
didAwaitActCall = !0;
|
||||
thenable.then(function(returnValue) {
|
||||
popActScope(prevActQueue, prevActScopeDepth);
|
||||
if (0 === prevActScopeDepth) {
|
||||
try {
|
||||
flushActQueue(queue), enqueueTask(function() {
|
||||
return recursivelyFlushAsyncActWork(returnValue, resolve, reject);
|
||||
});
|
||||
} catch (error$0) {
|
||||
ReactSharedInternals.thrownErrors.push(error$0);
|
||||
}
|
||||
if (0 < ReactSharedInternals.thrownErrors.length) {
|
||||
var _thrownError = aggregateErrors(ReactSharedInternals.thrownErrors);
|
||||
ReactSharedInternals.thrownErrors.length = 0;
|
||||
reject(_thrownError);
|
||||
}
|
||||
} else resolve(returnValue);
|
||||
}, function(error) {
|
||||
popActScope(prevActQueue, prevActScopeDepth);
|
||||
0 < ReactSharedInternals.thrownErrors.length ? (error = aggregateErrors(ReactSharedInternals.thrownErrors), ReactSharedInternals.thrownErrors.length = 0, reject(error)) : reject(error);
|
||||
});
|
||||
} };
|
||||
}
|
||||
var returnValue$jscomp$0 = result;
|
||||
popActScope(prevActQueue, prevActScopeDepth);
|
||||
0 === prevActScopeDepth && (flushActQueue(queue), 0 !== queue.length && queueSeveralMicrotasks(function() {
|
||||
didAwaitActCall || didWarnNoAwaitAct || (didWarnNoAwaitAct = !0, console.error("A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:\n\nawait act(() => ...)"));
|
||||
}), ReactSharedInternals.actQueue = null);
|
||||
if (0 < ReactSharedInternals.thrownErrors.length) throw callback = aggregateErrors(ReactSharedInternals.thrownErrors), ReactSharedInternals.thrownErrors.length = 0, callback;
|
||||
return { then: function(resolve, reject) {
|
||||
didAwaitActCall = !0;
|
||||
0 === prevActScopeDepth ? (ReactSharedInternals.actQueue = queue, enqueueTask(function() {
|
||||
return recursivelyFlushAsyncActWork(returnValue$jscomp$0, resolve, reject);
|
||||
})) : resolve(returnValue$jscomp$0);
|
||||
} };
|
||||
};
|
||||
exports.cache = function(fn) {
|
||||
return function() {
|
||||
return fn.apply(null, arguments);
|
||||
};
|
||||
};
|
||||
exports.cacheSignal = function() {
|
||||
return null;
|
||||
};
|
||||
exports.captureOwnerStack = function() {
|
||||
var getCurrentStack = ReactSharedInternals.getCurrentStack;
|
||||
return null === getCurrentStack ? null : getCurrentStack();
|
||||
};
|
||||
exports.cloneElement = function(element, config, children) {
|
||||
if (null === element || void 0 === element) throw Error("The argument must be a React element, but you passed " + element + ".");
|
||||
var props = assign({}, element.props), key = element.key, owner = element._owner;
|
||||
if (null != config) {
|
||||
var JSCompiler_inline_result;
|
||||
a: {
|
||||
if (hasOwnProperty.call(config, "ref") && (JSCompiler_inline_result = Object.getOwnPropertyDescriptor(config, "ref").get) && JSCompiler_inline_result.isReactWarning) {
|
||||
JSCompiler_inline_result = !1;
|
||||
break a;
|
||||
}
|
||||
JSCompiler_inline_result = void 0 !== config.ref;
|
||||
}
|
||||
JSCompiler_inline_result && (owner = getOwner());
|
||||
hasValidKey(config) && (checkKeyStringCoercion(config.key), key = "" + config.key);
|
||||
for (propName in config) !hasOwnProperty.call(config, propName) || "key" === propName || "__self" === propName || "__source" === propName || "ref" === propName && void 0 === config.ref || (props[propName] = config[propName]);
|
||||
}
|
||||
var propName = arguments.length - 2;
|
||||
if (1 === propName) props.children = children;
|
||||
else if (1 < propName) {
|
||||
JSCompiler_inline_result = Array(propName);
|
||||
for (var i = 0; i < propName; i++) JSCompiler_inline_result[i] = arguments[i + 2];
|
||||
props.children = JSCompiler_inline_result;
|
||||
}
|
||||
props = ReactElement(element.type, key, props, owner, element._debugStack, element._debugTask);
|
||||
for (key = 2; key < arguments.length; key++) validateChildKeys(arguments[key]);
|
||||
return props;
|
||||
};
|
||||
exports.createContext = function(defaultValue) {
|
||||
defaultValue = {
|
||||
$$typeof: REACT_CONTEXT_TYPE,
|
||||
_currentValue: defaultValue,
|
||||
_currentValue2: defaultValue,
|
||||
_threadCount: 0,
|
||||
Provider: null,
|
||||
Consumer: null
|
||||
};
|
||||
defaultValue.Provider = defaultValue;
|
||||
defaultValue.Consumer = {
|
||||
$$typeof: REACT_CONSUMER_TYPE,
|
||||
_context: defaultValue
|
||||
};
|
||||
defaultValue._currentRenderer = null;
|
||||
defaultValue._currentRenderer2 = null;
|
||||
return defaultValue;
|
||||
};
|
||||
exports.createElement = function(type, config, children) {
|
||||
for (var i = 2; i < arguments.length; i++) validateChildKeys(arguments[i]);
|
||||
i = {};
|
||||
var key = null;
|
||||
if (null != config) for (propName in didWarnAboutOldJSXRuntime || !("__self" in config) || "key" in config || (didWarnAboutOldJSXRuntime = !0, console.warn("Your app (or one of its dependencies) is using an outdated JSX transform. Update to the modern JSX transform for faster performance: https://react.dev/link/new-jsx-transform")), hasValidKey(config) && (checkKeyStringCoercion(config.key), key = "" + config.key), config) hasOwnProperty.call(config, propName) && "key" !== propName && "__self" !== propName && "__source" !== propName && (i[propName] = config[propName]);
|
||||
var childrenLength = arguments.length - 2;
|
||||
if (1 === childrenLength) i.children = children;
|
||||
else if (1 < childrenLength) {
|
||||
for (var childArray = Array(childrenLength), _i = 0; _i < childrenLength; _i++) childArray[_i] = arguments[_i + 2];
|
||||
Object.freeze && Object.freeze(childArray);
|
||||
i.children = childArray;
|
||||
}
|
||||
if (type && type.defaultProps) for (propName in childrenLength = type.defaultProps, childrenLength) void 0 === i[propName] && (i[propName] = childrenLength[propName]);
|
||||
key && defineKeyPropWarningGetter(i, "function" === typeof type ? type.displayName || type.name || "Unknown" : type);
|
||||
var propName = 1e4 > ReactSharedInternals.recentlyCreatedOwnerStacks++;
|
||||
return ReactElement(type, key, i, getOwner(), propName ? Error("react-stack-top-frame") : unknownOwnerDebugStack, propName ? createTask(getTaskName(type)) : unknownOwnerDebugTask);
|
||||
};
|
||||
exports.createRef = function() {
|
||||
var refObject = { current: null };
|
||||
Object.seal(refObject);
|
||||
return refObject;
|
||||
};
|
||||
exports.forwardRef = function(render) {
|
||||
null != render && render.$$typeof === REACT_MEMO_TYPE ? console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...)).") : "function" !== typeof render ? console.error("forwardRef requires a render function but was given %s.", null === render ? "null" : typeof render) : 0 !== render.length && 2 !== render.length && console.error("forwardRef render functions accept exactly two parameters: props and ref. %s", 1 === render.length ? "Did you forget to use the ref parameter?" : "Any additional parameter will be undefined.");
|
||||
null != render && null != render.defaultProps && console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");
|
||||
var elementType = {
|
||||
$$typeof: REACT_FORWARD_REF_TYPE,
|
||||
render
|
||||
}, ownName;
|
||||
Object.defineProperty(elementType, "displayName", {
|
||||
enumerable: !1,
|
||||
configurable: !0,
|
||||
get: function() {
|
||||
return ownName;
|
||||
},
|
||||
set: function(name) {
|
||||
ownName = name;
|
||||
render.name || render.displayName || (Object.defineProperty(render, "name", { value: name }), render.displayName = name);
|
||||
}
|
||||
});
|
||||
return elementType;
|
||||
};
|
||||
exports.isValidElement = isValidElement;
|
||||
exports.lazy = function(ctor) {
|
||||
ctor = {
|
||||
_status: -1,
|
||||
_result: ctor
|
||||
};
|
||||
var lazyType = {
|
||||
$$typeof: REACT_LAZY_TYPE,
|
||||
_payload: ctor,
|
||||
_init: lazyInitializer
|
||||
}, ioInfo = {
|
||||
name: "lazy",
|
||||
start: -1,
|
||||
end: -1,
|
||||
value: null,
|
||||
owner: null,
|
||||
debugStack: Error("react-stack-top-frame"),
|
||||
debugTask: console.createTask ? console.createTask("lazy()") : null
|
||||
};
|
||||
ctor._ioInfo = ioInfo;
|
||||
lazyType._debugInfo = [{ awaited: ioInfo }];
|
||||
return lazyType;
|
||||
};
|
||||
exports.memo = function(type, compare) {
|
||||
type ?? console.error("memo: The first argument must be a component. Instead received: %s", null === type ? "null" : typeof type);
|
||||
compare = {
|
||||
$$typeof: REACT_MEMO_TYPE,
|
||||
type,
|
||||
compare: void 0 === compare ? null : compare
|
||||
};
|
||||
var ownName;
|
||||
Object.defineProperty(compare, "displayName", {
|
||||
enumerable: !1,
|
||||
configurable: !0,
|
||||
get: function() {
|
||||
return ownName;
|
||||
},
|
||||
set: function(name) {
|
||||
ownName = name;
|
||||
type.name || type.displayName || (Object.defineProperty(type, "name", { value: name }), type.displayName = name);
|
||||
}
|
||||
});
|
||||
return compare;
|
||||
};
|
||||
exports.startTransition = function(scope) {
|
||||
var prevTransition = ReactSharedInternals.T, currentTransition = {};
|
||||
currentTransition._updatedFibers = /* @__PURE__ */ new Set();
|
||||
ReactSharedInternals.T = currentTransition;
|
||||
try {
|
||||
var returnValue = scope(), onStartTransitionFinish = ReactSharedInternals.S;
|
||||
null !== onStartTransitionFinish && onStartTransitionFinish(currentTransition, returnValue);
|
||||
"object" === typeof returnValue && null !== returnValue && "function" === typeof returnValue.then && (ReactSharedInternals.asyncTransitions++, returnValue.then(releaseAsyncTransition, releaseAsyncTransition), returnValue.then(noop, reportGlobalError));
|
||||
} catch (error) {
|
||||
reportGlobalError(error);
|
||||
} finally {
|
||||
null === prevTransition && currentTransition._updatedFibers && (scope = currentTransition._updatedFibers.size, currentTransition._updatedFibers.clear(), 10 < scope && console.warn("Detected a large number of updates inside startTransition. If this is due to a subscription please re-write it to use React provided hooks. Otherwise concurrent mode guarantees are off the table.")), null !== prevTransition && null !== currentTransition.types && (null !== prevTransition.types && prevTransition.types !== currentTransition.types && console.error("We expected inner Transitions to have transferred the outer types set and that you cannot add to the outer Transition while inside the inner.This is a bug in React."), prevTransition.types = currentTransition.types), ReactSharedInternals.T = prevTransition;
|
||||
}
|
||||
};
|
||||
exports.unstable_useCacheRefresh = function() {
|
||||
return resolveDispatcher().useCacheRefresh();
|
||||
};
|
||||
exports.use = function(usable) {
|
||||
return resolveDispatcher().use(usable);
|
||||
};
|
||||
exports.useActionState = function(action, initialState, permalink) {
|
||||
return resolveDispatcher().useActionState(action, initialState, permalink);
|
||||
};
|
||||
exports.useCallback = function(callback, deps) {
|
||||
return resolveDispatcher().useCallback(callback, deps);
|
||||
};
|
||||
exports.useContext = function(Context) {
|
||||
var dispatcher = resolveDispatcher();
|
||||
Context.$$typeof === REACT_CONSUMER_TYPE && console.error("Calling useContext(Context.Consumer) is not supported and will cause bugs. Did you mean to call useContext(Context) instead?");
|
||||
return dispatcher.useContext(Context);
|
||||
};
|
||||
exports.useDebugValue = function(value, formatterFn) {
|
||||
return resolveDispatcher().useDebugValue(value, formatterFn);
|
||||
};
|
||||
exports.useDeferredValue = function(value, initialValue) {
|
||||
return resolveDispatcher().useDeferredValue(value, initialValue);
|
||||
};
|
||||
exports.useEffect = function(create, deps) {
|
||||
create ?? console.warn("React Hook useEffect requires an effect callback. Did you forget to pass a callback to the hook?");
|
||||
return resolveDispatcher().useEffect(create, deps);
|
||||
};
|
||||
exports.useEffectEvent = function(callback) {
|
||||
return resolveDispatcher().useEffectEvent(callback);
|
||||
};
|
||||
exports.useId = function() {
|
||||
return resolveDispatcher().useId();
|
||||
};
|
||||
exports.useImperativeHandle = function(ref, create, deps) {
|
||||
return resolveDispatcher().useImperativeHandle(ref, create, deps);
|
||||
};
|
||||
exports.useInsertionEffect = function(create, deps) {
|
||||
create ?? console.warn("React Hook useInsertionEffect requires an effect callback. Did you forget to pass a callback to the hook?");
|
||||
return resolveDispatcher().useInsertionEffect(create, deps);
|
||||
};
|
||||
exports.useLayoutEffect = function(create, deps) {
|
||||
create ?? console.warn("React Hook useLayoutEffect requires an effect callback. Did you forget to pass a callback to the hook?");
|
||||
return resolveDispatcher().useLayoutEffect(create, deps);
|
||||
};
|
||||
exports.useMemo = function(create, deps) {
|
||||
return resolveDispatcher().useMemo(create, deps);
|
||||
};
|
||||
exports.useOptimistic = function(passthrough, reducer) {
|
||||
return resolveDispatcher().useOptimistic(passthrough, reducer);
|
||||
};
|
||||
exports.useReducer = function(reducer, initialArg, init) {
|
||||
return resolveDispatcher().useReducer(reducer, initialArg, init);
|
||||
};
|
||||
exports.useRef = function(initialValue) {
|
||||
return resolveDispatcher().useRef(initialValue);
|
||||
};
|
||||
exports.useState = function(initialState) {
|
||||
return resolveDispatcher().useState(initialState);
|
||||
};
|
||||
exports.useSyncExternalStore = function(subscribe, getSnapshot, getServerSnapshot) {
|
||||
return resolveDispatcher().useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
};
|
||||
exports.useTransition = function() {
|
||||
return resolveDispatcher().useTransition();
|
||||
};
|
||||
exports.version = "19.2.4";
|
||||
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error());
|
||||
})();
|
||||
}));
|
||||
//#endregion
|
||||
//#region node_modules/react/index.js
|
||||
var require_react = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
||||
module.exports = require_react_development();
|
||||
}));
|
||||
//#endregion
|
||||
export { __commonJSMin as n, require_react as t };
|
||||
|
||||
//# sourceMappingURL=react-Na5-BvaJ.js.map
|
||||
1
fotograf-de-scraper/frontend/node_modules/.vite/deps/react-Na5-BvaJ.js.map
generated
vendored
Normal file
185
fotograf-de-scraper/frontend/node_modules/.vite/deps/react-dom.js
generated
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
import { n as __commonJSMin, t as require_react } from "./react-Na5-BvaJ.js";
|
||||
//#region node_modules/react-dom/cjs/react-dom.development.js
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.development.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
var require_react_dom_development = /* @__PURE__ */ __commonJSMin(((exports) => {
|
||||
(function() {
|
||||
function noop() {}
|
||||
function testStringCoercion(value) {
|
||||
return "" + value;
|
||||
}
|
||||
function createPortal$1(children, containerInfo, implementation) {
|
||||
var key = 3 < arguments.length && void 0 !== arguments[3] ? arguments[3] : null;
|
||||
try {
|
||||
testStringCoercion(key);
|
||||
var JSCompiler_inline_result = !1;
|
||||
} catch (e) {
|
||||
JSCompiler_inline_result = !0;
|
||||
}
|
||||
JSCompiler_inline_result && (console.error("The provided key is an unsupported type %s. This value must be coerced to a string before using it here.", "function" === typeof Symbol && Symbol.toStringTag && key[Symbol.toStringTag] || key.constructor.name || "Object"), testStringCoercion(key));
|
||||
return {
|
||||
$$typeof: REACT_PORTAL_TYPE,
|
||||
key: null == key ? null : "" + key,
|
||||
children,
|
||||
containerInfo,
|
||||
implementation
|
||||
};
|
||||
}
|
||||
function getCrossOriginStringAs(as, input) {
|
||||
if ("font" === as) return "";
|
||||
if ("string" === typeof input) return "use-credentials" === input ? input : "";
|
||||
}
|
||||
function getValueDescriptorExpectingObjectForWarning(thing) {
|
||||
return null === thing ? "`null`" : void 0 === thing ? "`undefined`" : "" === thing ? "an empty string" : "something with type \"" + typeof thing + "\"";
|
||||
}
|
||||
function getValueDescriptorExpectingEnumForWarning(thing) {
|
||||
return null === thing ? "`null`" : void 0 === thing ? "`undefined`" : "" === thing ? "an empty string" : "string" === typeof thing ? JSON.stringify(thing) : "number" === typeof thing ? "`" + thing + "`" : "something with type \"" + typeof thing + "\"";
|
||||
}
|
||||
function resolveDispatcher() {
|
||||
var dispatcher = ReactSharedInternals.H;
|
||||
null === dispatcher && console.error("Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.");
|
||||
return dispatcher;
|
||||
}
|
||||
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());
|
||||
var React = require_react(), Internals = {
|
||||
d: {
|
||||
f: noop,
|
||||
r: function() {
|
||||
throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.");
|
||||
},
|
||||
D: noop,
|
||||
C: noop,
|
||||
L: noop,
|
||||
m: noop,
|
||||
X: noop,
|
||||
S: noop,
|
||||
M: noop
|
||||
},
|
||||
p: 0,
|
||||
findDOMNode: null
|
||||
}, REACT_PORTAL_TYPE = Symbol.for("react.portal"), ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||
"function" === typeof Map && null != Map.prototype && "function" === typeof Map.prototype.forEach && "function" === typeof Set && null != Set.prototype && "function" === typeof Set.prototype.clear && "function" === typeof Set.prototype.forEach || console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills");
|
||||
exports.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = Internals;
|
||||
exports.createPortal = function(children, container) {
|
||||
var key = 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null;
|
||||
if (!container || 1 !== container.nodeType && 9 !== container.nodeType && 11 !== container.nodeType) throw Error("Target container is not a DOM element.");
|
||||
return createPortal$1(children, container, null, key);
|
||||
};
|
||||
exports.flushSync = function(fn) {
|
||||
var previousTransition = ReactSharedInternals.T, previousUpdatePriority = Internals.p;
|
||||
try {
|
||||
if (ReactSharedInternals.T = null, Internals.p = 2, fn) return fn();
|
||||
} finally {
|
||||
ReactSharedInternals.T = previousTransition, Internals.p = previousUpdatePriority, Internals.d.f() && console.error("flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.");
|
||||
}
|
||||
};
|
||||
exports.preconnect = function(href, options) {
|
||||
"string" === typeof href && href ? null != options && "object" !== typeof options ? console.error("ReactDOM.preconnect(): Expected the `options` argument (second) to be an object but encountered %s instead. The only supported option at this time is `crossOrigin` which accepts a string.", getValueDescriptorExpectingEnumForWarning(options)) : null != options && "string" !== typeof options.crossOrigin && console.error("ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered %s instead. Try removing this option or passing a string value instead.", getValueDescriptorExpectingObjectForWarning(options.crossOrigin)) : console.error("ReactDOM.preconnect(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.", getValueDescriptorExpectingObjectForWarning(href));
|
||||
"string" === typeof href && (options ? (options = options.crossOrigin, options = "string" === typeof options ? "use-credentials" === options ? options : "" : void 0) : options = null, Internals.d.C(href, options));
|
||||
};
|
||||
exports.prefetchDNS = function(href) {
|
||||
if ("string" !== typeof href || !href) console.error("ReactDOM.prefetchDNS(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.", getValueDescriptorExpectingObjectForWarning(href));
|
||||
else if (1 < arguments.length) {
|
||||
var options = arguments[1];
|
||||
"object" === typeof options && options.hasOwnProperty("crossOrigin") ? console.error("ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.", getValueDescriptorExpectingEnumForWarning(options)) : console.error("ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.", getValueDescriptorExpectingEnumForWarning(options));
|
||||
}
|
||||
"string" === typeof href && Internals.d.D(href);
|
||||
};
|
||||
exports.preinit = function(href, options) {
|
||||
"string" === typeof href && href ? null == options || "object" !== typeof options ? console.error("ReactDOM.preinit(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preinitialized but encountered %s instead.", getValueDescriptorExpectingEnumForWarning(options)) : "style" !== options.as && "script" !== options.as && console.error("ReactDOM.preinit(): Expected the `as` property in the `options` argument (second) to contain a valid value describing the type of resource to be preinitialized but encountered %s instead. Valid values for `as` are \"style\" and \"script\".", getValueDescriptorExpectingEnumForWarning(options.as)) : console.error("ReactDOM.preinit(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.", getValueDescriptorExpectingObjectForWarning(href));
|
||||
if ("string" === typeof href && options && "string" === typeof options.as) {
|
||||
var as = options.as, crossOrigin = getCrossOriginStringAs(as, options.crossOrigin), integrity = "string" === typeof options.integrity ? options.integrity : void 0, fetchPriority = "string" === typeof options.fetchPriority ? options.fetchPriority : void 0;
|
||||
"style" === as ? Internals.d.S(href, "string" === typeof options.precedence ? options.precedence : void 0, {
|
||||
crossOrigin,
|
||||
integrity,
|
||||
fetchPriority
|
||||
}) : "script" === as && Internals.d.X(href, {
|
||||
crossOrigin,
|
||||
integrity,
|
||||
fetchPriority,
|
||||
nonce: "string" === typeof options.nonce ? options.nonce : void 0
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.preinitModule = function(href, options) {
|
||||
var encountered = "";
|
||||
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
|
||||
void 0 !== options && "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : options && "as" in options && "script" !== options.as && (encountered += " The `as` option encountered was " + getValueDescriptorExpectingEnumForWarning(options.as) + ".");
|
||||
if (encountered) console.error("ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` string and, optionally, an `options` object with a valid `as` property.%s", encountered);
|
||||
else switch (encountered = options && "string" === typeof options.as ? options.as : "script", encountered) {
|
||||
case "script": break;
|
||||
default: encountered = getValueDescriptorExpectingEnumForWarning(encountered), console.error("ReactDOM.preinitModule(): Currently the only supported \"as\" type for this function is \"script\" but received \"%s\" instead. This warning was generated for `href` \"%s\". In the future other module types will be supported, aligning with the import-attributes proposal. Learn more here: (https://github.com/tc39/proposal-import-attributes)", encountered, href);
|
||||
}
|
||||
if ("string" === typeof href) if ("object" === typeof options && null !== options) {
|
||||
if (null == options.as || "script" === options.as) encountered = getCrossOriginStringAs(options.as, options.crossOrigin), Internals.d.M(href, {
|
||||
crossOrigin: encountered,
|
||||
integrity: "string" === typeof options.integrity ? options.integrity : void 0,
|
||||
nonce: "string" === typeof options.nonce ? options.nonce : void 0
|
||||
});
|
||||
} else options ?? Internals.d.M(href);
|
||||
};
|
||||
exports.preload = function(href, options) {
|
||||
var encountered = "";
|
||||
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
|
||||
null == options || "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : "string" === typeof options.as && options.as || (encountered += " The `as` option encountered was " + getValueDescriptorExpectingObjectForWarning(options.as) + ".");
|
||||
encountered && console.error("ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel=\"preload\" as=\"...\" />` tag.%s", encountered);
|
||||
if ("string" === typeof href && "object" === typeof options && null !== options && "string" === typeof options.as) {
|
||||
encountered = options.as;
|
||||
var crossOrigin = getCrossOriginStringAs(encountered, options.crossOrigin);
|
||||
Internals.d.L(href, encountered, {
|
||||
crossOrigin,
|
||||
integrity: "string" === typeof options.integrity ? options.integrity : void 0,
|
||||
nonce: "string" === typeof options.nonce ? options.nonce : void 0,
|
||||
type: "string" === typeof options.type ? options.type : void 0,
|
||||
fetchPriority: "string" === typeof options.fetchPriority ? options.fetchPriority : void 0,
|
||||
referrerPolicy: "string" === typeof options.referrerPolicy ? options.referrerPolicy : void 0,
|
||||
imageSrcSet: "string" === typeof options.imageSrcSet ? options.imageSrcSet : void 0,
|
||||
imageSizes: "string" === typeof options.imageSizes ? options.imageSizes : void 0,
|
||||
media: "string" === typeof options.media ? options.media : void 0
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.preloadModule = function(href, options) {
|
||||
var encountered = "";
|
||||
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
|
||||
void 0 !== options && "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : options && "as" in options && "string" !== typeof options.as && (encountered += " The `as` option encountered was " + getValueDescriptorExpectingObjectForWarning(options.as) + ".");
|
||||
encountered && console.error("ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `<link rel=\"modulepreload\" as=\"...\" />` tag.%s", encountered);
|
||||
"string" === typeof href && (options ? (encountered = getCrossOriginStringAs(options.as, options.crossOrigin), Internals.d.m(href, {
|
||||
as: "string" === typeof options.as && "script" !== options.as ? options.as : void 0,
|
||||
crossOrigin: encountered,
|
||||
integrity: "string" === typeof options.integrity ? options.integrity : void 0
|
||||
})) : Internals.d.m(href));
|
||||
};
|
||||
exports.requestFormReset = function(form) {
|
||||
Internals.d.r(form);
|
||||
};
|
||||
exports.unstable_batchedUpdates = function(fn, a) {
|
||||
return fn(a);
|
||||
};
|
||||
exports.useFormState = function(action, initialState, permalink) {
|
||||
return resolveDispatcher().useFormState(action, initialState, permalink);
|
||||
};
|
||||
exports.useFormStatus = function() {
|
||||
return resolveDispatcher().useHostTransitionStatus();
|
||||
};
|
||||
exports.version = "19.2.4";
|
||||
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error());
|
||||
})();
|
||||
}));
|
||||
//#endregion
|
||||
//#region node_modules/react-dom/index.js
|
||||
var require_react_dom = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
||||
module.exports = require_react_dom_development();
|
||||
}));
|
||||
//#endregion
|
||||
export default require_react_dom();
|
||||
export { require_react_dom as t };
|
||||
|
||||
//# sourceMappingURL=react-dom.js.map
|
||||