summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordaniel <noreply@maennchen1.de>2026-09-24 11:24:12 +0200
committerdaniel <noreply@maennchen1.de>2026-09-24 11:24:12 +0200
commita95de0fa4cd3c617ef08a9679a6c32ae5f676fda (patch)
treeb78bd9fa23550e7dfd03cc693032cce5e9048236
parent19a8938fa3b41a6c6ed1e15c20586f860f4a637b (diff)
- Feature: PHP 8.3 Kompatibilität
- Feature: SmartCheckout - Ersatz für EinSeitenCheckout - Feature: Optik im Frontend an moderne Themes angepasst
-rw-r--r--views/css/smartcheckout.css317
-rw-r--r--views/js/smartcheckout.js694
-rw-r--r--views/mods/mod_basketteaser/smartcheckout.phtml44
-rw-r--r--views/mods/mod_orderupload/smartcheckout_checkout.phtml77
-rw-r--r--views/mods/mod_orderupload/smartcheckout_row.phtml21
-rw-r--r--views/mods/mod_paypalapi/overview_preauth_button.phtml15
-rw-r--r--views/mods/mod_productvars/smartcheckout_row.phtml57
7 files changed, 1225 insertions, 0 deletions
diff --git a/views/css/smartcheckout.css b/views/css/smartcheckout.css
new file mode 100644
index 0000000..6d9ac8b
--- /dev/null
+++ b/views/css/smartcheckout.css
@@ -0,0 +1,317 @@
+@CHARSET "UTF-8";
+
+/*
+ * Moderner EinSeitenCheckout
+ *
+ * Die Optik hält sich bewusst zurück: Schrift, Textfarbe und Hintergrund kommen aus dem
+ * Theme, eigene Angaben gibt es nur dort, wo die Struktur sie braucht. Linien und Flächen
+ * sind neutrales Grau mit Transparenz und wirken deshalb auf hellen wie auf dunklen
+ * Themes. Gedämpfter Text entsteht über opacity statt über eine eigene Farbe.
+ *
+ * Die wenigen Werte liegen als Custom Properties auf .wpsg_smartcheckout und können im
+ * Theme überschrieben werden, ohne dass eine Regel angefasst werden muss.
+ */
+
+.wpsg_smartcheckout {
+ --sc-line:rgba(127, 127, 127, 0.35);
+ --sc-line-soft:rgba(127, 127, 127, 0.2);
+ --sc-flat:rgba(127, 127, 127, 0.08);
+ --sc-accent:currentColor;
+ --sc-error:#b3261e;
+ --sc-radius:3px;
+ --sc-gap:2rem;
+}
+
+.wpsg_smartcheckout [v-cloak], .wpsg_smartcheckout[v-cloak] { display:none; }
+.wpsg_smartcheckout_noscript { padding:1rem; border-left:3px solid var(--sc-error, #b3261e); margin-bottom:1.5rem; }
+
+.wpsg_smartcheckout * { box-sizing:border-box; }
+
+/* Themes mit Inhaltskarte (z.B. Kadence "content-style-boxed") ziehen alignwide bis an den Kartenrand, der Checkout bleibt im Innenabstand */
+html body.content-style-boxed .entry-content .wpsg_smartcheckout.alignwide,
+html body.content-style-boxed .entry-content .wpsg_smartcheckout_after.alignwide { margin-left:0; margin-right:0; width:auto; max-width:100%; }
+
+/* Layout */
+.wpsg_smartcheckout .sc_layout { display:grid; grid-template-columns:minmax(0, 1fr) 21rem; gap:var(--sc-gap); align-items:start; }
+.wpsg_smartcheckout_single .sc_layout { grid-template-columns:minmax(0, 1fr); }
+.wpsg_smartcheckout .sc_main { display:flex; flex-direction:column; min-width:0; }
+.wpsg_smartcheckout .sc_side { position:sticky; top:1.5rem; }
+.wpsg_smartcheckout_single .sc_side { position:static; }
+.wpsg_smartcheckout .sc_layout.sc_busy { opacity:0.6; pointer-events:none; transition:opacity 0.15s ease-out; }
+
+/* Die Schritte werden nur durch eine Linie getrennt, nicht durch gerahmte Kästen */
+.wpsg_smartcheckout .sc_box { padding:var(--sc-gap) 0; border-top:1px solid var(--sc-line-soft); }
+.wpsg_smartcheckout .sc_box:first-child { padding-top:0; border-top:0; }
+.wpsg_smartcheckout .sc_title { margin:0 0 1.25rem 0; font-size:1.05rem; line-height:1.3; }
+.wpsg_smartcheckout .sc_step { margin-right:0.5rem; font-weight:400; opacity:0.5; }
+
+/* Meldungen */
+.wpsg_smartcheckout .sc_messages { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:var(--sc-gap); }
+.wpsg_smartcheckout .sc_messages:empty { display:none; margin:0; }
+.wpsg_smartcheckout .sc_message { padding:0.6rem 0.9rem; border-left:3px solid var(--sc-line); background:var(--sc-flat); }
+.wpsg_smartcheckout .sc_message_error { border-left-color:var(--sc-error); }
+.wpsg_smartcheckout .sc_message_message { border-left-color:#5a9e3d; }
+
+/* Leerer Warenkorb */
+.wpsg_smartcheckout .sc_empty { padding:2.5rem 0; }
+
+/* Warenkorbpositionen: jede Position steht in einem eigenen Rahmen, damit Produktvariablen und Hinweise erkennbar zum Produkt gehören */
+.wpsg_smartcheckout .sc_products { display:flex; flex-direction:column; gap:0.75rem; }
+.wpsg_smartcheckout .sc_product { display:grid; grid-template-columns:auto minmax(0, 1fr) auto auto; gap:1rem; align-items:start; padding:1rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); }
+.wpsg_smartcheckout .sc_product_image img { display:block; width:56px; height:56px; object-fit:cover; border-radius:var(--sc-radius); }
+.wpsg_smartcheckout .sc_product_name { font-weight:600; }
+.wpsg_smartcheckout .sc_product_desc,
+.wpsg_smartcheckout .sc_product_extra,
+.wpsg_smartcheckout .sc_product_tax { font-size:0.85rem; opacity:0.7; margin-top:0.2rem; }
+
+/* Eingabefelder der Module (z.B. Produktvariablen) als eigene Zeile über alle Spalten der Position, innerhalb des Rahmens durch eine Linie abgesetzt */
+.wpsg_smartcheckout .sc_product_fields { grid-column:1 / -1; display:flex; flex-direction:column; gap:1rem; min-width:0; padding-top:0.75rem; border-top:1px solid var(--sc-line-soft); font-size:0.85rem; }
+
+/* Upload zum Produkt (wpsg_mod_orderupload) an der Warenkorbposition
+ *
+ * Das Ziel wird nach dem Hochladen vom Modul per Script neu befüllt, die Regeln hängen deshalb am
+ * klassischen Markup (ul, input[type=button]). Die Spezifität liegt über den Button-Regeln der Themes.
+ */
+.wpsg_smartcheckout .sc_upload { display:flex; flex-direction:column; gap:0.4rem; }
+.wpsg_smartcheckout .sc_upload_label { font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_upload_target { display:flex; flex-wrap:wrap; align-items:center; gap:0.5rem; }
+.wpsg_smartcheckout .sc_upload_target ul { flex:1 1 100%; display:flex; flex-direction:column; gap:0.3rem; margin:0; padding:0; list-style:none; }
+.wpsg_smartcheckout .sc_upload_target li { display:flex; align-items:center; gap:0.5rem; margin:0; padding:0.35rem 0.6rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); }
+.wpsg_smartcheckout .sc_upload_target li > a:first-child { flex:1 1 auto; min-width:0; overflow-wrap:anywhere; color:inherit; }
+.wpsg_smartcheckout .sc_upload_target .size { font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_upload_target .removeLink { flex:0 0 auto; display:flex; opacity:0.7; }
+.wpsg_smartcheckout .sc_upload_target .removeLink:hover { opacity:1; }
+.wpsg_smartcheckout .sc_upload_target .removeLink img { display:block; width:16px; height:16px; }
+.wpsg_smartcheckout .sc_upload_target input[type="file"] { flex:1 1 14rem; min-width:0; }
+.wpsg_smartcheckout .sc_upload_target input[type="button"] { flex:0 0 auto; min-height:0; margin:0; padding:0.5rem 0.9rem; border:1px solid var(--sc-line); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; font-size:0.9rem; line-height:1.2; text-transform:none; letter-spacing:normal; box-shadow:none; cursor:pointer; }
+.wpsg_smartcheckout .sc_upload_target input[type="button"]:hover { background:var(--sc-flat); }
+/* Der Upload startet direkt nach der Auswahl (smartcheckout.js, startUpload), der Button des Moduls wird nicht gebraucht */
+.wpsg_smartcheckout .sc_upload_target [data-sc-autoupload] { display:none; }
+
+/* Bestellupload (wpsg_mod_orderupload) als eigener Block unter den Kundendaten, Liste und Dateiauswahl wie beim Upload zum Produkt */
+.wpsg_smartcheckout .sc_orderupload .sc_title { margin-bottom:1rem; }
+.wpsg_smartcheckout .sc_orderupload .sc_hint { margin-top:0; }
+.wpsg_smartcheckout .sc_orderupload .sc_upload { font-size:0.85rem; }
+.wpsg_smartcheckout .sc_upload_message { flex:1 1 100%; padding:0.35rem 0.6rem; border-left:3px solid var(--sc-line); background:var(--sc-flat); }
+.wpsg_smartcheckout .sc_upload_message_error { border-left-color:var(--sc-error); }
+.wpsg_smartcheckout .sc_upload_message_message { border-left-color:#5a9e3d; }
+.wpsg_smartcheckout .sc_upload_wait { flex:1 1 100%; opacity:0.7; }
+
+/* Dateiauswahl im gesamten Checkout (Upload zum Produkt, Bestellupload) in der Optik der übrigen Felder */
+.wpsg_smartcheckout input[type="file"] { max-width:100%; color:inherit; font:inherit; font-size:0.85rem; }
+.wpsg_smartcheckout input[type="file"]::file-selector-button { margin-right:0.6rem; padding:0.45rem 0.8rem; border:1px solid var(--sc-line); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; cursor:pointer; }
+.wpsg_smartcheckout input[type="file"]::file-selector-button:hover { background:var(--sc-flat); }
+
+/* Produktvariablen an der Warenkorbposition */
+.wpsg_smartcheckout .sc_pvars { display:flex; flex-direction:column; gap:0.6rem; }
+.wpsg_smartcheckout .sc_pvar { display:flex; flex-direction:column; gap:0.25rem; }
+.wpsg_smartcheckout .sc_pvar_label { font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_pvar_input { width:100%; padding:0.4rem 0.6rem; border:1px solid var(--sc-line); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; }
+.wpsg_smartcheckout textarea.sc_pvar_input { resize:vertical; }
+.wpsg_smartcheckout .sc_pvar_check { display:flex; align-items:flex-start; gap:0.5rem; cursor:pointer; }
+.wpsg_smartcheckout .sc_pvar_check input[type="checkbox"] { flex:0 0 auto; margin:0.15rem 0 0 0; }
+.wpsg_smartcheckout .sc_pvar.sc_invalid .sc_pvar_input { border-color:var(--sc-error); }
+.wpsg_smartcheckout .sc_pvar.sc_invalid .sc_pvar_label,
+.wpsg_smartcheckout .sc_pvar.sc_invalid .sc_pvar_check { color:var(--sc-error); opacity:1; }
+.wpsg_smartcheckout .sc_product_price { text-align:right; white-space:nowrap; }
+.wpsg_smartcheckout .sc_product_price_total { font-weight:600; }
+.wpsg_smartcheckout .sc_product_price_single { font-size:0.8rem; opacity:0.7; }
+
+/* Mengenwahl */
+.wpsg_smartcheckout .sc_product_amount { display:flex; flex-direction:column; align-items:center; gap:0.35rem; }
+.wpsg_smartcheckout .sc_stepper { display:inline-flex; align-items:stretch; border:1px solid var(--sc-line); border-radius:var(--sc-radius); overflow:hidden; }
+.wpsg_smartcheckout .sc_stepper_btn { border:0; background:none; width:1.9rem; cursor:pointer; font:inherit; line-height:1; color:inherit; }
+.wpsg_smartcheckout .sc_stepper_btn:disabled { cursor:default; opacity:0.4; }
+.wpsg_smartcheckout .sc_stepper_input { width:2.8rem; border:0; text-align:center; padding:0.4rem 0; background:none; color:inherit; font:inherit; }
+.wpsg_smartcheckout .sc_stepper_static { display:inline-block; padding:0.4rem 0.9rem; }
+.wpsg_smartcheckout .sc_remove { border:0; background:none; padding:0; font-size:0.8rem; opacity:0.7; text-decoration:underline; cursor:pointer; color:inherit; }
+
+/* Warenkorbteaser: angelehnt an die Warenkorbpositionen, aber zurückhaltender */
+.wpsg_smartcheckout .sc_teaser { padding-top:1rem; border-top:1px solid var(--sc-line-soft); }
+.wpsg_smartcheckout .sc_teaser_title { margin-bottom:0.5rem; font-size:0.85rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_teaser_item { display:flex; align-items:center; gap:0.75rem 1rem; flex-wrap:wrap; padding:0.5rem 0; font-size:0.9rem; }
+.wpsg_smartcheckout .sc_teaser_image img { display:block; width:40px; height:40px; object-fit:cover; border-radius:var(--sc-radius); }
+.wpsg_smartcheckout .sc_teaser_body { flex:1 1 10rem; min-width:0; }
+.wpsg_smartcheckout .sc_teaser_price { font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_teaser_action { display:flex; align-items:center; gap:0.5rem; }
+.wpsg_smartcheckout .sc_teaser_amount { width:2.8rem; padding:0.45rem 0; border:1px solid var(--sc-line); border-radius:var(--sc-radius); text-align:center; background:none; color:inherit; font:inherit; }
+
+/* Gutschein */
+.wpsg_smartcheckout .sc_voucher { margin-top:1.5rem; }
+.wpsg_smartcheckout .sc_voucher_label { display:block; margin-bottom:0.35rem; font-size:0.85rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_voucher_row { display:flex; gap:0.5rem; flex-wrap:wrap; }
+.wpsg_smartcheckout .sc_voucher_input { flex:1 1 12rem; min-width:0; }
+.wpsg_smartcheckout .sc_voucher_list { margin-top:0.75rem; display:flex; flex-direction:column; gap:0.4rem; }
+.wpsg_smartcheckout .sc_voucher_item { display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap; font-size:0.9rem; }
+.wpsg_smartcheckout .sc_voucher_code { font-weight:600; }
+.wpsg_smartcheckout .sc_voucher_partial { opacity:0.7; }
+.wpsg_smartcheckout .sc_voucher_value { margin-left:auto; }
+
+/* Formularfelder */
+.wpsg_smartcheckout .sc_fields { display:grid; grid-template-columns:repeat(12, 1fr); gap:0.9rem; }
+.wpsg_smartcheckout .sc_field { display:flex; flex-direction:column; gap:0.3rem; grid-column:span 12; min-width:0; }
+.wpsg_smartcheckout .sc_field_half { grid-column:span 6; }
+.wpsg_smartcheckout .sc_field_wide { grid-column:span 8; }
+.wpsg_smartcheckout .sc_field_narrow { grid-column:span 4; }
+.wpsg_smartcheckout .sc_label { font-size:0.85rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_required { color:var(--sc-error); margin-left:0.15rem; }
+.wpsg_smartcheckout .sc_input,
+.wpsg_smartcheckout .sc_voucher_input,
+.wpsg_smartcheckout .sc_textarea { width:100%; padding:0.55rem 0.7rem; border:1px solid var(--sc-line); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; }
+.wpsg_smartcheckout .sc_textarea { resize:vertical; }
+/* Auswahlfelder mit eigenem Pfeil: der Hintergrund der Felder wird zurückgesetzt, damit verschwindet ein vom Theme gezeichneter Pfeil */
+#wpsg_smartcheckout select.sc_input,
+#wpsg_smartcheckout select.sc_pvar_input,
+#wpsg_smartcheckout .sc_modul_extra select { -webkit-appearance:none; appearance:none; padding-right:2rem; background-color:transparent; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%237f7f7f' stroke-width='1.6'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 0.7rem center; background-size:0.7rem auto; cursor:pointer; }
+.wpsg_smartcheckout .sc_hint { margin-top:1rem; font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_modul_login:empty,
+.wpsg_smartcheckout .sc_modul_customer:empty,
+.wpsg_smartcheckout .sc_modul_payment:empty { display:none; }
+.wpsg_smartcheckout .sc_modul_basket,
+.wpsg_smartcheckout .sc_modul_customer,
+.wpsg_smartcheckout .sc_modul_payment,
+.wpsg_smartcheckout .sc_modul_sum { margin-top:1rem; }
+.wpsg_smartcheckout .sc_modul_sum:empty { display:none; }
+
+/* Modulblöcke vor dem Abschluss (Bestellvariablen, gesonderte Lieferadresse, …)
+ *
+ * Diese Module bringen das Markup des klassischen Checkouts mit (wpsg_box, wpsg_inner,
+ * wpsg_checkoutblock). Die Regeln dazu in frontend.css hängen an .wpsg_checkout und
+ * greifen hier nicht — die Felder werden deshalb an dieser Stelle auf die Optik des
+ * EinSeitenCheckouts gebracht.
+ */
+.wpsg_smartcheckout .sc_modul_extra > div + div { margin-top:var(--sc-gap); }
+.wpsg_smartcheckout .sc_modul_extra h2 { margin:0 0 1.25rem 0; padding:0; font-size:1.05rem; line-height:1.3; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_spacer,
+.wpsg_smartcheckout .sc_modul_extra .wpsg_clear { display:none; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_inner { display:flex; flex-direction:column; gap:0.9rem; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_checkoutblock { display:flex; flex-direction:column; gap:0.3rem; }
+.wpsg_smartcheckout .sc_modul_extra label { font-size:0.85rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_required { color:var(--sc-error); margin-left:0.15rem; }
+.wpsg_smartcheckout .sc_modul_extra input[type="text"],
+.wpsg_smartcheckout .sc_modul_extra input[type="tel"],
+.wpsg_smartcheckout .sc_modul_extra input[type="email"],
+.wpsg_smartcheckout .sc_modul_extra input[type="number"],
+.wpsg_smartcheckout .sc_modul_extra select,
+.wpsg_smartcheckout .sc_modul_extra textarea { width:100%; padding:0.55rem 0.7rem; border:1px solid var(--sc-line); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_error { border-color:var(--sc-error); }
+
+/* Ankreuzfelder: Kästchen und Text stehen in einer Zeile, der Text bleibt bei einem Umbruch bündig */
+.wpsg_smartcheckout .sc_modul_extra .wpsg_mod_ordervars_check label { display:flex; align-items:flex-start; gap:0.5rem; opacity:1; cursor:pointer; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_mod_ordervars_check label > input[type="checkbox"] { flex:0 0 auto; width:auto; margin:0; }
+.wpsg_smartcheckout .sc_modul_extra .wpsg_mod_ordervars_text { flex:1 1 auto; min-width:0; }
+
+/* Versand- und Zahlungsauswahl */
+.wpsg_smartcheckout .sc_choices { display:flex; flex-direction:column; gap:0.5rem; }
+.wpsg_smartcheckout .sc_choice { --sc-choice-radio:1rem; --sc-choice-gap:0.6rem; display:flex; flex-direction:column; gap:0.3rem; padding:0.8rem 0.9rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); cursor:pointer; }
+.wpsg_smartcheckout .sc_choice_active { border-color:var(--sc-accent); background:var(--sc-flat); }
+/* Kopfzeile: Radio, Logo, Bezeichnung und Preis auf einer Linie */
+.wpsg_smartcheckout .sc_choice_head { display:flex; align-items:center; gap:var(--sc-choice-gap); min-width:0; }
+.wpsg_smartcheckout .sc_choice_head input[type="radio"] { flex:0 0 auto; width:var(--sc-choice-radio); height:var(--sc-choice-radio); margin:0; }
+/* Feste Breite, damit die Bezeichnungen bei unterschiedlich breiten Logos bündig untereinander stehen */
+.wpsg_smartcheckout .sc_choice_logo { flex:0 0 auto; width:4.5rem; height:22px; object-fit:contain; object-position:left center; }
+.wpsg_smartcheckout .sc_choice_name { flex:1 1 auto; min-width:0; font-weight:600; }
+.wpsg_smartcheckout .sc_choice_price { flex:0 0 auto; margin-left:auto; white-space:nowrap; }
+/* Beschreibung und Zusatzangaben darunter, bündig mit Logo bzw. Bezeichnung */
+.wpsg_smartcheckout .sc_choice_hint,
+.wpsg_smartcheckout .sc_choice_extra { padding-left:calc(var(--sc-choice-radio) + var(--sc-choice-gap)); font-size:0.85rem; }
+.wpsg_smartcheckout .sc_choice_hint { opacity:0.7; }
+
+/* Zusammenfassung */
+.wpsg_smartcheckout .sc_summary { position:relative; padding:1.25rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); }
+.wpsg_smartcheckout_single .sc_side { margin-top:var(--sc-gap); }
+.wpsg_smartcheckout .sc_sum_rows { display:flex; flex-direction:column; gap:0.4rem; }
+.wpsg_smartcheckout .sc_sum_row { display:flex; justify-content:space-between; gap:1rem; align-items:baseline; font-size:0.95rem; }
+.wpsg_smartcheckout .sc_sum_value { white-space:nowrap; }
+.wpsg_smartcheckout .sc_sum_note,
+.wpsg_smartcheckout .sc_sum_hint { font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_sum_hint { margin-top:0.75rem; }
+.wpsg_smartcheckout .sc_sum_total { margin-top:0.5rem; padding-top:0.6rem; border-top:1px solid var(--sc-line); font-size:1.05rem; font-weight:600; }
+
+/* Zusatzzeilen der Module (z.B. Gesamtgewicht) bringen das Markup der klassischen Zusammenfassung mit, sie erscheinen gedämpft wie die Hinweise */
+.wpsg_smartcheckout .sc_modul_sum .overview_sum_row { display:flex; justify-content:space-between; gap:1rem; align-items:baseline; font-size:0.8rem; opacity:0.7; }
+.wpsg_smartcheckout .sc_modul_sum .overview_sum_row .title,
+.wpsg_smartcheckout .sc_modul_sum .overview_sum_row .value { width:auto; margin:0; font-weight:normal; }
+.wpsg_smartcheckout .sc_modul_sum .overview_sum_row .value { white-space:nowrap; }
+.wpsg_smartcheckout .sc_modul_sum .overview_sum_row .value + .value { display:none; }
+
+/* Bestellbedingungen und AGB */
+.wpsg_smartcheckout .sc_modul_conditions,
+.wpsg_smartcheckout .sc_agb { margin-top:1.1rem; font-size:0.85rem; }
+.wpsg_smartcheckout .sc_modul_conditions .wpsg_agb + .wpsg_agb { margin-top:0.6rem; }
+/* Die Module setzen den Text als losen Textknoten ins label — Flex würde ihn zerreißen */
+.wpsg_smartcheckout .sc_modul_conditions label,
+.wpsg_smartcheckout .sc_agb label { display:block; position:relative; padding-left:1.5rem; cursor:pointer; }
+.wpsg_smartcheckout .sc_modul_conditions label > input[type="checkbox"],
+.wpsg_smartcheckout .sc_agb label > input[type="checkbox"] { position:absolute; left:0; top:0.15rem; margin:0; }
+.wpsg_smartcheckout .sc_modul_conditions .wpsg_mod_ordercondition_hint { max-height:9rem; overflow-y:auto; margin-top:0.5rem; padding:0.6rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); }
+
+/* Fehlerhafte Eingaben */
+.wpsg_smartcheckout .sc_field.sc_invalid .sc_input,
+.wpsg_smartcheckout .sc_invalid.sc_voucher_input { border-color:var(--sc-error); }
+.wpsg_smartcheckout .sc_choices.sc_invalid,
+.wpsg_smartcheckout .sc_modul_conditions.sc_invalid,
+.wpsg_smartcheckout .sc_agb.sc_invalid { outline:1px solid var(--sc-error); outline-offset:4px; }
+
+/* Buttons erben Schrift und Farbe des Themes und bleiben bewusst schlicht */
+.wpsg_smartcheckout .sc_button { display:inline-flex; align-items:center; justify-content:center; padding:0.65rem 1.1rem; border:1px solid var(--sc-accent); border-radius:var(--sc-radius); background:none; color:inherit; font:inherit; cursor:pointer; }
+.wpsg_smartcheckout .sc_button:hover { background:var(--sc-flat); }
+.wpsg_smartcheckout .sc_button:disabled { opacity:0.5; cursor:default; }
+.wpsg_smartcheckout .sc_button_secondary { padding:0.5rem 0.9rem; border-color:var(--sc-line); font-size:0.9rem; }
+.wpsg_smartcheckout .sc_button_order { width:100%; margin-top:1.1rem; font-weight:600; }
+
+/* Ladeanzeige */
+.wpsg_smartcheckout .sc_spinner { position:absolute; top:1.25rem; right:1.25rem; }
+.wpsg_smartcheckout .sc_spinner_dot { display:block; width:0.9rem; height:0.9rem; border:2px solid var(--sc-line); border-top-color:var(--sc-accent); border-radius:50%; animation:wpsg_sc_spin 0.7s linear infinite; }
+
+/* Layer beim Abschicken der Bestellung über der gesamten Seite */
+.wpsg_smartcheckout .sc_submitting { position:fixed; top:0; left:0; right:0; bottom:0; z-index:100001; display:flex; align-items:center; justify-content:center; padding:1rem; background-color:rgba(255, 255, 255, 0.75); cursor:wait; }
+.wpsg_smartcheckout .sc_submitting_inner { display:flex; align-items:center; gap:0.75rem; padding:1rem 1.25rem; border:1px solid var(--sc-line-soft); border-radius:var(--sc-radius); background-color:#FFFFFF; color:#1d1d1d; box-shadow:0 4px 20px rgba(0, 0, 0, 0.08); }
+.wpsg_smartcheckout .sc_submitting .sc_spinner_dot { width:1.2rem; height:1.2rem; }
+.wpsg_smartcheckout .sc_label_hidden,
+.wpsg_smartcheckout .sc_visuallyhidden { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
+
+/* GARAN-Gewährleistungslabel */
+
+.wpsg_smartcheckout .sc_garan { text-align:center; margin:1rem 0; }
+.wpsg_smartcheckout .sc_garan img { display:block; max-width:100%; height:auto; }
+.wpsg_smartcheckout .sc_garan_zoom { display:inline-block; padding:0; border:0; background:none; cursor:zoom-in; }
+.wpsg_smartcheckout .sc_garan_zoom:focus-visible { outline:2px solid currentColor; outline-offset:2px; }
+.wpsg_smartcheckout .sc_garan_lightbox { cursor:zoom-out; }
+.wpsg_smartcheckout .sc_garan_lightbox .sc_garan_popup_inner { cursor:default; }
+.wpsg_smartcheckout .sc_garan_popup { position:fixed; top:0; left:0; right:0; bottom:0; z-index:100000; display:flex; align-items:center; justify-content:center; padding:1rem; background-color:rgba(0, 0, 0, 0.6); }
+.wpsg_smartcheckout .sc_garan_popup_inner { display:flex; flex-direction:column; max-width:600px; max-height:100%; padding:1rem; text-align:center; background-color:#FFFFFF; }
+.wpsg_smartcheckout .sc_garan_popup_image { min-height:0; overflow-y:auto; }
+.wpsg_smartcheckout .sc_garan_popup_image img { max-width:100%; height:auto; }
+.wpsg_smartcheckout .sc_garan_popup_inner .sc_button { flex:0 0 auto; margin-top:1rem; }
+.wpsg_smartcheckout .sc_product_revocation { margin-top:0.5rem; }
+
+@keyframes wpsg_sc_spin {
+ to { transform:rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .wpsg_smartcheckout .sc_spinner_dot { animation:none; }
+ .wpsg_smartcheckout .sc_layout.sc_busy { transition:none; }
+}
+
+/* Schmale Ansichten */
+@media (max-width: 900px) {
+ .wpsg_smartcheckout .sc_layout { grid-template-columns:minmax(0, 1fr); }
+ .wpsg_smartcheckout .sc_side { position:static; margin-top:var(--sc-gap); }
+}
+
+@media (max-width: 600px) {
+ .wpsg_smartcheckout .sc_summary { padding:1rem; }
+ .wpsg_smartcheckout .sc_product { padding:0.75rem; }
+ /* Ohne Produktbild bliebe im Raster eine leere Spalte stehen, deshalb hier Flex */
+ .wpsg_smartcheckout .sc_product { display:flex; flex-wrap:wrap; align-items:center; gap:0.75rem 1rem; }
+ .wpsg_smartcheckout .sc_product_image { flex:0 0 auto; }
+ .wpsg_smartcheckout .sc_product_body { flex:1 1 60%; min-width:0; }
+ .wpsg_smartcheckout .sc_product_fields { flex:1 1 100%; }
+ .wpsg_smartcheckout .sc_product_amount { flex:0 0 auto; align-items:flex-start; }
+ .wpsg_smartcheckout .sc_product_price { flex:1 1 auto; }
+ .wpsg_smartcheckout .sc_field_half,
+ .wpsg_smartcheckout .sc_field_wide,
+ .wpsg_smartcheckout .sc_field_narrow { grid-column:span 12; }
+}
diff --git a/views/js/smartcheckout.js b/views/js/smartcheckout.js
new file mode 100644
index 0000000..df7721a
--- /dev/null
+++ b/views/js/smartcheckout.js
@@ -0,0 +1,694 @@
+/**
+ * Vue-Anwendung des modernen EinSeitenCheckouts
+ *
+ * Die Anwendung rechnet selbst nichts. Jede Änderung wird als Formulardaten an die
+ * Checkout-Seite geschickt, dort serverseitig neu berechnet und als vollständiger
+ * Zustand zurückgeliefert.
+ */
+
+(function() {
+
+ 'use strict';
+
+ var el_root = document.getElementById('wpsg_smartcheckout');
+ var el_state = document.getElementById('wpsg_smartcheckout_state');
+
+ if (el_root === null || el_state === null || typeof Vue === 'undefined') return;
+
+ var initialState = null;
+
+ try {
+
+ initialState = JSON.parse(el_state.textContent);
+
+ } catch (e) {
+
+ return;
+
+ }
+
+ Vue.createApp({
+
+ data: function() {
+
+ return {
+ state: initialState,
+ form: {},
+ amounts: {},
+ voucher: '',
+ voucherError: false,
+ agb: false,
+ garanPopup: false,
+ garanConfirmed: false,
+ garanSubmit: false,
+ garanLightbox: false,
+ busy: false,
+ queued: false,
+ queuedUpload: null,
+ submitting: false,
+ timer: null
+ };
+
+ },
+
+ created: function() {
+
+ this.syncForm(true);
+
+ // Bereits akzeptierte AGB, z.B. nach der Rückkehr von PayPal
+ this.agb = (this.state.accepted && this.state.accepted.agb === true);
+
+ },
+
+ mounted: function() {
+
+ // Nach einem abgelehnten Bestellversuch steht der Kunde vor dem ersten Fehler
+ if (this.state.errors.length !== 0) this.$nextTick(this.focusError);
+
+ // Das GARAN-Gewährleistungslabel erscheint wie in der klassischen Zusammenfassung beim Betreten der Seite
+ if (this.state.warranty && this.state.warranty.popup === true && this.state.empty !== true) this.garanPopup = true;
+
+ // Die Lightbox des Gewährleistungslabels lässt sich mit Esc schließen
+ var self = this;
+
+ document.addEventListener('keydown', function(event) {
+
+ if (event.key === 'Escape' && self.garanLightbox === true) self.garanLightbox = false;
+
+ });
+
+ // Zurück aus dem Seiten-Cache des Browsers (z.B. Zurück-Button nach dem Zahlungsanbieter): Layer wieder entfernen
+ window.addEventListener('pageshow', function(event) {
+
+ if (event.persisted) self.submitting = false;
+
+ });
+
+ // Eingaben in den Blöcken der Module haben kein Vue-Binding, Änderungen werden hier abgefangen
+ el_root.addEventListener('change', this.onModuleChange);
+
+ // Buttons in den Blöcken der Module, die eine Menge an den Server schicken (z.B. Warenkorbteaser)
+ el_root.addEventListener('click', this.onModuleClick);
+ el_root.addEventListener('keydown', this.onModuleKeydown);
+
+ },
+
+ methods: {
+
+ /**
+ * Übernimmt den Zustand des Servers in die Formularfelder
+ *
+ * Textfelder werden nur beim ersten Aufbau gesetzt — sonst würde eine Antwort,
+ * die während des Tippens eintrifft, die Eingabe des Kunden überschreiben.
+ */
+ syncForm: function(full) {
+
+ var self = this;
+
+ if (full === true) {
+
+ Object.keys(this.state.checkout).forEach(function(key) {
+
+ self.form[key] = self.state.checkout[key];
+
+ });
+
+ }
+
+ this.form.shipping = this.state.shipping.selected;
+ this.form.payment = this.state.payment.selected;
+
+ this.amounts = {};
+
+ this.state.products.forEach(function(product) {
+
+ self.amounts[product.index] = product.amount;
+
+ });
+
+ }, // syncForm(full)
+
+ /**
+ * Baut den Namen des Formularfeldes, benutzerdefinierte Felder liegen verschachtelt
+ */
+ fieldName: function(field) {
+
+ if (field.custom) return 'wpsg[checkout][custom][' + field.custom + ']';
+
+ return 'wpsg[checkout][' + field.name + ']';
+
+ }, // fieldName(field)
+
+ /**
+ * Hat der Server dieses Feld als fehlerhaft gemeldet?
+ */
+ hasError: function(name) {
+
+ return (this.state.errors.indexOf(name) >= 0);
+
+ }, // hasError(name)
+
+ /**
+ * Erhöht oder verringert die Menge einer Position
+ */
+ changeAmount: function(product, diff) {
+
+ var amount = parseInt(this.amounts[product.index], 10);
+
+ if (isNaN(amount)) amount = product.amount;
+
+ amount = amount + diff;
+
+ if (amount < 1) amount = 1;
+
+ this.amounts[product.index] = amount;
+
+ this.refresh();
+
+ }, // changeAmount(product, diff)
+
+ /**
+ * Schickt die Änderungen gesammelt an den Server
+ *
+ * Mehrere schnell aufeinander folgende Änderungen werden zu einer Anfrage
+ * zusammengefasst.
+ */
+ refresh: function() {
+
+ var self = this;
+
+ if (this.timer !== null) window.clearTimeout(this.timer);
+
+ this.timer = window.setTimeout(function() {
+
+ self.timer = null;
+ self.send('update');
+
+ }, 250);
+
+ }, // refresh()
+
+ /**
+ * Entfernt eine Position aus dem Warenkorb
+ */
+ removeProduct: function(product) {
+
+ this.send('remove', { wpsg_produkt: product.index });
+
+ }, // removeProduct(product)
+
+ /**
+ * Löst den eingegebenen Gutscheincode ein
+ */
+ addVoucher: function() {
+
+ if (this.voucher === '') return;
+
+ this.send('voucherAdd');
+
+ }, // addVoucher()
+
+ /**
+ * Nimmt einen eingelösten Gutschein wieder aus dem Warenkorb
+ */
+ removeVoucher: function(item) {
+
+ this.send('voucherRemove', { wpsg_voucher: item.id });
+
+ }, // removeVoucher(item)
+
+ /**
+ * Schickt das Formular an den Server und übernimmt den neuen Zustand
+ *
+ * arKeepFiles: Namen von Dateifeldern, deren Datei mitgeschickt wird (Upload direkt nach der Auswahl)
+ */
+ send: function(action, extra, arKeepFiles) {
+
+ var el_form = document.getElementById('wpsg_smartcheckout_form');
+
+ if (el_form === null) return;
+
+ // Dateien der direkt hochzuladenden Felder sofort sichern: der Block wird mit der nächsten Antwort neu aufgebaut
+ var arUpload = [];
+
+ if (Array.isArray(arKeepFiles)) {
+
+ arKeepFiles.forEach(function(name) {
+
+ el_form.querySelectorAll('input[type="file"]').forEach(function(el) {
+
+ if (el.name !== name || !el.files) return;
+
+ for (var i = 0; i < el.files.length; i++) arUpload.push({ name: name, file: el.files[i] });
+
+ });
+
+ });
+
+ }
+
+ // Es läuft bereits eine Anfrage, die Änderung wird danach nachgereicht (ein Upload samt seiner Dateien)
+ if (this.busy) {
+
+ if (arUpload.length > 0) this.queuedUpload = { action: action, extra: extra, files: arUpload };
+ else this.queued = true;
+
+ return;
+
+ }
+
+ this.sendData(el_form, action, extra, arUpload);
+
+ }, // send(action, extra, arKeepFiles)
+
+ /**
+ * Baut die Formulardaten, schickt sie ab und übernimmt den neuen Zustand
+ *
+ * arUpload: [{ name, file }] Dateien, die mit dieser Anfrage hochgeladen werden
+ */
+ sendData: function(el_form, action, extra, arUpload) {
+
+ var self = this;
+
+ var modules = this.captureModules();
+
+ var data = new FormData(el_form);
+
+ // Dateien werden erst beim Abschluss übertragen, nicht bei jeder Aktualisierung, außer sie sollen direkt hochgeladen werden
+ el_form.querySelectorAll('input[type="file"][name]').forEach(function(el) {
+
+ data.delete(el.name);
+
+ });
+
+ (arUpload || []).forEach(function(item) {
+
+ data.append(item.name, item.file, item.file.name);
+
+ });
+
+ data.append('wpsg_smartcheckout_ajax', action);
+
+ if (extra) {
+
+ Object.keys(extra).forEach(function(key) {
+
+ data.append(key, extra[key]);
+
+ });
+
+ }
+
+ this.busy = true;
+
+ window.fetch(el_form.getAttribute('action'), {
+ method: 'POST',
+ body: data,
+ credentials: 'same-origin',
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }
+ })
+ .then(function(response) { return response.json(); })
+ .then(function(result) {
+
+ self.busy = false;
+
+ if (result.reload === true) {
+
+ window.location.reload();
+
+ return;
+
+ }
+
+ self.voucherError = (result.voucherError === true);
+
+ if (result.ok === true && action === 'voucherAdd') self.voucher = '';
+
+ if (result.state) {
+
+ var accepted = self.state.accepted;
+
+ self.state = result.state;
+ self.syncForm(false);
+ self.$nextTick(function() {
+
+ self.restoreModules(modules);
+ self.revokeAccepted(accepted);
+
+ });
+
+ }
+
+ if (self.queuedUpload !== null) {
+
+ var upload = self.queuedUpload;
+
+ self.queuedUpload = null;
+ self.queued = false;
+ self.sendData(el_form, upload.action, upload.extra, upload.files);
+
+ } else if (self.queued) {
+
+ self.queued = false;
+ self.refresh();
+
+ }
+
+ })
+ .catch(function() {
+
+ self.busy = false;
+ self.queued = false;
+ self.queuedUpload = null;
+
+ });
+
+ }, // sendData(el_form, action, extra, arUpload)
+
+ /**
+ * Sichert die Eingaben in den servergerenderten Modulblöcken
+ *
+ * Diese Blöcke werden vom Server neu geliefert, sobald sich der Warenkorb
+ * ändert — ein angekreuztes Kästchen wäre sonst nach jedem Versandartwechsel
+ * wieder leer.
+ */
+ captureModules: function() {
+
+ var data = {};
+
+ document.querySelectorAll('#wpsg_smartcheckout .sc_modul_dynamic [name]').forEach(function(el) {
+
+ // Dateifelder lassen sich nicht per Script setzen, ein Zuweisen von value würde eine Exception werfen
+ if (el.type === 'file') return;
+
+ if (el.type === 'checkbox' || el.type === 'radio') data[el.name + '\n' + el.value] = el.checked;
+ else data[el.name] = el.value;
+
+ });
+
+ return data;
+
+ }, // captureModules()
+
+ /**
+ * Stellt die gesicherten Eingaben wieder her, soweit die Felder noch existieren
+ */
+ restoreModules: function(data) {
+
+ document.querySelectorAll('#wpsg_smartcheckout .sc_modul_dynamic [name]').forEach(function(el) {
+
+ if (el.type === 'file') return;
+
+ if (el.type === 'checkbox' || el.type === 'radio') {
+
+ var key = el.name + '\n' + el.value;
+
+ if (data.hasOwnProperty(key)) el.checked = data[key];
+
+ } else if (data.hasOwnProperty(el.name)) {
+
+ el.value = data[el.name];
+
+ }
+
+ });
+
+ }, // restoreModules(data)
+
+ /**
+ * Entfernt die Haken bei AGB und Bestellbedingungen, deren Akzeptanz der Server zurückgenommen hat
+ *
+ * Das passiert, wenn sich die Bestellung nach der Rückkehr von einem Zahlungsanbieter ändert.
+ */
+ revokeAccepted: function(before) {
+
+ var after = this.state.accepted || { agb: false, ordercondition: [] };
+
+ if (!before) return;
+
+ if (before.agb === true && after.agb !== true) this.agb = false;
+
+ (before.ordercondition || []).forEach(function(oc_id) {
+
+ if (after.ordercondition.indexOf(oc_id) >= 0) return;
+
+ var el = document.querySelector('#wpsg_smartcheckout .sc_modul_dynamic input[name="wpsg_mod_ordercondition[' + oc_id + ']"]');
+
+ if (el !== null) el.checked = false;
+
+ });
+
+ }, // revokeAccepted(before)
+
+ /**
+ * Springt zum ersten beanstandeten Feld
+ */
+ focusError: function() {
+
+ var el_first = document.querySelector('#wpsg_smartcheckout .sc_invalid');
+
+ if (el_first === null) return;
+
+ el_first.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ var el_input = el_first.querySelector('input, select, textarea');
+
+ if (el_input !== null) el_input.focus();
+
+ }, // focusError()
+
+ /**
+ * Blockt das Absenden, solange der Server noch rechnet
+ */
+ /**
+ * Berechnet neu, wenn sich eine Eingabe in einem Block der Module ändert (z.B. Lieferadresse, Produktvariablen)
+ *
+ * Dateifelder laden über eigene Wege hoch, Zustimmungen ändern nichts an der Berechnung.
+ */
+ onModuleChange: function(event) {
+
+ var el = event.target;
+
+ if (!el || typeof el.closest !== 'function') return;
+
+ // Uploads starten direkt nach der Auswahl über den Button des Moduls (z.B. Upload zum Produkt)
+ if (el.type === 'file') {
+
+ this.startUpload(el);
+
+ return;
+
+ }
+
+ // Felder ohne Namen werden nicht übertragen, eine Aktualisierung brächte nichts
+ if (!el.name) return;
+
+ if (el.closest('.sc_modul_dynamic, .sc_modul_extra, .sc_modul_customer') === null) return;
+ if (el.closest('.sc_modul_conditions') !== null) return;
+
+ this.refresh();
+
+ }, // onModuleChange(event)
+
+ /**
+ * Löst den Upload-Button des Moduls aus, sobald im Dateifeld daneben eine Datei gewählt wurde
+ *
+ * Der Button trägt data-sc-autoupload und wird im SmartCheckout ausgeblendet. Hochgeladen wird über
+ * den bestehenden Weg des Moduls, der auch das Ziel mit der Dateiliste neu befüllt. Trägt das Dateifeld
+ * selbst data-sc-upload, geht die Datei mit einer Aktualisierung des SmartCheckouts an den Server.
+ */
+ startUpload: function(el_file) {
+
+ if (!el_file.files || el_file.files.length === 0 || el_file.parentElement === null) return;
+
+ // Dateifeld, das der SmartCheckout selbst an den Server schickt (z.B. Bestellupload)
+ if (el_file.hasAttribute('data-sc-upload') && el_file.name) {
+
+ // Rückmeldung wie beim Upload zum Produkt, der Block wird mit der Antwort neu aufgebaut
+ if (el_file.hasAttribute('data-sc-upload-wait') && el_file.parentElement.querySelector('.sc_upload_wait') === null) {
+
+ var el_wait = document.createElement('div');
+
+ el_wait.className = 'sc_upload_wait';
+ el_wait.textContent = el_file.getAttribute('data-sc-upload-wait') + '…';
+
+ el_file.insertAdjacentElement('afterend', el_wait);
+
+ }
+
+ if (this.timer !== null) {
+
+ window.clearTimeout(this.timer);
+ this.timer = null;
+
+ }
+
+ this.send('update', null, [el_file.name]);
+
+ return;
+
+ }
+
+ var el_button = el_file.parentElement.querySelector('[data-sc-autoupload]');
+
+ if (el_button !== null) el_button.click();
+
+ }, // startUpload(el_file)
+
+ /**
+ * Schickt die Menge eines Buttons mit data-sc-send unter dem dort angegebenen Namen an den Server
+ *
+ * Die Menge steht im Feld mit data-sc-amount innerhalb desselben data-sc-item, ein fester Wert in
+ * data-sc-value. Mit data-sc-confirm fragt der Button vorher nach.
+ */
+ onModuleClick: function(event) {
+
+ var el = event.target;
+
+ if (!el || typeof el.closest !== 'function') return;
+
+ var el_button = el.closest('.sc_modul_dynamic [data-sc-send]');
+
+ if (el_button === null) return;
+
+ event.preventDefault();
+
+ if (this.busy) return;
+
+ // Rückfrage des Moduls, z.B. vor dem Entfernen eines Dokuments
+ if (el_button.hasAttribute('data-sc-confirm') && !window.confirm(el_button.getAttribute('data-sc-confirm'))) return;
+
+ var value;
+
+ if (el_button.hasAttribute('data-sc-value')) {
+
+ // Fester Wert des Buttons, z.B. der Index eines Dokuments
+ value = el_button.getAttribute('data-sc-value');
+
+ } else {
+
+ var el_item = el_button.closest('[data-sc-item]');
+ var el_amount = ((el_item !== null)?el_item.querySelector('[data-sc-amount]'):null);
+
+ value = ((el_amount !== null)?parseInt(el_amount.value, 10):1);
+
+ if (isNaN(value) || value < 1) value = 1;
+
+ }
+
+ var extra = {};
+ extra[el_button.getAttribute('data-sc-send')] = value;
+
+ // Eine noch ausstehende Änderung geht in derselben Anfrage mit
+ if (this.timer !== null) {
+
+ window.clearTimeout(this.timer);
+ this.timer = null;
+
+ }
+
+ this.send('update', extra);
+
+ }, // onModuleClick(event)
+
+ /**
+ * Enter im Mengenfeld eines Modulblocks löst dessen Button aus, statt die Bestellung abzuschicken
+ */
+ onModuleKeydown: function(event) {
+
+ if (event.key !== 'Enter') return;
+
+ var el = event.target;
+
+ if (!el || typeof el.closest !== 'function' || !el.hasAttribute('data-sc-amount')) return;
+
+ event.preventDefault();
+
+ var el_item = el.closest('[data-sc-item]');
+ var el_button = ((el_item !== null)?el_item.querySelector('[data-sc-send]'):null);
+
+ if (el_button !== null) el_button.click();
+
+ }, // onModuleKeydown(event)
+
+ /**
+ * Blockt das Absenden, solange der Server noch rechnet oder eine Änderung noch nicht übertragen wurde
+ */
+ onSubmit: function(event) {
+
+ // Andere Buttons im Formular (z.B. Login der Kundenverwaltung) werden normal abgeschickt
+ if (event.submitter && event.submitter.name !== 'wpsg_smartcheckout_order') return;
+
+ // Die Bestellung ist bereits unterwegs, ein weiterer Klick darf sie nicht ein zweites Mal abschicken
+ if (this.submitting) {
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (this.timer !== null) {
+
+ // Ausstehende Änderung sofort übertragen, der Kunde sieht danach die neue Summe
+ event.preventDefault();
+
+ window.clearTimeout(this.timer);
+ this.timer = null;
+ this.send('update');
+
+ return;
+
+ }
+
+ if (this.busy) {
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ // Das GARAN-Gewährleistungslabel muss vor dem Bestellabschluss zur Kenntnis genommen werden
+ if (this.state.warranty && this.state.warranty.popup === true && this.garanConfirmed !== true) {
+
+ event.preventDefault();
+
+ this.garanSubmit = true;
+ this.garanPopup = true;
+
+ return;
+
+ }
+
+ // Ab hier wird tatsächlich bestellt: Layer über die Seite, bis der Browser die nächste Seite lädt
+ this.submitting = true;
+
+ }, // onSubmit(event)
+
+ /**
+ * Schließt das GARAN-Popup, kam es über den Bestellbutton, wird die Bestellung danach abgeschickt
+ */
+ confirmGaran: function() {
+
+ this.garanConfirmed = true;
+ this.garanPopup = false;
+
+ if (this.garanSubmit !== true) return;
+
+ this.garanSubmit = false;
+
+ var el_form = document.getElementById('wpsg_smartcheckout_form');
+ var el_button = el_form.querySelector('button[name="wpsg_smartcheckout_order"]');
+
+ // requestSubmit() übermittelt den Bestellbutton mit und prüft die Pflichtfelder erneut
+ if (typeof el_form.requestSubmit === 'function' && el_button !== null) el_form.requestSubmit(el_button);
+ else if (el_button !== null) el_button.click();
+
+ } // confirmGaran()
+
+ }
+
+ }).mount('#wpsg_smartcheckout');
+
+})();
diff --git a/views/mods/mod_basketteaser/smartcheckout.phtml b/views/mods/mod_basketteaser/smartcheckout.phtml
new file mode 100644
index 0000000..712098c
--- /dev/null
+++ b/views/mods/mod_basketteaser/smartcheckout.phtml
@@ -0,0 +1,44 @@
+<?php
+
+ /**
+ * Template für die Warenkorbteaserprodukte im SmartCheckout
+ *
+ * Das Mengenfeld hat bewusst keinen Namen: es wird so bei den Aktualisierungen des
+ * Checkouts nicht mitgeschickt. Erst der Button überträgt die Menge unter
+ * wpsg_mod_basketteaser_row[ID] (siehe onModuleClick in smartcheckout.js).
+ */
+
+?>
+<div class="sc_teaser">
+
+ <div class="sc_teaser_title"><?php echo esc_html__('Das könnte Sie auch interessieren', 'wpsg'); ?></div>
+
+ <?php foreach ($this->view['wpsg_mod_basketteaser']['products'] as $p) { ?>
+ <div class="sc_teaser_item" data-sc-item>
+
+ <?php if ($p['image'] !== '') { ?>
+ <div class="sc_teaser_image">
+ <img src="<?php echo esc_url($p['image']); ?>" alt="<?php echo esc_attr($p['name']); ?>" loading="lazy" />
+ </div>
+ <?php } ?>
+
+ <div class="sc_teaser_body">
+ <div class="sc_teaser_name">
+ <?php if ($p['link'] !== '') { ?>
+ <a href="<?php echo esc_url($p['link']); ?>"><?php echo esc_html($p['name']); ?></a>
+ <?php } else { ?>
+ <?php echo esc_html($p['name']); ?>
+ <?php } ?>
+ </div>
+ <div class="sc_teaser_price"><?php echo esc_html($p['price']); ?></div>
+ </div>
+
+ <div class="sc_teaser_action">
+ <input type="text" inputmode="numeric" class="sc_teaser_amount" value="1" data-sc-amount aria-label="<?php echo esc_attr__('Menge', 'wpsg'); ?>" />
+ <button type="button" class="sc_button sc_button_secondary" data-sc-send="wpsg_mod_basketteaser_row[<?php echo intval($p['id']); ?>]"><?php echo esc_html__('Hinzufügen', 'wpsg'); ?></button>
+ </div>
+
+ </div>
+ <?php } ?>
+
+</div>
diff --git a/views/mods/mod_orderupload/smartcheckout_checkout.phtml b/views/mods/mod_orderupload/smartcheckout_checkout.phtml
new file mode 100644
index 0000000..7da51cb
--- /dev/null
+++ b/views/mods/mod_orderupload/smartcheckout_checkout.phtml
@@ -0,0 +1,77 @@
+<?php
+
+ /**
+ * Template für den Bestellupload im SmartCheckout
+ *
+ * Aufbau und Verhalten wie beim Upload zum Produkt (smartcheckout_row.phtml): Dateiliste mit Größe und
+ * Löschen, darunter die Dateiauswahl. Die Datei wird direkt nach der Auswahl hochgeladen (data-sc-upload,
+ * siehe smartcheckout.js), entfernt wird über data-sc-send, beides landet in
+ * wpsg_mod_orderupload::smartcheckout_ajax(). Der Block wird bei jeder Antwort neu geliefert.
+ */
+
+ $arUploads = (array)($_SESSION['wpsg']['wpsg_mod_orderupload'] ?? []);
+ $uploadsum = floatval($this->view['wpsg_mod_orderupload']['uploadsum'] ?? 0);
+ $maxweight = (string)($this->view['wpsg_mod_orderupload']['maxweight'] ?? '');
+ $bRequired = ($this->get_option('wpsg_mod_orderupload_optional') != '1');
+ $bMulti = ($this->get_option('wpsg_mod_orderupload_multifiles') == '1');
+ $arMessages = (array)($this->view['wpsg_mod_orderupload']['messages'] ?? []);
+ $arAllowed = (array)($this->view['wpsg_mod_orderupload']['allowedext'] ?? []);
+ $strAccept = implode(',', array_map(function($ext) { return '.'.$ext; }, $arAllowed));
+
+?>
+<div class="sc_orderupload">
+
+ <h2 class="sc_title"><?php echo esc_html__('Dateiupload', 'wpsg'); ?><?php if ($bRequired) { ?><span class="sc_required" aria-hidden="true">*</span><?php } ?></h2>
+
+ <div class="sc_upload">
+
+ <div class="sc_upload_label"><?php echo esc_html__('Upload zur Bestellung', 'wpsg'); ?></div>
+
+ <div class="sc_upload_target">
+
+ <?php /* Meldungen des letzten Uploads bzw. Entfernens, wie beim Upload zum Produkt direkt am Block */ ?>
+ <?php foreach ($arMessages as $message) { ?>
+ <div class="sc_upload_message sc_upload_message_<?php echo esc_attr($message['type']); ?>"><?php echo wp_kses_post($message['text']); ?></div>
+ <?php } ?>
+
+ <?php if (wpsg_isSizedArray($arUploads)) { ?>
+ <ul>
+ <?php foreach ($arUploads as $ou_index => $ou) { ?>
+ <li>
+ <a href="<?php echo esc_url($ou[2]); ?>" target="_blank" rel="noopener"><?php echo wpsg_hspc($ou[0]); ?> <span class="size">(<?php echo esc_html(wpsg_formatSize($ou[3])); ?>)</span></a>
+ <a
+ href="#"
+ class="removeLink"
+ role="button"
+ data-sc-send="wpsg_mod_orderupload_remove"
+ data-sc-value="<?php echo intval($ou_index); ?>"
+ data-sc-confirm="<?php echo esc_attr__('Sind Sie sich sicher, dass Sie dieses Dokument aus der Bestellung entfernen möchten?', 'wpsg'); ?>"
+ title="<?php echo esc_attr__('Upload entfernen.', 'wpsg'); ?>"
+ ><img src="<?php echo esc_url(WPSG_URL.'views/gfx/bin_x.png'); ?>" alt="<?php echo esc_attr__('Löschen', 'wpsg'); ?>" /></a>
+ </li>
+ <?php } ?>
+ </ul>
+ <?php } ?>
+
+ <?php if ($uploadsum > 0) { ?>
+ <input
+ type="file"
+ name="wpsg_mod_orderupload_file<?php echo (($bMulti)?'[]':''); ?>"
+ data-sc-upload="1"
+ data-sc-upload-wait="<?php echo esc_attr__('Bitte warten ', 'wpsg'); ?>"
+ <?php if ($strAccept !== '') { ?>accept="<?php echo esc_attr($strAccept); ?>"<?php } ?>
+ <?php echo (($bMulti)?'multiple="multiple"':''); ?>
+ aria-label="<?php echo esc_attr__('Upload zur Bestellung', 'wpsg'); ?>"
+ />
+ <?php } ?>
+
+ </div>
+
+ <div class="sc_hint"><?php echo esc_html(wpsg_translate(__('Es stehen Ihnen #1# Mb von #2# Mb für den Upload zur Verfügung', 'wpsg'), $uploadsum, $maxweight)); ?></div>
+ <?php if (wpsg_isSizedArray($arAllowed)) { ?>
+ <div class="sc_hint"><?php echo esc_html(wpsg_translate(__('Erlaubte Dateiendungen: #1#', 'wpsg'), implode(', ', $arAllowed))); ?></div>
+ <?php } ?>
+
+ </div>
+
+</div>
diff --git a/views/mods/mod_orderupload/smartcheckout_row.phtml b/views/mods/mod_orderupload/smartcheckout_row.phtml
new file mode 100644
index 0000000..66986f9
--- /dev/null
+++ b/views/mods/mod_orderupload/smartcheckout_row.phtml
@@ -0,0 +1,21 @@
+<?php
+
+ /**
+ * Template für den Upload zum Produkt an einer Warenkorbposition im SmartCheckout
+ *
+ * Das Ziel trägt dieselbe ID wie im klassischen Warenkorb, die Scripte aus basket_after.phtml
+ * ersetzen seinen Inhalt nach dem Hochladen bzw. Löschen.
+ */
+
+ $product_index = $this->view['wpsg_mod_orderupload']['product_index'];
+
+?>
+<div class="sc_upload">
+
+ <div class="sc_upload_label"><?php echo esc_html__('Upload zum Produkt', 'wpsg'); ?></div>
+
+ <div id="wpsg_mod_orderupload_<?php echo intval($product_index); ?>_target" class="wpsg_mod_orderupload_target sc_upload_target">
+ <?php echo $this->view['wpsg_mod_orderupload']['strHTMLTarget']; ?>
+ </div>
+
+</div>
diff --git a/views/mods/mod_paypalapi/overview_preauth_button.phtml b/views/mods/mod_paypalapi/overview_preauth_button.phtml
new file mode 100644
index 0000000..9fd775a
--- /dev/null
+++ b/views/mods/mod_paypalapi/overview_preauth_button.phtml
@@ -0,0 +1,15 @@
+<?php
+
+ /**
+ * Ersetzt in der Bestellübersicht den Button "zahlungspflichtig bestellen", solange die Zahlung noch nicht bei PayPal autorisiert wurde
+ */
+
+?>
+
+<?php if (($this->view['wpsg_mod_paypalapi']['preauth_changed']??false) === true) { ?>
+<div class="wpsg_mod_paypalapi_preauth_hint">
+ <p><?php echo esc_html__('Ihre Bestellung hat sich seit der Autorisierung geändert. Bitte autorisieren Sie die Zahlung erneut bei PayPal.', 'wpsg'); ?></p>
+</div>
+<?php } ?>
+
+<input type="submit" class="wpsg_orderbutton wpsg_mod_paypalapi_preauth_button" value="<?php echo esc_attr__('Mit PayPal bezahlen', 'wpsg'); ?>" name="wpsg_order" />
diff --git a/views/mods/mod_productvars/smartcheckout_row.phtml b/views/mods/mod_productvars/smartcheckout_row.phtml
new file mode 100644
index 0000000..b5845b5
--- /dev/null
+++ b/views/mods/mod_productvars/smartcheckout_row.phtml
@@ -0,0 +1,57 @@
+<?php
+
+ /**
+ * Template für die Produktvariablen an einer Warenkorbposition im SmartCheckout
+ *
+ * Die Feldnamen entsprechen denen aus basket_row.phtml, gespeichert wird über
+ * wpsg_mod_productvars::basket_preUpdate(). Pflichtfelder tragen required, der leere
+ * Platzhalter der Auswahl wird serverseitig zu not_set.
+ */
+
+ $p = $this->view['wpsg_mod_productvars']['p'];
+ $product_index = $p['product_index'];
+ $arError = (array)$this->view['error'];
+
+?>
+<div class="sc_pvars">
+
+ <?php foreach ($this->view['wpsg_mod_productvars']['data'] as $pv) { ?>
+ <?php
+
+ $field_name = 'wpsg_mod_productvars['.$pv['id'].']['.$product_index.']';
+ $field_id = 'wpsg_sc_pvar_'.$pv['id'].'_'.$product_index;
+ $value = wpsg_getStr($p['wpsg_mod_productvars'][$pv['id']]);
+ $bError = in_array('wpsg_mod_productvars_'.$pv['id'].'_'.$product_index, $arError);
+ $bRequired = ($pv['pflicht'] == '1');
+ $strRequired = (($bRequired)?'<span class="sc_required" aria-hidden="true">*</span>':'');
+
+ ?>
+ <div class="sc_pvar sc_pvar_typ<?php echo intval($pv['typ']); ?><?php echo (($bError)?' sc_invalid':''); ?>">
+
+ <?php if ($pv['typ'] == '3') { // Checkbox ?>
+ <label class="sc_pvar_check">
+ <input type="hidden" value="0" name="<?php echo esc_attr($field_name); ?>" />
+ <input type="checkbox" value="1" name="<?php echo esc_attr($field_name); ?>" <?php echo (($value === '1')?'checked="checked"':''); ?><?php echo (($bRequired)?' required':''); ?> />
+ <span><?php echo wpsg_hspc(__($pv['name'], 'wpsg')); ?><?php echo $strRequired; ?></span>
+ </label>
+ <?php } else { ?>
+ <label class="sc_pvar_label" for="<?php echo esc_attr($field_id); ?>"><?php echo wpsg_hspc(__($pv['name'], 'wpsg')); ?><?php echo $strRequired; ?></label>
+
+ <?php if ($pv['typ'] == '1') { // Auswahlfeld ?>
+ <select class="sc_pvar_input" id="<?php echo esc_attr($field_id); ?>" name="<?php echo esc_attr($field_name); ?>"<?php echo (($bRequired)?' required':''); ?>>
+ <option value=""><?php echo __('Bitte wählen', 'wpsg'); ?></option>
+ <?php foreach ((array)$pv['auswahl'] as $option) { ?>
+ <option <?php echo ((wpsg_hspc($value) === wpsg_hspc($option))?'selected="selected"':''); ?> value="<?php echo wpsg_hspc($option); ?>"><?php echo wpsg_hspc($option); ?></option>
+ <?php } ?>
+ </select>
+ <?php } else if ($pv['typ'] == '4') { // Textfeld mehrzeilig ?>
+ <textarea class="sc_pvar_input" id="<?php echo esc_attr($field_id); ?>" name="<?php echo esc_attr($field_name); ?>" rows="3"<?php echo (($bRequired)?' required':''); ?>><?php echo wpsg_hspc($value); ?></textarea>
+ <?php } else { // Textfeld ?>
+ <input class="sc_pvar_input" type="text" id="<?php echo esc_attr($field_id); ?>" name="<?php echo esc_attr($field_name); ?>" value="<?php echo wpsg_hspc($value); ?>"<?php echo (($bRequired)?' required':''); ?> />
+ <?php } ?>
+ <?php } ?>
+
+ </div>
+ <?php } ?>
+
+</div>