@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +63 -0
- package/src/i18n/en.json +63 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +24 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/structuredData.ts +4 -1
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`, {
|
|
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({
|
|
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",
|