@bash-app/bash-common 30.232.0 → 30.234.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.
@@ -669,6 +669,8 @@ model BashEvent {
669
669
  subtitle String?
670
670
  isFeatured Boolean?
671
671
  featuredUntil DateTime? // When paid feature expires (null = no expiry / super-user pinned)
672
+ /// Paid discovery — carousel impression counter
673
+ featuredImpressions Int @default(0)
672
674
  isTrending Boolean?
673
675
  venueId String?
674
676
  averageRating Float? @default(0)
@@ -2247,6 +2249,8 @@ model User {
2247
2249
  createdOn DateTime @default(now())
2248
2250
  stripeCustomerId String? @unique
2249
2251
  stripeAccountId String? @unique
2252
+ /// Preferred processor for dual-rail onboarding (optional; legacy Stripe fields remain source of truth until fully migrated).
2253
+ paymentProvider PaymentProvider @default(STRIPE)
2250
2254
  isSuperUser Boolean @default(false)
2251
2255
  isSuspended Boolean @default(false)
2252
2256
  intent UserIntent?
@@ -2508,6 +2512,7 @@ model User {
2508
2512
  sponsorBookingRequestsAsHost SponsorBookingRequest[] @relation("SponsorBookingHost")
2509
2513
  sponsorships SponsoredEvent[]
2510
2514
  stripeAccounts StripeAccount[]
2515
+ paymentProviderAccounts PaymentProviderAccount[]
2511
2516
  ticketsISent Ticket[] @relation("TicketsISent")
2512
2517
  ticketsIOwn Ticket[] @relation("TicketsIOwn")
2513
2518
  bashEventDonations BashEventDonation[]
@@ -3073,6 +3078,7 @@ model Service {
3073
3078
  serviceRating Float? @default(0)
3074
3079
  totalRatings Int? @default(0)
3075
3080
  stripeAccountId String?
3081
+ paymentAccountId String?
3076
3082
  targetAudienceId String? @unique
3077
3083
  visibility VisibilityPreference @default(Public)
3078
3084
  bashesNotInterestedIn BashEventType[]
@@ -3113,6 +3119,7 @@ model Service {
3113
3119
  organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade)
3114
3120
  owner User? @relation("OwnedService", fields: [ownerId], references: [id])
3115
3121
  sponsor Sponsor? @relation(fields: [sponsorId], references: [id], onDelete: Cascade)
3122
+ paymentAccount PaymentProviderAccount? @relation(fields: [paymentAccountId], references: [id], onDelete: SetNull, onUpdate: Cascade)
3116
3123
  stripeAccount StripeAccount? @relation(fields: [stripeAccountId], references: [id], onDelete: Restrict)
3117
3124
  targetAudience TargetAudience? @relation(fields: [targetAudienceId], references: [id], onDelete: Cascade)
3118
3125
  vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: Cascade)
@@ -3140,6 +3147,7 @@ model Service {
3140
3147
  serviceBudgets Budget[] @relation("ServiceBudgets")
3141
3148
 
3142
3149
  @@index([serviceListingStripeSubscriptionId])
3150
+ @@index([paymentAccountId])
3143
3151
  @@index([isFreeFirstListing])
3144
3152
  @@index([isVerified])
3145
3153
  @@index([isLicensed])
@@ -3161,6 +3169,32 @@ model StripeAccount {
3161
3169
  @@index([ownerId])
3162
3170
  }
3163
3171
 
3172
+ /// Connects a user to Stripe and/or Square rails for provider payouts (dual-processor layer).
3173
+ model PaymentProviderAccount {
3174
+ id String @id @default(cuid())
3175
+ userId String
3176
+ provider PaymentProvider @default(STRIPE)
3177
+ stripeAccountId String? @unique
3178
+ stripeCustomerId String? @unique
3179
+ squareMerchantId String? @unique
3180
+ squareLocationId String?
3181
+ squareAccessToken String? @db.Text
3182
+ squareRefreshToken String? @db.Text
3183
+ squareTokenExpiresAt DateTime?
3184
+ accountVerified Boolean @default(false)
3185
+ payoutsEnabled Boolean @default(false)
3186
+ accountStatus String?
3187
+ createdAt DateTime @default(now())
3188
+ updatedAt DateTime @updatedAt
3189
+ lastVerifiedAt DateTime?
3190
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict, onUpdate: Cascade)
3191
+ services Service[]
3192
+
3193
+ @@unique([userId, provider])
3194
+ @@index([userId])
3195
+ @@index([provider])
3196
+ }
3197
+
3164
3198
  model StripeWebhookEvent {
3165
3199
  id String @id
3166
3200
  createdAt DateTime @default(now())
@@ -3905,10 +3939,22 @@ model ServiceAddon {
3905
3939
  ServiceBookingAddOn ServiceBookingAddOn[]
3906
3940
  convertedFromInclude ServiceInclude[] @relation("IncludeConvertedToAddon")
3907
3941
  convertedFromRequest ServiceAddonRequest[] @relation("RequestConvertedToAddon")
3942
+ serviceMerchSquareFulfillments ServiceMerchSquareFulfillment[]
3908
3943
 
3909
3944
  @@index([imageUrl])
3910
3945
  }
3911
3946
 
3947
+ /// Idempotency ledger: one row per Square payment applied to service merch inventory (public hired-service SKUs).
3948
+ model ServiceMerchSquareFulfillment {
3949
+ id String @id @default(cuid())
3950
+ squarePaymentId String @unique
3951
+ serviceAddonId String
3952
+ createdAt DateTime @default(now())
3953
+ serviceAddon ServiceAddon @relation(fields: [serviceAddonId], references: [id], onDelete: Restrict, onUpdate: Cascade)
3954
+
3955
+ @@index([serviceAddonId])
3956
+ }
3957
+
3912
3958
  model ServiceInclude {
3913
3959
  id String @id @default(cuid())
3914
3960
  serviceId String
@@ -6728,6 +6774,11 @@ enum ServiceTypes {
6728
6774
  Volunteers
6729
6775
  }
6730
6776
 
6777
+ enum PaymentProvider {
6778
+ STRIPE
6779
+ SQUARE
6780
+ }
6781
+
6731
6782
  enum PricingModel {
6732
6783
  FLAT_FEE // Fixed amount: "$500 for vendor booth"
6733
6784
  PER_ATTENDEE // Per person: "$50 per expected attendee"
@@ -71,6 +71,67 @@ export const BASH_PASS_PRICE_ENV_KEYS: Record<BashPassTier, string> = {
71
71
  [BashPassTier.UNLIMITED]: "STRIPE_BASH_PASS_UNLIMITED_PRICE_ID",
72
72
  };
73
73
 
74
+ // ─── Ticket.purchaseType values (string column; keep in sync with API writers) ───
75
+
76
+ export const TICKET_PURCHASE_TYPE_USD = "USD" as const;
77
+ export const TICKET_PURCHASE_TYPE_BASHPOINTS = "BashPoints" as const;
78
+ export const TICKET_PURCHASE_TYPE_FREE = "Free" as const;
79
+ export const TICKET_PURCHASE_TYPE_COMP = "Comp" as const;
80
+ export const TICKET_PURCHASE_TYPE_BASHPASS = "BashPass" as const;
81
+ /** Unpaid reservation — pay at venue (RSVP & Pay at Door). */
82
+ export const TICKET_PURCHASE_TYPE_RSVP_AT_DOOR = "RSVPAtDoor" as const;
83
+ /** Cash collected at door (staff-marked). */
84
+ export const TICKET_PURCHASE_TYPE_CASH = "Cash" as const;
85
+
86
+ export type TicketPurchaseTypeStored =
87
+ | typeof TICKET_PURCHASE_TYPE_USD
88
+ | typeof TICKET_PURCHASE_TYPE_BASHPOINTS
89
+ | typeof TICKET_PURCHASE_TYPE_FREE
90
+ | typeof TICKET_PURCHASE_TYPE_COMP
91
+ | typeof TICKET_PURCHASE_TYPE_BASHPASS
92
+ | typeof TICKET_PURCHASE_TYPE_RSVP_AT_DOOR
93
+ | typeof TICKET_PURCHASE_TYPE_CASH;
94
+
95
+ /** Stripe Checkout metadata: settle an existing RSVP ticket after card pay. */
96
+ export const CHECKOUT_PURPOSE_RSVP_DOOR_CHECKOUT = "rsvp_door_checkout" as const;
97
+
98
+ /** Door scanner: ticket QR verification result. */
99
+ export const DOOR_TICKET_SCAN_OUTCOMES = [
100
+ "paid_ok",
101
+ "payment_required",
102
+ "invalid_or_expired",
103
+ "wrong_event",
104
+ "already_checked_in",
105
+ ] as const;
106
+ export type DoorTicketScanOutcome = (typeof DOOR_TICKET_SCAN_OUTCOMES)[number];
107
+
108
+ /** Guest may enter without an additional payment step at the door. */
109
+ export function ticketIsPaidForEventAdmission(
110
+ ticket: { purchaseType: string | null; paidOn: Date | string | null },
111
+ tierPriceCents: number
112
+ ): boolean {
113
+ if (tierPriceCents <= 0) {
114
+ return true;
115
+ }
116
+ if (ticket.purchaseType === TICKET_PURCHASE_TYPE_COMP) {
117
+ return true;
118
+ }
119
+ if (ticket.purchaseType === TICKET_PURCHASE_TYPE_RSVP_AT_DOOR) {
120
+ return ticket.paidOn != null;
121
+ }
122
+ return ticket.paidOn != null;
123
+ }
124
+
125
+ export function isUnpaidRsvpAtDoorTicket(ticket: {
126
+ purchaseType: string | null;
127
+ paidOn: Date | string | null;
128
+ }): boolean {
129
+ return (
130
+ ticket.purchaseType === TICKET_PURCHASE_TYPE_RSVP_AT_DOOR &&
131
+ ticket.paidOn == null
132
+ );
133
+ }
134
+
74
135
  export const URL_PARAMS_BASH_EVENT_ID = "bashEventId" as const;
75
136
  export const URL_PARAMS_BASH_EVENT_TITLE = "bashEventTitle" as const;
76
137
  export const URL_PARAMS_BASH_EVENT_DESC = "bashEventDesc" as const;
@@ -201,6 +201,8 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
201
201
 
202
202
  export const PRIVATE_USER_ACCOUNT_TO_SELECT = {
203
203
  ...FRONT_END_USER_DATA_TO_SELECT,
204
+ /** Self session only — preferred payment rail (Stripe vs Square). */
205
+ paymentProvider: true,
204
206
  // Self-only sensitive demographics — re-added so /me can populate edit forms.
205
207
  gender: true,
206
208
  genderIdentity: true,
@@ -263,22 +265,33 @@ export type BashEventMerchCatalog = {
263
265
  settings: BashEventHostMerchSettings;
264
266
  };
265
267
 
266
- /** Service profile merch modal purchase options (ServiceRatesAssociation.merchSettings JSON). */
268
+ /**
269
+ * Service profile merch purchase options (ServiceRatesAssociation.merchSettings JSON).
270
+ *
271
+ * **Hired services → Square:** Only `showShipping` and `showPreOrder` produce checkout
272
+ * buttons. `showAddToCheckout` is for Stripe host/ticket-cart flows on the event (`BashMerchSection`);
273
+ * it is not used for Square Payment Links under hired services.
274
+ */
267
275
  export type ServiceMerchSettings = {
268
276
  showShipping: boolean;
269
277
  showPreOrder: boolean;
278
+ /** Stripe ticket-cart bundle option for host merch flows—not used for hired-service Square checkout. */
270
279
  showAddToCheckout: boolean;
271
280
  purchaseNote?: string | null;
272
281
  };
273
282
 
274
283
  /** Public bash detail: hired service bookings + merch SKUs for the event (GET /event/:id, /public-event/:id) */
275
- export interface BashEventHiredServicePublic {
284
+ export type BashEventHiredServicePublic = {
276
285
  bookingId: string;
277
286
  serviceId: string;
278
287
  specificId: string;
279
288
  serviceName: string | null;
280
289
  serviceType: ServiceTypes;
281
290
  coverPhoto: string | null;
291
+ /** When true, standalone Square checkout is available for merchandise SKUs below. */
292
+ squareMerchCheckoutEnabled: boolean;
293
+ /** Merch options from provider profile—Square hired-service checkout reads ship + pickup only. */
294
+ merchSettings: ServiceMerchSettings | null;
282
295
  merchandiseAddons: Array<{
283
296
  id: string;
284
297
  name: string;
@@ -288,7 +301,7 @@ export interface BashEventHiredServicePublic {
288
301
  imageAlt: string | null;
289
302
  quantity: number;
290
303
  }>;
291
- }
304
+ };
292
305
 
293
306
  export interface BashEventExt extends Omit<
294
307
  BashEvent,
@@ -320,7 +333,7 @@ export interface BashEventExt extends Omit<
320
333
  creator: PublicUser;
321
334
  })[];
322
335
  eventTasks: EventTask[];
323
- tickets?: Ticket[]; // Only include tickets that the user has purchased and not all tickets (could be thousands + privacy)
336
+ tickets?: TicketFlexible[]; // Only include tickets that the user has purchased and not all tickets (could be thousands + privacy)
324
337
  // Do not include in fetch. Could be hundreds of these
325
338
  invitations: Partial<InvitationExt>[];
326
339
  venueServiceId?: string;
@@ -1248,6 +1261,23 @@ export interface TicketExt extends Ticket {
1248
1261
  > | null;
1249
1262
  }
1250
1263
 
1264
+ /**
1265
+ * Ticket rows in attendee/event payloads where nested relations may be omitted
1266
+ * (e.g. authenticated GET `/event/:id` may include `transferInvite` without
1267
+ * `owner` / `forUser` selects).
1268
+ */
1269
+ export type TicketFlexible = Ticket & {
1270
+ owner?: PublicUser;
1271
+ forUser?: PublicUser;
1272
+ checkout?: Checkout;
1273
+ transfers?: TicketTransfer[];
1274
+ metadata?: TicketMetadata[];
1275
+ transferInvite?: Pick<
1276
+ TicketTransferInvite,
1277
+ "status" | "expiresAt" | "token" | "toEmail" | "toUserId"
1278
+ > | null;
1279
+ };
1280
+
1251
1281
  export interface CheckoutExt extends Checkout {
1252
1282
  owner: PublicUser;
1253
1283
  tickets: Ticket[];
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./extendedSchemas.js";
7
7
  export * from "./partnerStoreTypes.js";
8
8
  export * from "./bashFeedTypes.js";
9
9
  export * from "./membershipDefinitions.js";
10
+ export * from "./onSaleCapabilityRecommendations.js";
10
11
  export * from "./aiApproval.js";
11
12
  export * from "./userReportTypes.js";
12
13
 
@@ -148,6 +149,7 @@ export {
148
149
  PrizePaymentMethod,
149
150
  PrizePaymentStatus,
150
151
  PartnerTier,
152
+ PaymentProvider,
151
153
  PrizeType,
152
154
  ProjectorSetupFormat,
153
155
  PromoterFormat,
@@ -114,19 +114,30 @@ export const MEMBERSHIP_PRICING = {
114
114
  * When they exhaust free slots or want more, they pay these rates.
115
115
  */
116
116
  export const FEATURE_BOOST_DURATIONS = [
117
- { id: "24h", hours: 24, label: "24 hours", priceCents: 500 }, // $5
118
- { id: "72h", hours: 72, label: "3 days", priceCents: 1200 }, // $12
119
- { id: "7d", hours: 168, label: "7 days", priceCents: 2500 }, // $25
117
+ { id: "24h", hours: 24, label: "24 hours", priceCents: 500 }, // $5
118
+ { id: "72h", hours: 72, label: "3 days", priceCents: 1200 }, // $12
119
+ { id: "7d", hours: 168, label: "7 days", priceCents: 2500 }, // $25 — typical “best value” tier in UI
120
+ { id: "14d", hours: 336, label: "14 days", priceCents: 4500 }, // $45
121
+ { id: "30d", hours: 720, label: "30 days", priceCents: 8000 }, // $80
120
122
  ] as const;
121
123
 
122
124
  export type FeatureBoostDurationId = typeof FEATURE_BOOST_DURATIONS[number]["id"];
123
125
 
124
- /** Applies to both event and feed-post feature boosts */
126
+ /** Applies to event, feed-post, and service profile feature boosts */
125
127
  export const FEATURE_BOOST_STRIPE_DESCRIPTION = {
126
128
  event: (durationLabel: string) => `Featured Event — ${durationLabel}`,
127
129
  feedPost: (durationLabel: string) => `Featured Feed Post — ${durationLabel}`,
130
+ service: (durationLabel: string) => `Featured Service — ${durationLabel}`,
128
131
  };
129
132
 
133
+ /**
134
+ * Free “featured event” slots from membership (`featuredEventsPerMonth` in {@link MEMBERSHIP_LIMITS})
135
+ * are **not** tied to the calendar month. Usage is tracked in `User.membershipFeaturedEventsUsed` and
136
+ * reset when the membership subscription period renews (see Stripe `invoice.payment_succeeded` handling
137
+ * in the API — same path that can reset `membershipFeaturedEventsUsed`). Host UI should describe this as
138
+ * “resets when your membership renews,” not “resets on the 1st.”
139
+ */
140
+
130
141
  // Helper: Get monthly price in cents for a tier (for BashPoints calculations)
131
142
  export const MEMBERSHIP_MONTHLY_PRICE_CENTS: Record<MembershipTier, number> = {
132
143
  Basic: MEMBERSHIP_PRICING.Basic.monthly,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Heuristic defaults for festival-style on-sale capabilities (wizard UI).
3
+ * Pure functions — safe for SSR/tests; no IO.
4
+ */
5
+
6
+ export type CapabilityKey =
7
+ | "queue"
8
+ | "installments"
9
+ | "sms"
10
+ | "namedTickets";
11
+
12
+ export interface CapabilitySignals {
13
+ expectedAttendance: number | null;
14
+ highestTicketPriceCents: number | null;
15
+ lowestTicketPriceCents: number | null;
16
+ multiDay: boolean;
17
+ ticketTierCount: number;
18
+ onSaleOpensAt: string | null;
19
+ /** ISO currency lowercase, e.g. "usd" */
20
+ currency: string;
21
+ }
22
+
23
+ export interface CapabilityRecommendation {
24
+ capability: CapabilityKey;
25
+ enabled: boolean;
26
+ /** Human-readable bullets for inline “Recommended because …”. */
27
+ rationale: string[];
28
+ suggestedConfig?: {
29
+ queueAdmitRatePerSecond?: number;
30
+ };
31
+ }
32
+
33
+ function admitRatePerSecond(attendance: number | null): number {
34
+ if (attendance == null) return 3;
35
+ if (attendance < 400) return 8;
36
+ if (attendance < 1500) return 5;
37
+ if (attendance < 5000) return 3;
38
+ return 2;
39
+ }
40
+
41
+ /**
42
+ * Inspect draft event signals and suggest which toggles to pre-check on first load.
43
+ * The UI always treats checkbox state as source of truth; this only informs defaults.
44
+ */
45
+ export function computeCapabilityRecommendations(
46
+ signals: CapabilitySignals
47
+ ): CapabilityRecommendation[] {
48
+ const {
49
+ expectedAttendance,
50
+ highestTicketPriceCents,
51
+ ticketTierCount,
52
+ multiDay,
53
+ currency,
54
+ } = signals;
55
+
56
+ const attendance = expectedAttendance ?? 0;
57
+ const highCents = highestTicketPriceCents ?? 0;
58
+ const tierCount = ticketTierCount;
59
+
60
+ const queueRationale: string[] = [];
61
+ let queueOn = false;
62
+ if (attendance >= 350) {
63
+ queueOn = true;
64
+ queueRationale.push("Expected headcount is high enough that a waiting room reduces checkout failures.");
65
+ }
66
+ if (multiDay) {
67
+ queueOn = true;
68
+ queueRationale.push("Multi-day events often see concentrated on-sale traffic.");
69
+ }
70
+ if (tierCount >= 4) {
71
+ queueOn = true;
72
+ queueRationale.push("Several ticket tiers usually means more buyers hitting the page at once.");
73
+ }
74
+ if (highCents >= 8_000) {
75
+ queueOn = true;
76
+ queueRationale.push("Higher ticket prices tend to drive more simultaneous purchase attempts.");
77
+ }
78
+
79
+ const installmentsRationale: string[] = [];
80
+ let installmentsOn = false;
81
+ if (currency === "usd" && highCents >= 4_500) {
82
+ installmentsOn = true;
83
+ installmentsRationale.push("Installments help convert for tickets around $45+ in supported currencies.");
84
+ }
85
+
86
+ const smsRationale: string[] = [];
87
+ let smsOn = false;
88
+ if (attendance >= 500 || highCents >= 10_000) {
89
+ smsOn = true;
90
+ smsRationale.push("Larger or higher-stakes sales benefit from a clear confirmation channel.");
91
+ }
92
+
93
+ const namedRationale: string[] = [];
94
+ let namedOn = false;
95
+ if (tierCount >= 2 && highCents >= 6_000) {
96
+ namedOn = true;
97
+ namedRationale.push("Named tickets help groups and higher-value tiers stay organized at the door.");
98
+ }
99
+
100
+ const queue: CapabilityRecommendation = {
101
+ capability: "queue",
102
+ enabled: queueOn,
103
+ rationale: queueRationale,
104
+ ...(queueOn
105
+ ? {
106
+ suggestedConfig: {
107
+ queueAdmitRatePerSecond: admitRatePerSecond(expectedAttendance),
108
+ },
109
+ }
110
+ : {}),
111
+ };
112
+
113
+ return [
114
+ queue,
115
+ {
116
+ capability: "installments",
117
+ enabled: installmentsOn,
118
+ rationale: installmentsRationale,
119
+ },
120
+ {
121
+ capability: "sms",
122
+ enabled: smsOn,
123
+ rationale: smsRationale,
124
+ },
125
+ {
126
+ capability: "namedTickets",
127
+ enabled: namedOn,
128
+ rationale: namedRationale,
129
+ },
130
+ ];
131
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * getBackendHost — chooses the API origin (api.bash.community vs localhost:3500
2
+ * getBackendHost — chooses the API origin (api.bash.community vs Vite dev same-origin
3
3
  * vs the bundled API_HOST for native Capacitor builds).
4
4
  *
5
5
  * This file pins the contract so a future "let's tidy up env handling" change
@@ -42,35 +42,79 @@ afterEach(() => {
42
42
  clearWindow();
43
43
  });
44
44
 
45
- describe("getBackendHost — local dev", () => {
46
- it("returns http://localhost:3500 for localhost (does NOT reuse :3000 from the page)", () => {
45
+ describe("getBackendHost — local dev (Vite NODE_ENV=development)", () => {
46
+ let origNodeEnv: string | undefined;
47
+
48
+ beforeAll(() => {
49
+ origNodeEnv = process.env.NODE_ENV;
50
+ process.env.NODE_ENV = "development";
51
+ });
52
+
53
+ afterAll(() => {
54
+ if (origNodeEnv === undefined) {
55
+ delete process.env.NODE_ENV;
56
+ } else {
57
+ process.env.NODE_ENV = origNodeEnv;
58
+ }
59
+ });
60
+
61
+ it("returns same origin as the page (Vite proxies /api to Express)", () => {
47
62
  setWindow({
48
63
  protocol: "http:",
49
64
  hostname: "localhost",
50
65
  host: "localhost:3000",
51
66
  port: "3000",
52
67
  });
53
- expect(getBackendHost()).toBe("http://localhost:3500");
68
+ expect(getBackendHost()).toBe("http://localhost:3000");
54
69
  });
55
70
 
56
- it("returns http://127.0.0.1:3500 for 127.0.0.1", () => {
71
+ it("returns 127.0.0.1 with the page port (not a hard-coded API port)", () => {
57
72
  setWindow({
58
73
  protocol: "http:",
59
74
  hostname: "127.0.0.1",
60
75
  host: "127.0.0.1:3000",
61
76
  port: "3000",
62
77
  });
63
- expect(getBackendHost()).toBe("http://127.0.0.1:3500");
78
+ expect(getBackendHost()).toBe("http://127.0.0.1:3000");
64
79
  });
65
80
 
66
- it("uses the LAN IP for 192.168.x.x (so a phone on the same wifi can hit the dev API)", () => {
81
+ it("uses the LAN host:port of the dev server (phone on same Wi‑Fi same origin + proxy)", () => {
67
82
  setWindow({
68
83
  protocol: "http:",
69
84
  hostname: "192.168.1.42",
70
85
  host: "192.168.1.42:3000",
71
86
  port: "3000",
72
87
  });
73
- expect(getBackendHost()).toBe("http://192.168.1.42:3500");
88
+ expect(getBackendHost()).toBe("http://192.168.1.42:3000");
89
+ });
90
+
91
+ it("matches page origin when Vite listens on PORT=3500 — set VITE_API_PROXY_TARGET so /api proxies to Express (not the same TCP port)", () => {
92
+ setWindow({
93
+ protocol: "http:",
94
+ hostname: "localhost",
95
+ host: "localhost:3500",
96
+ port: "3500",
97
+ });
98
+ expect(getBackendHost()).toBe("http://localhost:3500");
99
+ });
100
+ });
101
+
102
+ describe("getBackendHost — local non-dev (e.g. jest NODE_ENV=test)", () => {
103
+ it("falls back to canonical API port :3500 when not in Vite development bundle", () => {
104
+ const orig = process.env.NODE_ENV;
105
+ process.env.NODE_ENV = "test";
106
+ try {
107
+ setWindow({
108
+ protocol: "http:",
109
+ hostname: "localhost",
110
+ host: "localhost:3000",
111
+ port: "3000",
112
+ });
113
+ expect(getBackendHost()).toBe("http://localhost:3500");
114
+ } finally {
115
+ if (orig === undefined) delete process.env.NODE_ENV;
116
+ else process.env.NODE_ENV = orig;
117
+ }
74
118
  });
75
119
  });
76
120
 
@@ -56,21 +56,38 @@ export function getFrontendHost(): string {
56
56
  }
57
57
 
58
58
  export function getBackendHost(): string {
59
+ if (typeof window === "undefined") {
60
+ return API_HOST;
61
+ }
62
+
59
63
  // Capacitor loads the app from a bundled origin whose hostname is `localhost`; we must
60
64
  // still use the baked-in API URL (REACT_APP_API), not localhost:3500.
61
65
  if (isCapacitorNativePlatform()) {
62
66
  return API_HOST;
63
67
  }
64
68
 
65
- // Browser: local dev uses API on :3500; deployed web uses API_HOST from env.
69
+ /**
70
+ * In Vite dev only (`NODE_ENV === "development"`), call `/api` on the same origin so
71
+ * `server.proxy['/api']` forwards to Express. Use port :3500 for local preview/tests
72
+ * where there's no proxy.
73
+ *
74
+ * Hard-coding `:3500` alone breaks when `PORT=3500` for Vite: fetches targeted Vite itself
75
+ * (404 on POST /public-event/…/door-qr instead of Express).
76
+ */
66
77
  const isLocalDev =
67
78
  window.location.hostname === "localhost" ||
68
79
  window.location.hostname === "127.0.0.1" ||
69
80
  window.location.hostname.includes("192.168");
70
81
 
71
- return isLocalDev
72
- ? `${window.location.protocol}//${window.location.hostname}:3500`
73
- : API_HOST;
82
+ if (!isLocalDev) {
83
+ return API_HOST;
84
+ }
85
+
86
+ if (process.env.NODE_ENV === "development") {
87
+ return `${window.location.protocol}//${window.location.host}`;
88
+ }
89
+
90
+ return `${window.location.protocol}//${window.location.hostname}:3500`;
74
91
  }
75
92
 
76
93
  export function getSsrBashDetailUrl(bashEventId: string | undefined): string {