@tribe-nest/forge 3.64.0 → 3.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingAvailability.ts +18 -6
  3. package/src/data/queries/useMyBookings.ts +7 -0
  4. package/src/data/queries/useWebsite.ts +42 -11
  5. package/src/data/queries/useWebsiteAgent.ts +136 -6
  6. package/src/i18n/de.json +63 -0
  7. package/src/i18n/en.json +63 -0
  8. package/src/provider/ForgeProvider.tsx +7 -1
  9. package/src/types/models.ts +13 -0
  10. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  11. package/src/ui/format/bookingPricing.ts +55 -0
  12. package/src/ui/format/ticketPrice.ts +52 -0
  13. package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
  14. package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
  15. package/src/ui/headless/agent/callChallenge.ts +72 -0
  16. package/src/ui/headless/agent/useAgentCall.ts +189 -0
  17. package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
  18. package/src/ui/headless/agent/useAiAgent.ts +8 -20
  19. package/src/ui/headless/agent/visitorId.ts +20 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  21. package/src/ui/index.ts +24 -0
  22. package/src/ui/styled/AccountDashboard.tsx +11 -3
  23. package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
  24. package/src/ui/styled/AiAgentWidget.tsx +276 -21
  25. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  26. package/src/ui/styled/CoachingBooking.tsx +36 -158
  27. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  28. package/src/ui/styled/CoachingDetail.tsx +8 -2
  29. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  30. package/src/ui/styled/CookieConsent.tsx +13 -9
  31. package/src/ui/styled/EventDetail.tsx +29 -6
  32. package/src/ui/styled/EventTickets.tsx +35 -11
  33. package/src/ui/styled/MusicLinkPage.tsx +6 -3
  34. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  35. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  36. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  37. package/src/ui/styled/forge-utilities.css +0 -5
  38. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  39. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  40. package/src/utils/structuredData.ts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.64.0",
3
+ "version": "3.67.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,16 +35,25 @@ export type ReserveCoachingBookingResult = {
35
35
  * held, and the buyer's rights are governed by this copy, not by that read.
36
36
  */
37
37
  cancellation?: CancellationTermsView | null;
38
+ /** How many sessions this booking holds (1 for a single session). */
39
+ sessionCount?: number;
40
+ /** Every session held, earliest first. `bookingId` above is the first one's. */
41
+ sessions?: { bookingId: string; slotId: string; startTime: string; endTime: string }[];
42
+ /** When the server releases the hold (ISO). */
43
+ holdExpiresAt?: string;
38
44
  };
39
45
 
40
- /** Reserve a coaching booking slot. Returns the created booking + its agreed terms. */
46
+ /**
47
+ * Reserve a booking: one slot, or every session of a multi-session booking at
48
+ * once (`slotIds`). All or nothing: if any time was just taken, nothing is held.
49
+ */
41
50
  export function useReserveCoachingBooking(productId?: string) {
42
51
  const { client } = useForge();
43
52
 
44
- return useMutation<ReserveCoachingBookingResult, unknown, { slotId: string }>({
45
- mutationFn: async ({ slotId }) => {
53
+ return useMutation<ReserveCoachingBookingResult, unknown, { slotId?: string; slotIds?: string[] }>({
54
+ mutationFn: async ({ slotId, slotIds }) => {
46
55
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
47
- slotId,
56
+ ...(slotIds?.length ? { slotIds } : { slotId }),
48
57
  metaSignals: readMetaSignals(),
49
58
  });
50
59
  return res.data;
@@ -106,10 +115,13 @@ export function useUpdateCoachingBooking(productId?: string) {
106
115
  export function useUnreserveCoachingBooking(productId?: string) {
107
116
  const { client } = useForge();
108
117
 
109
- return useMutation<unknown, unknown, { bookingId: string }>({
110
- mutationFn: async ({ bookingId }) => {
118
+ return useMutation<unknown, unknown, { bookingId: string; bookingSecret?: string }>({
119
+ // The secret is required: without it the backend answers 404 for a guest's
120
+ // hold and the times stay held until the hold expires.
121
+ mutationFn: async ({ bookingId, bookingSecret }) => {
111
122
  const res = await client.post(`/public/coaching/products/${productId}/booking/unreserve`, {
112
123
  bookingId,
124
+ bookingSecret,
113
125
  });
114
126
  return res.data;
115
127
  },
@@ -83,6 +83,13 @@ export type MyBooking = {
83
83
  lastName: string | null;
84
84
  /** Already a number (or null for a free session), not a decimal string. */
85
85
  totalAmount: number | null;
86
+ /**
87
+ * Set on every session of a multi-session booking except the first, which
88
+ * carries the payment for all of them. Null on a single session.
89
+ */
90
+ packageLeadBookingId?: string | null;
91
+ /** How many sessions the booking this session belongs to holds (1 = single). */
92
+ sessionCount?: number;
86
93
  totalAmountInChargedCurrency: string | number | null;
87
94
  currency: string | null;
88
95
  chargedCurrency: string | null;
@@ -1,10 +1,11 @@
1
1
  import type { WebPage } from "../../types";
2
- import { useForge } from "../../provider/ForgeProvider";
2
+ import { useContext } from "react";
3
+ import { useForge, useOptionalForge } from "../../provider/ForgeProvider";
3
4
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
4
- import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
5
+ import { keepPreviousData, QueryClientContext, useMutation, useQuery } from "@tanstack/react-query";
5
6
 
6
7
  export interface SiteConfig {
7
- /** The tenant's settlement currency (e.g. "USD") — the currency API amounts
8
+ /** The tenant's settlement currency (e.g. "USD"): the currency API amounts
8
9
  * are in and payments are charged in. */
9
10
  currency: string;
10
11
  /** The language the tenant speaks ("en" | "de" today), from
@@ -16,7 +17,7 @@ export interface SiteConfig {
16
17
  locale?: string;
17
18
  /** Sales-tax display mode: true = listing prices already contain tax
18
19
  * ("incl. VAT" style, EU/UK), false/absent = tax added at checkout (US).
19
- * Display-only — checkout totals come from the authoritative taxQuote. */
20
+ * Display-only: checkout totals come from the authoritative taxQuote. */
20
21
  pricesIncludeTax?: boolean;
21
22
  metaPixelId: string | null;
22
23
  stripeAccountId: string | null;
@@ -29,7 +30,7 @@ export interface SiteConfig {
29
30
  /** Whether Altcha proof-of-work bot protection is active. Self-hosted, so it
30
31
  * works on every tenant subdomain + custom domain with no per-domain config. */
31
32
  altchaEnabled?: boolean;
32
- /** The creator's site display name — the manifest `name` fallback when the
33
+ /** The creator's site display name: the manifest `name` fallback when the
33
34
  * PWA identity has no explicit name. */
34
35
  siteName?: string | null;
35
36
  /** The profile-level PWA identity (icons/name/screenshots) used to build the
@@ -38,10 +39,21 @@ export interface SiteConfig {
38
39
  pwa?: PwaConfig | null;
39
40
  /** Suppresses the "Powered by TribeNest" badge on released builds. Server-side
40
41
  * on purpose: the tenant owns `__root.tsx`, so anything expressed as a prop is
41
- * an opt-out. Absent/false today — this is the lever a white-label plan
42
+ * an opt-out. Absent/false today. This is the lever a white-label plan
42
43
  * entitlement flips, and it works without waiting for a Forge bump to reach
43
44
  * every site. */
44
45
  hideTribeNestBadge?: boolean;
46
+ /** The privacy policy and terms of the brand the site is sold under
47
+ * (TribeNest's, or Mandatera's for a law firm). What a site with no policy of
48
+ * its own links to. Optional because an older backend sends no such field,
49
+ * and every reader then falls back to TribeNest's. */
50
+ legalUrls?: BrandLegalUrls | null;
51
+ }
52
+
53
+ /** The brand's hosted legal pages, from `site-config`. */
54
+ export interface BrandLegalUrls {
55
+ privacy: string;
56
+ terms: string;
45
57
  }
46
58
 
47
59
  /** The PWA identity block on `site-config` (mirrors `pwaConfig` on the profile). */
@@ -58,13 +70,13 @@ export interface PwaConfig {
58
70
 
59
71
  /**
60
72
  * The tenant's mutable runtime config (settlement currency, Meta Pixel id,
61
- * payment provider). Fetched at runtime — NOT baked into the build — so a
73
+ * payment provider). Fetched at runtime (NOT baked into the build) so a
62
74
  * creator can change these in settings and the live site reflects them without
63
75
  * a rebuild/redeploy.
64
76
  *
65
77
  * If a `<SiteConfigProvider>` seeded an SSR-fetched value (see `fetchSiteConfig`
66
78
  * in `@tribe-nest/forge/server`), it's used as `initialData` so the config is
67
- * present on the first render — no client-fetch flash, no hydration mismatch.
79
+ * present on the first render: no client-fetch flash, no hydration mismatch.
68
80
  * An explicit `initialData` override still wins.
69
81
  */
70
82
  export function useSiteConfig(opts?: { initialData?: SiteConfig }) {
@@ -79,10 +91,29 @@ export function useSiteConfig(opts?: { initialData?: SiteConfig }) {
79
91
  },
80
92
  enabled: !!client && !!profileId,
81
93
  initialData: opts?.initialData ?? ssrInitial,
82
- staleTime: 1000 * 60 * 5, // 5 min — settings change rarely; refetch keeps it fresh
94
+ staleTime: 1000 * 60 * 5, // 5 min: settings change rarely; refetch keeps it fresh
83
95
  });
84
96
  }
85
97
 
98
+ /**
99
+ * The brand's hosted legal pages (`site-config.legalUrls`), or `undefined` when
100
+ * they are not known yet.
101
+ *
102
+ * Safe outside a `<ForgeProvider>` (returns `undefined`), because the components
103
+ * that read it (`CookieConsent`, `MusicLinkPage`) are also rendered standalone.
104
+ * Reads the SSR-seeded config first, then whatever `useSiteConfig` has cached;
105
+ * it never starts a fetch of its own.
106
+ */
107
+ export function useBrandLegalUrls(): BrandLegalUrls | undefined {
108
+ const ssrInitial = useInitialSiteConfig();
109
+ const forge = useOptionalForge();
110
+ const queryClient = useContext(QueryClientContext);
111
+ const cached = forge?.profileId
112
+ ? queryClient?.getQueryData<SiteConfig>(["site-config", forge.profileId])
113
+ : undefined;
114
+ return cached?.legalUrls ?? ssrInitial?.legalUrls ?? undefined;
115
+ }
116
+
86
117
  /**
87
118
  * The store's tax display mode from site config: true = prices shown to the
88
119
  * buyer already include tax (inclusive/EU display), false = tax is added at
@@ -96,7 +127,7 @@ export function usePricesIncludeTax(): boolean {
96
127
 
97
128
  // Tax-aware price rendering primitive, re-exported here (a file the root barrel
98
129
  // already star-exports) so `@tribe-nest/frontend-shared` and app code can import
99
- // it from "@tribe-nest/forge". Its home is `ui/format/PriceDisplay` — the
130
+ // it from "@tribe-nest/forge". Its home is `ui/format/PriceDisplay`, and the
100
131
  // `forge/ui` barrel should export it from there as well.
101
132
  export {
102
133
  PriceDisplay,
@@ -118,7 +149,7 @@ export function useSubmitContactForm() {
118
149
  }
119
150
 
120
151
  /**
121
- * The rendered web page for a pathname — either the published site (by
152
+ * The rendered web page for a pathname: either the published site (by
122
153
  * subdomain) or a preview (by token). Keeps the previous page during navigation
123
154
  * to avoid a white flash.
124
155
  */
@@ -3,23 +3,33 @@ import { useQuery } from "@tanstack/react-query";
3
3
 
4
4
  export interface WebsiteAgentConfig {
5
5
  enabled: boolean;
6
+ /** The agent that answers on this website. */
7
+ agentId?: string;
6
8
  displayName?: string;
9
+ /** The agent's language, for example "en" or "de". */
10
+ language?: string;
7
11
  greeting?: string | null;
8
12
  allowAnonymous?: boolean;
9
13
  allowOrderLookup?: boolean;
10
14
  allowLeadCapture?: boolean;
11
15
  /** Paths the widget must not appear on. "/blog/*" covers every child. */
12
16
  excludedPaths?: string[];
17
+ /** The agent also takes calls from the browser: show "Call us". */
18
+ voiceEnabled?: boolean;
13
19
  }
14
20
 
15
- /** Widget bootstrap config for the profile. When `enabled` is false the widget
16
- * should render nothing. */
21
+ /** Widget bootstrap config for the website. When `enabled` is false the widget
22
+ * should render nothing. `websiteId` picks the agent of THIS website, since a
23
+ * profile with several sites can give each its own; without it the backend
24
+ * answers with the profile's default agent. */
17
25
  export function useWebsiteAgentConfig() {
18
- const { client, profileId } = useForge();
26
+ const { client, profileId, websiteId } = useForge();
19
27
  return useQuery<WebsiteAgentConfig>({
20
- queryKey: ["website-agent-config", profileId],
28
+ queryKey: ["website-agent-config", profileId, websiteId ?? null],
21
29
  queryFn: async () => {
22
- const res = await client.get(`/public/agent/config`, { params: { profileId } });
30
+ const res = await client.get(`/public/agent/config`, {
31
+ params: { profileId, ...(websiteId ? { websiteId } : {}) },
32
+ });
23
33
  return res.data;
24
34
  },
25
35
  enabled: !!profileId && !!client,
@@ -88,6 +98,7 @@ export async function createAgentConversation(params: {
88
98
  apiUrl: string;
89
99
  token?: string | null;
90
100
  profileId: string;
101
+ websiteId?: string;
91
102
  visitorId: string;
92
103
  }): Promise<{ id: string | null; requiresLogin?: boolean }> {
93
104
  const res = await fetch(`${params.apiUrl}/public/agent/conversations`, {
@@ -96,7 +107,126 @@ export async function createAgentConversation(params: {
96
107
  "Content-Type": "application/json",
97
108
  ...(params.token ? { authorization: `Bearer ${params.token}` } : {}),
98
109
  },
99
- body: JSON.stringify({ profileId: params.profileId, visitorId: params.visitorId }),
110
+ body: JSON.stringify({
111
+ profileId: params.profileId,
112
+ ...(params.websiteId ? { websiteId: params.websiteId } : {}),
113
+ visitorId: params.visitorId,
114
+ }),
100
115
  });
101
116
  return res.json();
102
117
  }
118
+
119
+ // "Call us": a call with the website's agent, from the browser.
120
+
121
+ /** Where the call's media room is, and how to come back to it. */
122
+ export interface AgentCallCredentials {
123
+ callId: string;
124
+ roomId: string;
125
+ mediaUrl: string;
126
+ token: string;
127
+ expiresAt: string;
128
+ /** This browser's seat in the room, reused on every reconnect. */
129
+ identity: string;
130
+ /** Proves this browser started the call: needed to rejoin or end it. */
131
+ callKey: string;
132
+ }
133
+
134
+ /** The bot check a call starts with. See `solveCallChallenge`. */
135
+ export interface AgentCallChallenge {
136
+ challenge: string;
137
+ difficulty: number;
138
+ expiresAt: string;
139
+ }
140
+
141
+ /** A refused call request, with the HTTP status that says why. */
142
+ export class AgentCallRequestError extends Error {
143
+ readonly status: number;
144
+ constructor(status: number, message: string) {
145
+ super(message);
146
+ this.name = "AgentCallRequestError";
147
+ this.status = status;
148
+ }
149
+ }
150
+
151
+ async function postAgentCall<T>(url: string, body: Record<string, unknown>, keepalive = false): Promise<T> {
152
+ let res: Response;
153
+ try {
154
+ res = await fetch(url, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify(body),
158
+ // Lets the request outlive the page, so leaving it still hangs up.
159
+ keepalive,
160
+ });
161
+ } catch (err) {
162
+ throw new AgentCallRequestError(0, err instanceof Error ? err.message : String(err));
163
+ }
164
+ const data = (await res.json().catch(() => null)) as (T & { message?: string }) | null;
165
+ if (!res.ok) throw new AgentCallRequestError(res.status, data?.message || res.statusText || "Request failed");
166
+ return data as T;
167
+ }
168
+
169
+ const withWebsite = (websiteId?: string) => (websiteId ? { websiteId } : {});
170
+
171
+ /** Step 1: ask for a bot check. 404 when the website's agent takes no calls. */
172
+ export function requestAgentCallChallenge(params: {
173
+ apiUrl: string;
174
+ profileId: string;
175
+ websiteId?: string;
176
+ visitorId: string;
177
+ }): Promise<AgentCallChallenge> {
178
+ return postAgentCall(`${params.apiUrl}/public/agent/calls/challenge`, {
179
+ profileId: params.profileId,
180
+ ...withWebsite(params.websiteId),
181
+ visitorId: params.visitorId,
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Step 2: start the call with the solved check. Refusals carry the status:
187
+ * 400 failed check, 429 over the limit, 404 not available, 409 the minutes or
188
+ * the lines are used up, 503 the call network is down.
189
+ */
190
+ export function startAgentCall(params: {
191
+ apiUrl: string;
192
+ profileId: string;
193
+ websiteId?: string;
194
+ visitorId: string;
195
+ challenge: string;
196
+ solution: string;
197
+ }): Promise<AgentCallCredentials> {
198
+ return postAgentCall(`${params.apiUrl}/public/agent/calls`, {
199
+ profileId: params.profileId,
200
+ ...withWebsite(params.websiteId),
201
+ visitorId: params.visitorId,
202
+ challenge: params.challenge,
203
+ solution: params.solution,
204
+ });
205
+ }
206
+
207
+ /** A fresh join ticket for a reconnect: a ticket expires in minutes. */
208
+ export function joinAgentCall(params: {
209
+ apiUrl: string;
210
+ callId: string;
211
+ callKey: string;
212
+ identity: string;
213
+ }): Promise<AgentCallCredentials> {
214
+ return postAgentCall(`${params.apiUrl}/public/agent/calls/${params.callId}/join`, {
215
+ callKey: params.callKey,
216
+ identity: params.identity,
217
+ });
218
+ }
219
+
220
+ /** Hangs up. `keepalive` for a page that is going away. */
221
+ export function endAgentCall(params: {
222
+ apiUrl: string;
223
+ callId: string;
224
+ callKey: string;
225
+ keepalive?: boolean;
226
+ }): Promise<{ ended: boolean }> {
227
+ return postAgentCall(
228
+ `${params.apiUrl}/public/agent/calls/${params.callId}/end`,
229
+ { callKey: params.callKey },
230
+ params.keepalive,
231
+ );
232
+ }
package/src/i18n/de.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "forge.account_dashboard.booking_duration": "{minutes} Minuten",
21
21
  "forge.account_dashboard.booking_join_label": "Teilnehmen:",
22
22
  "forge.account_dashboard.booking_join_video": "Videoanruf beitreten",
23
+ "forge.account_dashboard.booking_part_of": "Teil einer Buchung mit {count} Terminen",
23
24
  "forge.account_dashboard.booking_video_call": "Videoanruf auf dieser Website",
24
25
  "forge.account_dashboard.booking_video_closed": "Videoanruf: Der Raum dieser Session ist geschlossen",
25
26
  "forge.account_dashboard.booking_video_opens_at": "Videoanruf: Der Raum öffnet {datetime}",
@@ -184,8 +185,25 @@
184
185
  "forge.account_dashboard.waitlist_ticket_line_other": "{title} · {quantity} Tickets",
185
186
  "forge.account_dashboard.waitlists_empty": "Du stehst auf keiner Warteliste.",
186
187
  "forge.account_dashboard.waitlists_title": "Meine Wartelisten",
188
+ "forge.ai_agent_call_room.connecting": "Verbindung wird hergestellt…",
189
+ "forge.ai_agent_call_room.listening": "{name} hört zu",
190
+ "forge.ai_agent_call_room.no_mic": "Wir konnten dein Mikrofon nicht verwenden. Erlaube es in deinem Browser und ruf dann erneut an.",
191
+ "forge.ai_agent_call_room.speaking": "{name} spricht",
192
+ "forge.ai_agent_widget.aria_call": "Ruf uns an",
187
193
  "forge.ai_agent_widget.aria_close": "Schließen",
188
194
  "forge.ai_agent_widget.aria_open": "Chat-Assistent öffnen",
195
+ "forge.ai_agent_widget.aria_show_call": "Anruf anzeigen",
196
+ "forge.ai_agent_widget.call_again": "Erneut anrufen",
197
+ "forge.ai_agent_widget.call_back_to_chat": "Zurück zum Chat",
198
+ "forge.ai_agent_widget.call_connecting": "Verbindung wird hergestellt…",
199
+ "forge.ai_agent_widget.call_ended": "Anruf beendet.",
200
+ "forge.ai_agent_widget.call_error_busy": "Alle unsere Leitungen sind belegt. Bitte versuch es in ein paar Minuten noch einmal.",
201
+ "forge.ai_agent_widget.call_error_check_failed": "Wir konnten den Anruf nicht starten. Bitte versuch es noch einmal.",
202
+ "forge.ai_agent_widget.call_error_failed": "Wir konnten deinen Anruf nicht verbinden. Bitte versuch es noch einmal.",
203
+ "forge.ai_agent_widget.call_error_too_many": "Du hast in letzter Zeit mehrmals angerufen. Bitte versuch es später noch einmal.",
204
+ "forge.ai_agent_widget.call_error_unavailable": "Anrufe sind gerade nicht möglich.",
205
+ "forge.ai_agent_widget.call_hang_up": "Auflegen",
206
+ "forge.ai_agent_widget.call_try_again": "Erneut versuchen",
189
207
  "forge.ai_agent_widget.default_greeting": "Hi! Frag mich, was du willst.",
190
208
  "forge.ai_agent_widget.default_title": "Assistent",
191
209
  "forge.ai_agent_widget.input_placeholder": "Nachricht schreiben…",
@@ -365,7 +383,10 @@
365
383
  "forge.broadcast_poll.votes_other": "{count} Stimmen",
366
384
  "forge.broadcast_poll.your_vote": "(deine Stimme)",
367
385
  "forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
386
+ "forge.broadcast_ticket_purchase.free": "Kostenlos",
387
+ "forge.broadcast_ticket_purchase.free_to_price": "Kostenlos bis {price}",
368
388
  "forge.broadcast_ticket_purchase.from_price": "Ab {price}",
389
+ "forge.broadcast_ticket_purchase.reserve": "Reservieren",
369
390
  "forge.broadcast_watch.leave": "Übertragung verlassen",
370
391
  "forge.broadcast_watch.load_error": "Übertragung konnte nicht geladen werden",
371
392
  "forge.bundle_confirmation.continue_shopping": "Weiter einkaufen",
@@ -693,17 +714,33 @@
693
714
  "forge.checkout_confirmation.total_paid": "Bezahlt gesamt",
694
715
  "forge.checkout_confirmation.view_orders": "Bestellungen ansehen",
695
716
  "forge.coaching_booking.aria_close": "Schließen",
717
+ "forge.coaching_booking.aria_next_month": "Nächster Monat",
696
718
  "forge.coaching_booking.aria_next_week": "Nächste Woche",
719
+ "forge.coaching_booking.aria_prev_month": "Vorheriger Monat",
697
720
  "forge.coaching_booking.aria_prev_week": "Vorherige Woche",
698
721
  "forge.coaching_booking.back": "Zurück",
722
+ "forge.coaching_booking.basket_count_package": "{picked} von {count} gewählt",
723
+ "forge.coaching_booking.basket_count_range": "{picked} gewählt, bis zu {max}",
724
+ "forge.coaching_booking.basket_empty": "Noch keine Zeit gewählt",
725
+ "forge.coaching_booking.basket_full_limit": "Das ist das Limit von {max} Terminen. Entferne einen, um eine andere Zeit zu wählen.",
726
+ "forge.coaching_booking.basket_full_package": "Alle {count} Termine gewählt. Entferne einen, um eine andere Zeit zu wählen.",
727
+ "forge.coaching_booking.basket_more": "Noch {count} zu wählen",
728
+ "forge.coaching_booking.basket_title_many": "Deine Termine",
729
+ "forge.coaching_booking.basket_title_package": "Deine {count} Termine",
730
+ "forge.coaching_booking.basket_title_single": "Dein Termin",
699
731
  "forge.coaching_booking.confirm_booking": "Buchung bestätigen",
700
732
  "forge.coaching_booking.confirm_email_placeholder": "E-Mail bestätigen",
701
733
  "forge.coaching_booking.confirm_leave": "Möchtest du die Kasse wirklich verlassen?",
702
734
  "forge.coaching_booking.consent": "Mit dem Fortfahren stimmst du zu, Buchungsbestätigungen und wichtige Infos zu deiner Session per E-Mail zu bekommen.",
703
735
  "forge.coaching_booking.continue": "Weiter",
704
736
  "forge.coaching_booking.continue_to_payment": "Weiter zur Zahlung",
737
+ "forge.coaching_booking.continue_with_total": "Weiter, {amount}",
738
+ "forge.coaching_booking.day_has_times": "{date}, Zeiten verfügbar",
739
+ "forge.coaching_booking.day_no_times": "{date}, keine Zeiten",
705
740
  "forge.coaching_booking.details_subtitle": "Bitte gib deine Kontaktdaten für die Buchung an.",
706
741
  "forge.coaching_booking.details_title": "Kontaktdaten",
742
+ "forge.coaching_booking.done": "Fertig",
743
+ "forge.coaching_booking.duration_short": "{minutes} Min.",
707
744
  "forge.coaching_booking.email_invalid_hint": "Bitte gib eine gültige E-Mail-Adresse ein",
708
745
  "forge.coaching_booking.email_mismatch_hint": "Die E-Mail-Adressen stimmen nicht überein",
709
746
  "forge.coaching_booking.email_placeholder": "E-Mail",
@@ -715,14 +752,29 @@
715
752
  "forge.coaching_booking.first_name_placeholder": "Vorname",
716
753
  "forge.coaching_booking.last_name_placeholder": "Nachname",
717
754
  "forge.coaching_booking.loading_slots": "Freie Termine werden geladen…",
755
+ "forge.coaching_booking.next_available": "Nächster freier Termin",
718
756
  "forge.coaching_booking.no_slots": "Diese Woche sind keine Termine frei.",
757
+ "forge.coaching_booking.no_times_day": "An diesem Tag sind keine Zeiten frei.",
758
+ "forge.coaching_booking.no_times_month": "In diesem Monat sind keine Zeiten frei.",
719
759
  "forge.coaching_booking.not_found": "Coaching-Session nicht gefunden.",
760
+ "forge.coaching_booking.not_picked_yet": "Noch nicht gewählt",
761
+ "forge.coaching_booking.package_help_body": "Du wählst jetzt alle Termine und zahlst einmal. Jeden Termin kannst du später einzeln verschieben, nach den beim Checkout gezeigten Storno- und Verschiebungsbedingungen.",
762
+ "forge.coaching_booking.package_help_label": "Wie funktioniert ein Paket?",
763
+ "forge.coaching_booking.package_one_price": "{count} Termine, ein Preis",
764
+ "forge.coaching_booking.package_total": "{price} für alle {count}",
720
765
  "forge.coaching_booking.pay": "{amount} zahlen",
721
766
  "forge.coaching_booking.payment_not_configured": "Zahlung ist nicht eingerichtet.",
722
767
  "forge.coaching_booking.payment_subtitle": "Schließe den Kauf ab, um deine Buchung zu sichern.",
723
768
  "forge.coaching_booking.payment_title": "Zahlung",
769
+ "forge.coaching_booking.per_session_calc": "{count} × {price}",
770
+ "forge.coaching_booking.pick_a_time": "Wähle eine Zeit",
771
+ "forge.coaching_booking.pick_day": "Wähle einen markierten Tag, um seine Zeiten zu sehen.",
772
+ "forge.coaching_booking.pick_more": "Noch {count} wählen",
724
773
  "forge.coaching_booking.preparing_payment": "Zahlung wird vorbereitet…",
774
+ "forge.coaching_booking.price_per_session": "{price} pro Termin",
725
775
  "forge.coaching_booking.processing": "Wird verarbeitet…",
776
+ "forge.coaching_booking.remove": "Entfernen",
777
+ "forge.coaching_booking.remove_time": "{time} entfernen",
726
778
  "forge.coaching_booking.reserving": "Wird reserviert…",
727
779
  "forge.coaching_booking.select_placeholder": "Auswählen…",
728
780
  "forge.coaching_booking.slot_title": "Termin wählen",
@@ -756,6 +808,7 @@
756
808
  "forge.coaching_confirmation.row_paid": "Bezahlt",
757
809
  "forge.coaching_confirmation.row_reference": "Referenz",
758
810
  "forge.coaching_confirmation.row_session": "Session",
811
+ "forge.coaching_confirmation.row_session_n": "Termin {index} von {count}",
759
812
  "forge.coaching_confirmation.row_tax": "MwSt.",
760
813
  "forge.coaching_confirmation.status_unknown": "ungeklärt",
761
814
  "forge.coaching_confirmation.tax_included": "Enthält {amount} MwSt.",
@@ -764,6 +817,10 @@
764
817
  "forge.coaching_detail.about_title": "Über diese Session",
765
818
  "forge.coaching_detail.duration_minutes": "{minutes} Minuten",
766
819
  "forge.coaching_detail.not_found": "Coaching-Session nicht gefunden.",
820
+ "forge.coaching_detail.price_for_sessions": "für {count} Termine",
821
+ "forge.coaching_detail.price_per_session_suffix": "pro Termin",
822
+ "forge.coaching_detail.price_save": "Spare {amount}",
823
+ "forge.coaching_detail.price_up_to": "Bis zu {max} auf einmal buchen",
767
824
  "forge.cohort_checkout.aria_close": "Schließen",
768
825
  "forge.cohort_checkout.authorise_future_payments": "Autorisiere die späteren Zahlungen dieses Plans.",
769
826
  "forge.cohort_checkout.back": "Zurück",
@@ -1428,7 +1485,10 @@
1428
1485
  "forge.event_detail.location_virtual": "Online",
1429
1486
  "forge.event_detail.not_found": "Event nicht gefunden.",
1430
1487
  "forge.event_detail.presale_only": "Tickets für dieses Event gibt es nur im Vorverkauf.",
1488
+ "forge.event_detail.price_free": "Kostenlos",
1489
+ "forge.event_detail.price_free_to": "Kostenlos bis {max}",
1431
1490
  "forge.event_detail.price_from_label": "Ab",
1491
+ "forge.event_detail.reserve": "Reservieren",
1432
1492
  "forge.event_seat_picker.dismiss": "Schließen",
1433
1493
  "forge.event_seat_picker.empty": "Es gibt noch keine Plätze zur Auswahl.",
1434
1494
  "forge.event_seat_picker.help_body": "Tippe auf einen Platz, um ihn auszuwählen, und noch einmal, um ihn freizugeben. Zoome mit zwei Fingern oder dem Mausrad und ziehe, um den Plan zu verschieben. Graue Plätze sind nicht verfügbar. Beste verfügbare wählt die niedrigsten zusammenhängenden Plätze für dich.",
@@ -1487,6 +1547,7 @@
1487
1547
  "forge.event_tickets.expires_hours_soon_other": "⏰ Läuft in {count} Stunden ab!",
1488
1548
  "forge.event_tickets.field_required": "Dieses Feld ist erforderlich",
1489
1549
  "forge.event_tickets.first_name_placeholder": "Vorname",
1550
+ "forge.event_tickets.free": "Kostenlos",
1490
1551
  "forge.event_tickets.invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein",
1491
1552
  "forge.event_tickets.item_label_event": "Dieses Event",
1492
1553
  "forge.event_tickets.item_label_ticket": "Dieses Ticket",
@@ -1514,6 +1575,7 @@
1514
1575
  "forge.event_tickets.pwyw_range_min_max": "(mindestens {min}, bis {max})",
1515
1576
  "forge.event_tickets.remove_discount": "Rabatt entfernen",
1516
1577
  "forge.event_tickets.request_tickets": "Tickets anfragen",
1578
+ "forge.event_tickets.reserve": "Reservieren",
1517
1579
  "forge.event_tickets.seat_best_available": "Beste verfügbare",
1518
1580
  "forge.event_tickets.seat_remove_aria": "{seat} entfernen",
1519
1581
  "forge.event_tickets.seats_left_one": "{count} Platz frei",
@@ -2228,6 +2290,7 @@
2228
2290
  "forge.review_form.title_placeholder": "Fass es in ein paar Worten zusammen",
2229
2291
  "forge.review_form.update_submit": "Bewertung aktualisieren",
2230
2292
  "forge.seat_legend.aria_label": "Legende",
2293
+ "forge.seat_legend.free": "Kostenlos",
2231
2294
  "forge.seat_legend.left_one": "{count} frei",
2232
2295
  "forge.seat_legend.left_other": "{count} frei",
2233
2296
  "forge.seat_legend.selected": "Ausgewählt",