@bash-app/bash-common 30.228.0 → 30.230.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/dist/extendedSchemas.d.ts +16 -1
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +1 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/flyerUtils.test.js +55 -1
- package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
- package/dist/utils/__tests__/lobFlyerUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/lobFlyerUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/lobFlyerUtils.test.js +39 -0
- package/dist/utils/__tests__/lobFlyerUtils.test.js.map +1 -0
- package/dist/utils/__tests__/locationVisibility.test.d.ts +2 -0
- package/dist/utils/__tests__/locationVisibility.test.d.ts.map +1 -0
- package/dist/utils/__tests__/locationVisibility.test.js +172 -0
- package/dist/utils/__tests__/locationVisibility.test.js.map +1 -0
- package/dist/utils/flyerUtils.d.ts +19 -0
- package/dist/utils/flyerUtils.d.ts.map +1 -1
- package/dist/utils/flyerUtils.js +38 -0
- package/dist/utils/flyerUtils.js.map +1 -1
- package/dist/utils/lobFlyerUtils.d.ts +41 -0
- package/dist/utils/lobFlyerUtils.d.ts.map +1 -0
- package/dist/utils/lobFlyerUtils.js +68 -0
- package/dist/utils/lobFlyerUtils.js.map +1 -0
- package/dist/utils/locationVisibility.d.ts +66 -0
- package/dist/utils/locationVisibility.d.ts.map +1 -0
- package/dist/utils/locationVisibility.js +114 -0
- package/dist/utils/locationVisibility.js.map +1 -0
- package/dist/utils/service/serviceUtils.js +2 -2
- package/dist/utils/service/serviceUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +245 -61
- package/src/extendedSchemas.ts +16 -0
- package/src/index.ts +6 -0
- package/src/utils/__tests__/flyerUtils.test.ts +77 -0
- package/src/utils/__tests__/lobFlyerUtils.test.ts +58 -0
- package/src/utils/__tests__/locationVisibility.test.ts +248 -0
- package/src/utils/flyerUtils.ts +69 -0
- package/src/utils/lobFlyerUtils.ts +83 -0
- package/src/utils/locationVisibility.ts +165 -0
- package/src/utils/service/serviceUtils.ts +2 -2
|
@@ -6,17 +6,29 @@ import {
|
|
|
6
6
|
FLYER_PRICING_TIERS,
|
|
7
7
|
USPS_EDDM_RETAIL_MIN_MAILPIECES,
|
|
8
8
|
flyerQrScanPath,
|
|
9
|
+
flyerSponsorQrScanPath,
|
|
9
10
|
generateFlyerCampaignSlug,
|
|
10
11
|
distanceMiles,
|
|
12
|
+
FLYER_SPONSOR_DEFAULT_FEATURED_CENTS,
|
|
13
|
+
FLYER_SPONSOR_DEFAULT_SUPPORTING_CENTS,
|
|
14
|
+
FLYER_SPONSOR_ALLOWED_LOGO_MIME_TYPES,
|
|
15
|
+
FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH,
|
|
16
|
+
FLYER_SPONSOR_CATEGORY_MAX_LENGTH,
|
|
17
|
+
FLYER_SPONSOR_CTA_MAX_LENGTH,
|
|
18
|
+
FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH,
|
|
19
|
+
FLYER_SPONSOR_LOGO_MAX_BYTES,
|
|
20
|
+
validateFlyerSponsorCreative,
|
|
11
21
|
} from "../flyerUtils.js";
|
|
12
22
|
|
|
13
23
|
describe("flyerUtils", () => {
|
|
14
24
|
it("isEddmFlatSizeCompliant accepts 6.25 x 11", () => {
|
|
15
25
|
expect(isEddmFlatSizeCompliant(6.25, 11)).toBe(true);
|
|
26
|
+
expect(isEddmFlatSizeCompliant(11, 6.25)).toBe(true);
|
|
16
27
|
});
|
|
17
28
|
|
|
18
29
|
it("isEddmFlatSizeCompliant rejects too-small flats", () => {
|
|
19
30
|
expect(isEddmFlatSizeCompliant(4, 6)).toBe(false);
|
|
31
|
+
expect(isEddmFlatSizeCompliant(11, 6)).toBe(false);
|
|
20
32
|
});
|
|
21
33
|
|
|
22
34
|
it("quoteFlyerTierForHouseholds returns null below minimum", () => {
|
|
@@ -29,6 +41,17 @@ describe("flyerUtils", () => {
|
|
|
29
41
|
expect(q?.hostPriceCents).toBe(FLYER_PRICING_TIERS[0].hostPriceCents);
|
|
30
42
|
});
|
|
31
43
|
|
|
44
|
+
it("quoteFlyerTierForHouseholds covers every tier and caps above metro", () => {
|
|
45
|
+
expect(quoteFlyerTierForHouseholds(500)?.tierId).toBe("neighborhood");
|
|
46
|
+
expect(quoteFlyerTierForHouseholds(1500)?.tierId).toBe("district");
|
|
47
|
+
expect(quoteFlyerTierForHouseholds(4000)?.tierId).toBe("metro");
|
|
48
|
+
expect(quoteFlyerTierForHouseholds(50_000)?.tierId).toBe("metro");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("computeHostTotalAfterSponsors subtracts sponsor revenue when balance remains", () => {
|
|
52
|
+
expect(computeHostTotalAfterSponsors(32900, 10000)).toBe(22900);
|
|
53
|
+
});
|
|
54
|
+
|
|
32
55
|
it("computeHostTotalAfterSponsors never goes negative", () => {
|
|
33
56
|
expect(computeHostTotalAfterSponsors(32900, 50000)).toBe(0);
|
|
34
57
|
});
|
|
@@ -46,6 +69,60 @@ describe("flyerUtils", () => {
|
|
|
46
69
|
);
|
|
47
70
|
});
|
|
48
71
|
|
|
72
|
+
it("flyerSponsorQrScanPath encodes campaign and sponsor slugs", () => {
|
|
73
|
+
expect(flyerSponsorQrScanPath("fb&1", "sp=2")).toBe(
|
|
74
|
+
`/api/flyer/qr-scan?c=${encodeURIComponent(
|
|
75
|
+
"fb&1"
|
|
76
|
+
)}&s=${encodeURIComponent("sp=2")}`
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("default sponsor prices match the V1.5 slot model", () => {
|
|
81
|
+
expect(FLYER_SPONSOR_DEFAULT_FEATURED_CENTS).toBe(10000);
|
|
82
|
+
expect(FLYER_SPONSOR_DEFAULT_SUPPORTING_CENTS).toBe(5000);
|
|
83
|
+
expect(FLYER_SPONSOR_LOGO_MAX_BYTES).toBe(2 * 1024 * 1024);
|
|
84
|
+
expect(FLYER_SPONSOR_ALLOWED_LOGO_MIME_TYPES).toEqual([
|
|
85
|
+
"image/jpeg",
|
|
86
|
+
"image/png",
|
|
87
|
+
"image/webp",
|
|
88
|
+
"image/svg+xml",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("validates sponsor creative text limits and trims fields before checking", () => {
|
|
93
|
+
expect(
|
|
94
|
+
validateFlyerSponsorCreative({
|
|
95
|
+
businessName: ` ${"B".repeat(FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH)} `,
|
|
96
|
+
ctaText: ` ${"C".repeat(FLYER_SPONSOR_CTA_MAX_LENGTH)} `,
|
|
97
|
+
category: ` ${"D".repeat(FLYER_SPONSOR_CATEGORY_MAX_LENGTH)} `,
|
|
98
|
+
logoAltText: ` ${"E".repeat(FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH)} `,
|
|
99
|
+
}).ok
|
|
100
|
+
).toBe(true);
|
|
101
|
+
const invalid = validateFlyerSponsorCreative({
|
|
102
|
+
businessName: "",
|
|
103
|
+
ctaText: "x".repeat(FLYER_SPONSOR_CTA_MAX_LENGTH + 1),
|
|
104
|
+
category: "y".repeat(FLYER_SPONSOR_CATEGORY_MAX_LENGTH + 1),
|
|
105
|
+
logoAltText: "z".repeat(FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH + 1),
|
|
106
|
+
});
|
|
107
|
+
expect(invalid.ok).toBe(false);
|
|
108
|
+
expect(invalid.errors).toEqual([
|
|
109
|
+
"Business name is required.",
|
|
110
|
+
`Sponsor offer must be ${FLYER_SPONSOR_CTA_MAX_LENGTH} characters or fewer.`,
|
|
111
|
+
`Sponsor category must be ${FLYER_SPONSOR_CATEGORY_MAX_LENGTH} characters or fewer.`,
|
|
112
|
+
`Logo description must be ${FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH} characters or fewer.`,
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("validates business name maximum separately from required copy", () => {
|
|
117
|
+
const invalid = validateFlyerSponsorCreative({
|
|
118
|
+
businessName: "x".repeat(FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH + 1),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(invalid.errors).toEqual([
|
|
122
|
+
`Business name must be ${FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH} characters or fewer.`,
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
49
126
|
it("generateFlyerCampaignSlug yields fb prefix, fixed length, allowed charset", () => {
|
|
50
127
|
const rnd = jest.spyOn(Math, "random").mockReturnValue(0);
|
|
51
128
|
try {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
LOB_ADDRESSED_UNIT_CENTS_BY_SIZE,
|
|
4
|
+
LOB_BASH_MARGIN_BPS,
|
|
5
|
+
quoteLobAddressedPostageCents,
|
|
6
|
+
validateLob4x6WithPaidSponsors,
|
|
7
|
+
lobSizeToLobApiPostcardSize,
|
|
8
|
+
} from "../lobFlyerUtils.js";
|
|
9
|
+
|
|
10
|
+
describe("lobFlyerUtils", () => {
|
|
11
|
+
it("lobSizeToLobApiPostcardSize maps to Lob API string", () => {
|
|
12
|
+
expect(lobSizeToLobApiPostcardSize("LOB_4X6")).toBe("4x6");
|
|
13
|
+
expect(lobSizeToLobApiPostcardSize("LOB_6X9")).toBe("6x9");
|
|
14
|
+
expect(lobSizeToLobApiPostcardSize("LOB_6X11")).toBe("6x11");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("quoteLobAddressedPostageCents is zero for zero recipients", () => {
|
|
18
|
+
expect(quoteLobAddressedPostageCents("LOB_6X11", 0)).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("quoteLobAddressedPostageCents adds configured margin in bps", () => {
|
|
22
|
+
const n = 10;
|
|
23
|
+
const size = "LOB_6X11" as const;
|
|
24
|
+
const unit = LOB_ADDRESSED_UNIT_CENTS_BY_SIZE[size];
|
|
25
|
+
const subtotal = unit * n;
|
|
26
|
+
const expectedMargin = Math.round((subtotal * LOB_BASH_MARGIN_BPS) / 10000);
|
|
27
|
+
expect(quoteLobAddressedPostageCents(size, n)).toBe(
|
|
28
|
+
subtotal + expectedMargin
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("validateLob4x6WithPaidSponsors errors when 4x6 and paid sponsors", () => {
|
|
33
|
+
expect(
|
|
34
|
+
validateLob4x6WithPaidSponsors({
|
|
35
|
+
lobPostcardSize: "LOB_4X6",
|
|
36
|
+
blockingSponsorCount: 1,
|
|
37
|
+
})
|
|
38
|
+
).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("validateLob4x6WithPaidSponsors allows 4x6 with no paid sponsors", () => {
|
|
42
|
+
expect(
|
|
43
|
+
validateLob4x6WithPaidSponsors({
|
|
44
|
+
lobPostcardSize: "LOB_4X6",
|
|
45
|
+
blockingSponsorCount: 0,
|
|
46
|
+
})
|
|
47
|
+
).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("validateLob4x6WithPaidSponsors allows 6x11 with paid sponsors", () => {
|
|
51
|
+
expect(
|
|
52
|
+
validateLob4x6WithPaidSponsors({
|
|
53
|
+
lobPostcardSize: "LOB_6X11",
|
|
54
|
+
blockingSponsorCount: 2,
|
|
55
|
+
})
|
|
56
|
+
).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyBashEventLocationRedaction,
|
|
3
|
+
computeLocationPublicRevealDueAt,
|
|
4
|
+
defaultLocationRevealAudience,
|
|
5
|
+
geocodeQueryTextForPublicBashMap,
|
|
6
|
+
isLocationGloballyPublic,
|
|
7
|
+
redactBashEventLocation,
|
|
8
|
+
shouldAutoRevealLocationToPublicNow,
|
|
9
|
+
viewerCanSeeFullBashLocation,
|
|
10
|
+
viewerOwnsValidTicketForBash,
|
|
11
|
+
} from "../locationVisibility.js";
|
|
12
|
+
|
|
13
|
+
const baseBash = {
|
|
14
|
+
creatorId: "host-1",
|
|
15
|
+
hideLocation: true,
|
|
16
|
+
locationRevealedAt: null,
|
|
17
|
+
locationRevealAudience: "TicketHolders" as const,
|
|
18
|
+
locationRevealMode: "AtStartTime" as const,
|
|
19
|
+
locationRevealOffsetMins: null,
|
|
20
|
+
startDateTime: new Date("2026-06-15T20:00:00.000Z"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("viewerOwnsValidTicketForBash", () => {
|
|
24
|
+
it("returns false when no tickets", () => {
|
|
25
|
+
expect(viewerOwnsValidTicketForBash(undefined, "u1")).toBe(false);
|
|
26
|
+
expect(viewerOwnsValidTicketForBash([], "u1")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns true for non-cancelled ticket", () => {
|
|
30
|
+
expect(
|
|
31
|
+
viewerOwnsValidTicketForBash(
|
|
32
|
+
[{ status: "CheckedIn", ownerId: "u1" }],
|
|
33
|
+
"u1"
|
|
34
|
+
)
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("excludes Cancelled and WaitlistPending", () => {
|
|
39
|
+
expect(
|
|
40
|
+
viewerOwnsValidTicketForBash(
|
|
41
|
+
[{ status: "Cancelled", ownerId: "u1" }],
|
|
42
|
+
"u1"
|
|
43
|
+
)
|
|
44
|
+
).toBe(false);
|
|
45
|
+
expect(
|
|
46
|
+
viewerOwnsValidTicketForBash(
|
|
47
|
+
[{ status: "WaitlistPending", ownerId: "u1" }],
|
|
48
|
+
"u1"
|
|
49
|
+
)
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("viewerCanSeeFullBashLocation", () => {
|
|
55
|
+
it("allows full location when hideLocation is false", () => {
|
|
56
|
+
expect(
|
|
57
|
+
viewerCanSeeFullBashLocation(
|
|
58
|
+
{ ...baseBash, hideLocation: false },
|
|
59
|
+
{ viewerUserId: null, isHostOrOrganizer: false, viewerOwnsValidTicket: false, viewerPublicRsvpGoing: false, viewerHasAcceptedInvitation: false }
|
|
60
|
+
)
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("allows when globally revealed", () => {
|
|
65
|
+
expect(
|
|
66
|
+
viewerCanSeeFullBashLocation(
|
|
67
|
+
{ ...baseBash, locationRevealedAt: new Date() },
|
|
68
|
+
{ viewerUserId: null, isHostOrOrganizer: false, viewerOwnsValidTicket: false, viewerPublicRsvpGoing: false, viewerHasAcceptedInvitation: false }
|
|
69
|
+
)
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("allows host or organizer", () => {
|
|
74
|
+
expect(
|
|
75
|
+
viewerCanSeeFullBashLocation(baseBash, {
|
|
76
|
+
viewerUserId: "host-1",
|
|
77
|
+
isHostOrOrganizer: true,
|
|
78
|
+
viewerOwnsValidTicket: false,
|
|
79
|
+
viewerPublicRsvpGoing: false,
|
|
80
|
+
viewerHasAcceptedInvitation: false,
|
|
81
|
+
})
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("TicketHolders: only ticket", () => {
|
|
86
|
+
const ctxNo = {
|
|
87
|
+
viewerUserId: "u2",
|
|
88
|
+
isHostOrOrganizer: false,
|
|
89
|
+
viewerOwnsValidTicket: false,
|
|
90
|
+
viewerPublicRsvpGoing: true,
|
|
91
|
+
viewerHasAcceptedInvitation: true,
|
|
92
|
+
};
|
|
93
|
+
expect(viewerCanSeeFullBashLocation(baseBash, ctxNo)).toBe(false);
|
|
94
|
+
expect(
|
|
95
|
+
viewerCanSeeFullBashLocation(baseBash, { ...ctxNo, viewerOwnsValidTicket: true })
|
|
96
|
+
).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("TicketHoldersAndRsvps: Going without ticket", () => {
|
|
100
|
+
const bash = { ...baseBash, locationRevealAudience: "TicketHoldersAndRsvps" as const };
|
|
101
|
+
expect(
|
|
102
|
+
viewerCanSeeFullBashLocation(bash, {
|
|
103
|
+
viewerUserId: "u2",
|
|
104
|
+
isHostOrOrganizer: false,
|
|
105
|
+
viewerOwnsValidTicket: false,
|
|
106
|
+
viewerPublicRsvpGoing: true,
|
|
107
|
+
viewerHasAcceptedInvitation: false,
|
|
108
|
+
})
|
|
109
|
+
).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("TicketHoldersAndInvited: accepted invite without ticket or RSVP", () => {
|
|
113
|
+
const bash = { ...baseBash, locationRevealAudience: "TicketHoldersAndInvited" as const };
|
|
114
|
+
expect(
|
|
115
|
+
viewerCanSeeFullBashLocation(bash, {
|
|
116
|
+
viewerUserId: "u2",
|
|
117
|
+
isHostOrOrganizer: false,
|
|
118
|
+
viewerOwnsValidTicket: false,
|
|
119
|
+
viewerPublicRsvpGoing: false,
|
|
120
|
+
viewerHasAcceptedInvitation: true,
|
|
121
|
+
})
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("isLocationGloballyPublic", () => {
|
|
127
|
+
it("true when not hidden or revealed at set", () => {
|
|
128
|
+
expect(isLocationGloballyPublic({ ...baseBash, hideLocation: false })).toBe(true);
|
|
129
|
+
expect(isLocationGloballyPublic({ ...baseBash, locationRevealedAt: new Date() })).toBe(true);
|
|
130
|
+
expect(isLocationGloballyPublic(baseBash)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("redactBashEventLocation / applyBashEventLocationRedaction", () => {
|
|
135
|
+
it("clears sensitive fields", () => {
|
|
136
|
+
const e = {
|
|
137
|
+
id: "e1",
|
|
138
|
+
hideLocation: true,
|
|
139
|
+
location: "a|b",
|
|
140
|
+
street: "1 Main",
|
|
141
|
+
zipCode: "10001",
|
|
142
|
+
venueId: "v1",
|
|
143
|
+
city: "NYC",
|
|
144
|
+
state: "NY",
|
|
145
|
+
country: "US",
|
|
146
|
+
coordinates: [{ id: "c1", latitude: 1, longitude: 2 }],
|
|
147
|
+
};
|
|
148
|
+
const r = redactBashEventLocation(e);
|
|
149
|
+
expect(r.location).toBeNull();
|
|
150
|
+
expect(r.street).toBeNull();
|
|
151
|
+
expect(r.zipCode).toBeNull();
|
|
152
|
+
expect(r.venueId).toBeNull();
|
|
153
|
+
expect(r.coordinates).toEqual([]);
|
|
154
|
+
expect(r.city).toBe("NYC");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("apply skips when canSeeFull", () => {
|
|
158
|
+
const e = { hideLocation: true, location: "x", street: "y", zipCode: "z", venueId: null, coordinates: [] };
|
|
159
|
+
const r = applyBashEventLocationRedaction(e, true);
|
|
160
|
+
expect(r.location).toBe("x");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("computeLocationPublicRevealDueAt / shouldAutoRevealLocationToPublicNow", () => {
|
|
165
|
+
it("AtStartTime returns start", () => {
|
|
166
|
+
const start = new Date("2026-01-10T18:00:00.000Z");
|
|
167
|
+
expect(
|
|
168
|
+
computeLocationPublicRevealDueAt({
|
|
169
|
+
...baseBash,
|
|
170
|
+
startDateTime: start,
|
|
171
|
+
locationRevealMode: "AtStartTime",
|
|
172
|
+
})
|
|
173
|
+
).toEqual(start);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("CustomOffset subtracts minutes", () => {
|
|
177
|
+
const start = new Date("2026-01-10T18:00:00.000Z");
|
|
178
|
+
const due = computeLocationPublicRevealDueAt({
|
|
179
|
+
...baseBash,
|
|
180
|
+
startDateTime: start,
|
|
181
|
+
locationRevealMode: "CustomOffset",
|
|
182
|
+
locationRevealOffsetMins: 120,
|
|
183
|
+
});
|
|
184
|
+
expect(due?.getTime()).toBe(start.getTime() - 120 * 60_000);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("ManualOnly returns null", () => {
|
|
188
|
+
expect(
|
|
189
|
+
computeLocationPublicRevealDueAt({
|
|
190
|
+
...baseBash,
|
|
191
|
+
locationRevealMode: "ManualOnly",
|
|
192
|
+
})
|
|
193
|
+
).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("shouldAutoRevealLocationToPublicNow respects time", () => {
|
|
197
|
+
const bash = {
|
|
198
|
+
...baseBash,
|
|
199
|
+
locationRevealMode: "AtStartTime" as const,
|
|
200
|
+
startDateTime: new Date("2026-01-10T18:00:00.000Z"),
|
|
201
|
+
};
|
|
202
|
+
expect(shouldAutoRevealLocationToPublicNow(bash, new Date("2026-01-10T17:59:00.000Z"))).toBe(false);
|
|
203
|
+
expect(shouldAutoRevealLocationToPublicNow(bash, new Date("2026-01-10T18:00:00.000Z"))).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("ManualOnly never auto-reveals", () => {
|
|
207
|
+
expect(
|
|
208
|
+
shouldAutoRevealLocationToPublicNow(
|
|
209
|
+
{ ...baseBash, locationRevealMode: "ManualOnly", startDateTime: new Date("2020-01-01") },
|
|
210
|
+
new Date("2030-01-01")
|
|
211
|
+
)
|
|
212
|
+
).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("defaultLocationRevealAudience", () => {
|
|
217
|
+
it("matches paid vs free default", () => {
|
|
218
|
+
expect(defaultLocationRevealAudience(true)).toBe("TicketHolders");
|
|
219
|
+
expect(defaultLocationRevealAudience(false)).toBe("TicketHoldersAndRsvps");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("geocodeQueryTextForPublicBashMap", () => {
|
|
224
|
+
it("uses city when location redacted", () => {
|
|
225
|
+
expect(
|
|
226
|
+
geocodeQueryTextForPublicBashMap({
|
|
227
|
+
location: null,
|
|
228
|
+
hideLocation: true,
|
|
229
|
+
locationRevealedAt: null,
|
|
230
|
+
city: "Brooklyn",
|
|
231
|
+
state: "NY",
|
|
232
|
+
country: "US",
|
|
233
|
+
})
|
|
234
|
+
).toBe("Brooklyn, NY, US");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("prefers full location when present", () => {
|
|
238
|
+
expect(
|
|
239
|
+
geocodeQueryTextForPublicBashMap({
|
|
240
|
+
location: "Venue|123 St",
|
|
241
|
+
hideLocation: true,
|
|
242
|
+
city: "Brooklyn",
|
|
243
|
+
state: "NY",
|
|
244
|
+
country: "US",
|
|
245
|
+
})
|
|
246
|
+
).toBe("Venue|123 St");
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/utils/flyerUtils.ts
CHANGED
|
@@ -60,6 +60,65 @@ export const FLYER_PRICING_TIERS: FlyerTierQuote[] = [
|
|
|
60
60
|
/** Default sponsor slot list prices (Utah / intermountain — cents). */
|
|
61
61
|
export const FLYER_SPONSOR_DEFAULT_FEATURED_CENTS = 10000;
|
|
62
62
|
export const FLYER_SPONSOR_DEFAULT_SUPPORTING_CENTS = 5000;
|
|
63
|
+
export const FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH = 48;
|
|
64
|
+
export const FLYER_SPONSOR_CTA_MAX_LENGTH = 72;
|
|
65
|
+
export const FLYER_SPONSOR_CATEGORY_MAX_LENGTH = 40;
|
|
66
|
+
export const FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH = 90;
|
|
67
|
+
export const FLYER_SPONSOR_LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
|
68
|
+
export const FLYER_SPONSOR_ALLOWED_LOGO_MIME_TYPES = [
|
|
69
|
+
"image/jpeg",
|
|
70
|
+
"image/png",
|
|
71
|
+
"image/webp",
|
|
72
|
+
"image/svg+xml",
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
export interface FlyerSponsorCreativeInput {
|
|
76
|
+
businessName?: string | null;
|
|
77
|
+
ctaText?: string | null;
|
|
78
|
+
category?: string | null;
|
|
79
|
+
logoAltText?: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FlyerSponsorCreativeValidationResult {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
errors: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateFlyerSponsorCreative(
|
|
88
|
+
input: FlyerSponsorCreativeInput
|
|
89
|
+
): FlyerSponsorCreativeValidationResult {
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
const businessName = input.businessName?.trim() ?? "";
|
|
92
|
+
const ctaText = input.ctaText?.trim() ?? "";
|
|
93
|
+
const category = input.category?.trim() ?? "";
|
|
94
|
+
const logoAltText = input.logoAltText?.trim() ?? "";
|
|
95
|
+
|
|
96
|
+
if (!businessName) {
|
|
97
|
+
errors.push("Business name is required.");
|
|
98
|
+
}
|
|
99
|
+
if (businessName.length > FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH) {
|
|
100
|
+
errors.push(
|
|
101
|
+
`Business name must be ${FLYER_SPONSOR_BUSINESS_NAME_MAX_LENGTH} characters or fewer.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (ctaText.length > FLYER_SPONSOR_CTA_MAX_LENGTH) {
|
|
105
|
+
errors.push(
|
|
106
|
+
`Sponsor offer must be ${FLYER_SPONSOR_CTA_MAX_LENGTH} characters or fewer.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (category.length > FLYER_SPONSOR_CATEGORY_MAX_LENGTH) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`Sponsor category must be ${FLYER_SPONSOR_CATEGORY_MAX_LENGTH} characters or fewer.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (logoAltText.length > FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH) {
|
|
115
|
+
errors.push(
|
|
116
|
+
`Logo description must be ${FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH} characters or fewer.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ok: errors.length === 0, errors };
|
|
121
|
+
}
|
|
63
122
|
|
|
64
123
|
export function isEddmFlatSizeCompliant(widthIn: number, heightIn: number): boolean {
|
|
65
124
|
const long = Math.max(widthIn, heightIn);
|
|
@@ -124,6 +183,16 @@ export function flyerQrScanPath(campaignQrSlug: string): string {
|
|
|
124
183
|
return `/api/flyer/qr-scan?c=${encodeURIComponent(campaignQrSlug)}`;
|
|
125
184
|
}
|
|
126
185
|
|
|
186
|
+
/** URL path for sponsor-specific scans on a campaign flyer. */
|
|
187
|
+
export function flyerSponsorQrScanPath(
|
|
188
|
+
campaignQrSlug: string,
|
|
189
|
+
sponsorQrSlug: string
|
|
190
|
+
): string {
|
|
191
|
+
return `${flyerQrScanPath(campaignQrSlug)}&s=${encodeURIComponent(
|
|
192
|
+
sponsorQrSlug
|
|
193
|
+
)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
127
196
|
/**
|
|
128
197
|
* Short slug for campaign QR (avoid ambiguous chars); unique enforced in DB.
|
|
129
198
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lob addressed postcard economics and trim sizes.
|
|
3
|
+
*
|
|
4
|
+
* Do not use EDDM (`flyerUtils.ts`) per-piece rates for Lob — different product.
|
|
5
|
+
*
|
|
6
|
+
* **Pricing source:** Replace `LOB_ADDRESSED_UNIT_CENTS_BY_SIZE` after verifying
|
|
7
|
+
* print+postage with your Lob dashboard or account rep (note the date in comments
|
|
8
|
+
* when you update). Lob and USPS change rates periodically.
|
|
9
|
+
*
|
|
10
|
+
* **Trim:** Art for Lob must match these exact dimensions (not 6.25×11).
|
|
11
|
+
* See Lob artboard docs for bleed; these are trim sizes used for PDF page size.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type LobPostcardSizeKey = "LOB_4X6" | "LOB_6X9" | "LOB_6X11";
|
|
15
|
+
|
|
16
|
+
/** Trim width × height in inches (portrait), matching Lob postcard products. */
|
|
17
|
+
export const LOB_POSTCARD_TRIM_IN: Record<
|
|
18
|
+
LobPostcardSizeKey,
|
|
19
|
+
{ widthIn: number; heightIn: number }
|
|
20
|
+
> = {
|
|
21
|
+
LOB_4X6: { widthIn: 4, heightIn: 6 },
|
|
22
|
+
LOB_6X9: { widthIn: 6, heightIn: 9 },
|
|
23
|
+
LOB_6X11: { widthIn: 6, heightIn: 11 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Verified placeholder unit cost (print + postage to mailbox) in US cents per piece.
|
|
28
|
+
* TODO: replace with values from Lob dashboard for your mail class (e.g. first class).
|
|
29
|
+
* Last reviewed placeholder: 2026-04-26 — not for production pricing without verification.
|
|
30
|
+
*/
|
|
31
|
+
export const LOB_ADDRESSED_UNIT_CENTS_BY_SIZE: Record<LobPostcardSizeKey, number> = {
|
|
32
|
+
LOB_4X6: 85,
|
|
33
|
+
LOB_6X9: 98,
|
|
34
|
+
LOB_6X11: 110,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Bash platform margin on top of Lob unit cost (basis points, e.g. 1500 = 15%). */
|
|
38
|
+
export const LOB_BASH_MARGIN_BPS = 1500;
|
|
39
|
+
|
|
40
|
+
export function lobSizeToLobApiPostcardSize(size: LobPostcardSizeKey): "4x6" | "6x9" | "6x11" {
|
|
41
|
+
switch (size) {
|
|
42
|
+
case "LOB_4X6":
|
|
43
|
+
return "4x6";
|
|
44
|
+
case "LOB_6X9":
|
|
45
|
+
return "6x9";
|
|
46
|
+
case "LOB_6X11":
|
|
47
|
+
return "6x11";
|
|
48
|
+
default: {
|
|
49
|
+
const _x: never = size;
|
|
50
|
+
return _x;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Total print+postage charged to host before sponsor credits (integer cents).
|
|
57
|
+
*/
|
|
58
|
+
export function quoteLobAddressedPostageCents(
|
|
59
|
+
size: LobPostcardSizeKey,
|
|
60
|
+
recipientCount: number
|
|
61
|
+
): number {
|
|
62
|
+
if (recipientCount < 1) return 0;
|
|
63
|
+
const unit = LOB_ADDRESSED_UNIT_CENTS_BY_SIZE[size];
|
|
64
|
+
const subtotal = unit * recipientCount;
|
|
65
|
+
const margin = Math.round((subtotal * LOB_BASH_MARGIN_BPS) / 10000);
|
|
66
|
+
return subtotal + margin;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Product rule: 4×6 does not support paid sponsor slots that require the full sponsor strip.
|
|
71
|
+
* Returns an error message if invalid, or undefined if OK.
|
|
72
|
+
*/
|
|
73
|
+
export function validateLob4x6WithPaidSponsors(params: {
|
|
74
|
+
lobPostcardSize: LobPostcardSizeKey;
|
|
75
|
+
/** Count of sponsor rows with slotPriceCents > 0 or paidAt set */
|
|
76
|
+
blockingSponsorCount: number;
|
|
77
|
+
}): string | undefined {
|
|
78
|
+
if (params.lobPostcardSize !== "LOB_4X6") return undefined;
|
|
79
|
+
if (params.blockingSponsorCount > 0) {
|
|
80
|
+
return "4×6 flyers cannot include paid sponsor slots. Choose 6×9 or 6×11, or remove sponsors.";
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|