Webshop A3: Register-Eintrag shop, Haken, Rechtstext-Satz und Ticket-Details
Registereintrag config.features.shop (Bestelltexte mit Fassung config.shop.fassung, Rechtsunterlagen-Getter, Mailtexte, Ticket), STANDARD_HAKEN.shop (Schalter, Bestellpruefung ueber shop/anfrage.js, Unterlagen, Belehrung), legalDocs-Satz shop, shopTicketDetails sowie Rumpf von anfrageOffenePunkte. Tests angepasst und um Shop-Faelle erweitert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6782d99960
commit
0e74f1669c
12 files changed
+594
-23
No files matched your search
+131
-1
@@ -233,7 +233,85 @@ function termineConsent() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
|
||||
* Rechtsunterlage des Webshops: wie termineRechtsunterlage, aber mit Fassung und Adresse aus config.shop.fassung (bei jedem
|
||||
* Zugriff gelesen). Slugs wie in services/legalDocs.js (SAETZE.shop); test/legalDocs.test.js gleicht beide ab.
|
||||
*/
|
||||
function shopRechtsunterlage(slug, { nullIstWert = false } = {}) {
|
||||
let ueberschrieben // undefined = Standard
|
||||
const standard = () => ({ version: config.shop.fassung, url: `/rechtliches/${slug}-${config.shop.fassung}.html` })
|
||||
return {
|
||||
get: () => (ueberschrieben === undefined ? standard() : ueberschrieben),
|
||||
set(wert) {
|
||||
const std = standard()
|
||||
const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url)
|
||||
ueberschrieben = zurueck ? undefined : wert
|
||||
},
|
||||
}
|
||||
}
|
||||
const shopLb = shopRechtsunterlage('leistungsbeschreibung-webshop')
|
||||
const shopAvv = shopRechtsunterlage('avv-webshop')
|
||||
const shopWiderruf = shopRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true })
|
||||
|
||||
const SHOP_PREIS = '299 €'
|
||||
const SHOP_DAUER = 'meist innerhalb von 2–3 Wochen'
|
||||
|
||||
/**
|
||||
* Bestelltexte des Webshops: fuenf Haken (alle Besteller), ohne Adressen im Text (die versionierten Links zeigt die
|
||||
* Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.shop.fassung; die dynamischen Teile
|
||||
* (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. Aufbau wie
|
||||
* termineConsent (Haken je Fassung einmal gebaut; Tests duerfen einzelne Texte aendern und zurueckstellen). Bei Textaenderung
|
||||
* neues Fassungsdatum und neuen Hash im Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und
|
||||
* die Pflichtinformationen sind Entwurf bis zur Kanzlei.
|
||||
*/
|
||||
function shopConsent() {
|
||||
let versionGesetzt // undefined = config.shop.fassung
|
||||
let gebaut = null // { version, items }
|
||||
const version = () => versionGesetzt ?? config.shop.fassung
|
||||
const items = (v) => [
|
||||
{
|
||||
id: 'unterlagen',
|
||||
haken: true,
|
||||
text: `Ich stimme der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop in der Fassung ${v} zu.`,
|
||||
},
|
||||
{
|
||||
id: 'verantwortung',
|
||||
haken: true,
|
||||
text: 'Ich verkaufe als Unternehmer in eigenem Namen. Für meine Produkte, Preise, Produktangaben und Rechtstexte (AGB, Widerrufsbelehrung, Impressum, Datenschutzerklärung) bin ich selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Ich verkaufe keine der in der Leistungsbeschreibung ausgeschlossenen Waren.',
|
||||
},
|
||||
{
|
||||
id: 'stripe',
|
||||
haken: true,
|
||||
text: 'Die Zahlungen meiner Käufer laufen über mein eigenes Stripe-Konto zu den Bedingungen von Stripe. WEBklar erhält und verwahrt kein Geld meiner Käufer und erhebt keine Umsatzbeteiligung.',
|
||||
},
|
||||
{
|
||||
id: 'beginn',
|
||||
haken: true,
|
||||
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Webshops beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
|
||||
},
|
||||
{
|
||||
id: 'information',
|
||||
haken: true,
|
||||
text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Webshops für diese Website: Einbau des Shop-Widgets, Verwaltung im Portal, Muster-Rechtstexte), Gesamtpreis ${SHOP_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${SHOP_DAUER}, keine laufende Gebühr von WEBklar; der Webshop läuft, solange WEBklar die Website betreibt. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`,
|
||||
},
|
||||
]
|
||||
return {
|
||||
get version() {
|
||||
return version()
|
||||
},
|
||||
set version(wert) {
|
||||
versionGesetzt = wert === undefined || wert === null || wert === config.shop.fassung ? undefined : wert
|
||||
},
|
||||
knopf: `Zahlungspflichtig bestellen · ${SHOP_PREIS}`,
|
||||
get items() {
|
||||
const v = version()
|
||||
if (gebaut?.version !== v) gebaut = { version: v, items: items(v) }
|
||||
return gebaut.items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner, Webshop): Preis, Zustimmungen, Rechtsunterlagen,
|
||||
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
|
||||
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
|
||||
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
|
||||
@@ -360,6 +438,58 @@ const features = {
|
||||
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
|
||||
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
|
||||
},
|
||||
shop: {
|
||||
key: 'shop',
|
||||
product: 'shopSetup',
|
||||
label: { name: 'Webshop', kurz: 'Shop', keine: 'keinen Webshop' },
|
||||
priceLabel: SHOP_PREIS,
|
||||
setupEstimate: SHOP_DAUER,
|
||||
consent: shopConsent(),
|
||||
// Fassung und Adresse folgen config.shop.fassung bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js
|
||||
// importiert keine Dienste, test/legalDocs.test.js gleicht beide ab)
|
||||
get leistungsbeschreibung() {
|
||||
return shopLb.get()
|
||||
},
|
||||
set leistungsbeschreibung(wert) {
|
||||
shopLb.set(wert)
|
||||
},
|
||||
get avv() {
|
||||
return shopAvv.get()
|
||||
},
|
||||
set avv(wert) {
|
||||
shopAvv.set(wert)
|
||||
},
|
||||
get widerruf() {
|
||||
return shopWiderruf.get()
|
||||
},
|
||||
set widerruf(wert) {
|
||||
shopWiderruf.set(wert)
|
||||
},
|
||||
returnHash: 'webshop',
|
||||
returnProjekt: true,
|
||||
ticket: {
|
||||
topic: (project) => `Webshop einbauen – ${ticketProjektName(project)}`,
|
||||
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
|
||||
details: async (ctx) => {
|
||||
const { shopTicketDetails } = await import('./services/shop/ticketDetails.js')
|
||||
return shopTicketDetails(ctx)
|
||||
},
|
||||
},
|
||||
mails: {
|
||||
funktion: 'Webshop',
|
||||
formen: { dein: 'Dein Webshop', nom: 'der Webshop', akk: 'den Webshop', gen: 'des Webshops', pron: 'ihn' },
|
||||
knopf: 'Webshop öffnen',
|
||||
danach: 'Danach kannst Du im Portal Artikel anlegen, Dein Stripe-Konto verbinden und den Shop öffnen.',
|
||||
bereit: 'Dein Webshop ist eingebaut. Sobald Stripe-Konto, Artikel und Rechtstexte fertig sind, kannst Du ihn im Portal öffnen.',
|
||||
preisHinweis: `einmalig, ${ustHinweis}; keine laufende Gebühr von WEBklar, Stripe-Gebühren zahlst Du direkt an Stripe`,
|
||||
anleitung: false,
|
||||
},
|
||||
requiresWartungsAbo: false,
|
||||
ticketFreischaltung: true,
|
||||
disabledMessage: 'Der Webshop ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
|
||||
orderedConflictMessage: 'Der Webshop ist für dieses Projekt schon bestellt.',
|
||||
unavailableMessage: 'Der Webshop ist noch nicht verfügbar.',
|
||||
},
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { config } from '../config.js'
|
||||
import { termineFuerKunde } from './termine/schalter.js'
|
||||
import { shopFuerKunde } from './shop/schalter.js'
|
||||
|
||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
|
||||
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
|
||||
// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import()
|
||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner, Webshop; Daten in server/config.js).
|
||||
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahmen:
|
||||
// termine/schalter.js und shop/schalter.js, die nur config lesen): Standard-Haken laden ihre Dienste per dynamischem import()
|
||||
// innerhalb der Funktion (sonst entstuenden Importzyklen).
|
||||
|
||||
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
||||
@@ -58,6 +59,21 @@ async function termineBelehrung() {
|
||||
return widerrufsbelehrungFunktion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestellpruefung des Webshops im Checkout: die Anfrage des Projekts muss vom Team angenommen sein (shop/anfrage.js,
|
||||
* Block B). Geladen werden die Dienste erst beim Aufruf.
|
||||
*/
|
||||
async function shopBestellpruefung({ customerId, projectId }) {
|
||||
const { anfrageOffenePunkte } = await import('./shop/anfrage.js')
|
||||
return anfrageOffenePunkte({ customerId, projectId })
|
||||
}
|
||||
|
||||
/** Vertragsunterlagen des Webshops abrufbar? (Stufe 5b des Checkouts; legalDocs.featureLegalReady, fail-closed, wirft nie.) */
|
||||
async function shopUnterlagenBereit() {
|
||||
const { featureLegalReady } = await import('./legalDocs.js')
|
||||
return featureLegalReady('shop')
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||||
* (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||
@@ -73,6 +89,13 @@ const STANDARD_HAKEN = {
|
||||
belehrung: termineBelehrung,
|
||||
freischaltbar: termineFreischaltbar,
|
||||
},
|
||||
// Webshop: kein freischaltbar (ein geschlossenes Ticket genuegt); Belehrung aus derselben Quelle wie beim Terminplaner
|
||||
shop: {
|
||||
verfuegbar: (email) => shopFuerKunde(email),
|
||||
bestellpruefung: shopBestellpruefung,
|
||||
unterlagenBereit: shopUnterlagenBereit,
|
||||
belehrung: termineBelehrung,
|
||||
},
|
||||
}
|
||||
|
||||
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/):
|
||||
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und
|
||||
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum.
|
||||
// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse), 'termine' und 'shop' (Leistungsbeschreibung, AVV und
|
||||
// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners bzw. Webshops). analyseLegal.js ist ein duenner Wrapper darum.
|
||||
// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben.
|
||||
import { access, open, readFile, stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -32,13 +32,22 @@ const SAETZE = {
|
||||
avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' },
|
||||
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
||||
},
|
||||
shop: {
|
||||
lb: { slug: 'leistungsbeschreibung-webshop', pdf: 'Leistungsbeschreibung-Webshop' },
|
||||
avv: { slug: 'avv-webshop', pdf: 'AVV-Webshop' },
|
||||
widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' },
|
||||
},
|
||||
}
|
||||
|
||||
/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */
|
||||
const FUNKTION_SATZ = { news: null, termine: 'termine' }
|
||||
const FUNKTION_SATZ = { news: null, termine: 'termine', shop: 'shop' }
|
||||
|
||||
/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */
|
||||
const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion)
|
||||
const aktuelleFassung = (satz) => {
|
||||
if (satz === 'analyse') return config.analyse.avvVersion
|
||||
if (satz === 'shop') return config.shop.fassung
|
||||
return config.termine.avvVersion
|
||||
}
|
||||
|
||||
function satzDokumente(satz) {
|
||||
if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz')
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { getFeatureDoc } from '../siteFeatures.js'
|
||||
|
||||
/**
|
||||
* Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Dieser Stand ist der Rumpf aus
|
||||
* Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus.
|
||||
* Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist.
|
||||
*/
|
||||
|
||||
export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.'
|
||||
|
||||
/**
|
||||
* Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen
|
||||
* ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen
|
||||
* Status ist die Anfrage nicht freigegeben. Lesefehler werden weitergereicht (der Checkout bestellt dann nicht).
|
||||
*/
|
||||
export async function anfrageOffenePunkte({ customerId, projectId }) {
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'shop')
|
||||
if (!doc) return [ANFRAGE_OFFEN_TEXT]
|
||||
let shop
|
||||
try {
|
||||
shop = await getDocument(config.collections.shopConfig, doc.$id)
|
||||
} catch (err) {
|
||||
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT]
|
||||
throw err
|
||||
}
|
||||
return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { TICKET_DETAILS_MAX, anleitungsLink, kuerze, minimaleTicketDetails } from '../featureRegistry.js'
|
||||
|
||||
/**
|
||||
* Details des Einrichtungs-Tickets fuer den Webshop (config.features.shop.ticket.details, Spezifikation Abschnitt 6).
|
||||
* ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt (doc = portalFeatures-Dokument, project = websiteProjects-
|
||||
* Dokument, customer = Kunde oder null). Der Text ist deterministisch: Kopfzeilen, Kurzfassung der Anfrage (best effort aus
|
||||
* shopConfig, Dokument-ID = ID des portalFeatures-Dokuments; Lesefehler oder unlesbares JSON => ohne), Snippet und Schritte.
|
||||
* Die Funktion wirft nie (bezahlt ist bezahlt, das Ticket entsteht immer) und enthaelt keine Daten von Kaeufern.
|
||||
*/
|
||||
|
||||
const WARENART = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' }
|
||||
const ARTIKELZAHL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/** Kurzfassung der Anfrage als Zeile, oder '' (Lesefehler, fehlendes Dokument, leere Anfrage). */
|
||||
async function anfrageZeile(doc) {
|
||||
try {
|
||||
const shop = await getDocument(config.collections.shopConfig, doc.$id)
|
||||
const anfrage = typeof shop?.anfrage === 'string' && shop.anfrage ? JSON.parse(shop.anfrage) : null
|
||||
if (!istObjekt(anfrage)) return ''
|
||||
const teile = []
|
||||
if (Object.hasOwn(WARENART, anfrage.warenart)) teile.push(`Warenart: ${WARENART[anfrage.warenart]}`)
|
||||
const lieferung = [anfrage.versand === true ? 'Versand' : '', anfrage.abholung === true ? 'Abholung' : ''].filter(Boolean)
|
||||
if (lieferung.length) teile.push(`Lieferung: ${lieferung.join(' und ')}`)
|
||||
if (Object.hasOwn(ARTIKELZAHL, anfrage.artikelzahl)) teile.push(`Artikelzahl: ${ARTIKELZAHL[anfrage.artikelzahl]}`)
|
||||
return teile.length ? `Anfrage: ${teile.join(', ')}` : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function shopTicketDetails(ctx) {
|
||||
try {
|
||||
const { doc } = ctx
|
||||
const siteKey = doc?.siteKey || ''
|
||||
const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).split('\n').filter((zeile) => !zeile.startsWith('Verweis: '))
|
||||
const anfrage = doc?.$id ? await anfrageZeile(doc) : ''
|
||||
const zeilen = [
|
||||
...kopf,
|
||||
...(anfrage ? [anfrage] : []),
|
||||
'',
|
||||
'Snippet (an der Stelle der Shop-Seite einsetzen):',
|
||||
`<div data-webklar-shop="${siteKey}"></div>`,
|
||||
'<script src="https://project.webklar.com/embed/shop.js" defer></script>',
|
||||
'',
|
||||
'Schritte:',
|
||||
'1. Shop-Seite und Menüpunkt anlegen, Snippet einsetzen.',
|
||||
`2. Auf allen Seiten im Footer den Link "Vertrag widerrufen" setzen: https://project.webklar.com/shop/${siteKey}/widerruf`,
|
||||
'3. Datenschutz-Absatz Webshop einfügen (docs/funktionen/shop-datenschutz-absatz.md).',
|
||||
'4. Impressum prüfen.',
|
||||
`5. Vorschau prüfen: https://project.webklar.com/shop/${siteKey}`,
|
||||
'6. Ticket schließen: das schaltet die Funktion frei.',
|
||||
'',
|
||||
'Zeit im Arbeitsblatt erfassen, nicht auf Abo-Stunden anrechnen.',
|
||||
`Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`,
|
||||
]
|
||||
return kuerze(zeilen.join('\n').toWellFormed(), TICKET_DETAILS_MAX, { anZeile: true })
|
||||
} catch (err) {
|
||||
console.error(`[shop-ticket] Details nicht erzeugbar, Notfall-Details: ${err?.message}`)
|
||||
try {
|
||||
return kuerze(minimaleTicketDetails({ ...ctx, feature: { key: 'Webshop' } }).toWellFormed(), TICKET_DETAILS_MAX)
|
||||
} catch {
|
||||
return 'Funktion: Webshop\nDetails konnten nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).'
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user