git-subtree-dir: estore git-subtree-mainline: 746868d720b9be1003a2f783b7a12d526d8eea60 git-subtree-split: e34dfc397c94025670baa2b73b482c01f3033a6a
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
export interface Occasion {
|
||
key: string
|
||
label: string
|
||
emoji: string
|
||
blurb: string
|
||
/** The slug that Square items must have as their category to appear in this tab.
|
||
* Derived from the Square category name: lowercased, non-alphanumeric chars → '-'.
|
||
* If omitted the tab shows all products (fallback). */
|
||
squareCategorySlug?: string
|
||
ranges: (year: number) => Array<{ startMonth: number; startDay: number; endMonth: number; endDay: number }>
|
||
}
|
||
|
||
// ── Easter calculation (Anonymous Gregorian algorithm) ────────────────────────
|
||
function easterDate(year: number): Date {
|
||
const a = year % 19
|
||
const b = Math.floor(year / 100)
|
||
const c = year % 100
|
||
const d = Math.floor(b / 4)
|
||
const e = b % 4
|
||
const f = Math.floor((b + 8) / 25)
|
||
const g = Math.floor((b - f + 1) / 3)
|
||
const h = (19 * a + b - d - g + 15) % 30
|
||
const i = Math.floor(c / 4)
|
||
const k = c % 4
|
||
const l = (32 + 2 * e + 2 * i - h - k) % 7
|
||
const m = Math.floor((a + 11 * h + 22 * l) / 451)
|
||
const month = Math.floor((h + l - 7 * m + 114) / 31) // 1-12
|
||
const day = ((h + l - 7 * m + 114) % 31) + 1
|
||
return new Date(year, month - 1, day)
|
||
}
|
||
|
||
/** Returns the nth occurrence of a weekday (0=Sun…6=Sat) in a given month. */
|
||
function nthWeekday(year: number, month: number, weekday: number, n: number): Date {
|
||
const first = new Date(year, month, 1)
|
||
const offset = (weekday - first.getDay() + 7) % 7
|
||
return new Date(year, month, 1 + offset + (n - 1) * 7)
|
||
}
|
||
|
||
function addDays(d: Date, n: number): Date {
|
||
return new Date(d.getTime() + n * 86_400_000)
|
||
}
|
||
|
||
function toRange(start: Date, end: Date) {
|
||
return {
|
||
startMonth: start.getMonth() + 1,
|
||
startDay: start.getDate(),
|
||
endMonth: end.getMonth() + 1,
|
||
endDay: end.getDate(),
|
||
}
|
||
}
|
||
|
||
// ── Occasions ─────────────────────────────────────────────────────────────────
|
||
export const OCCASIONS: Occasion[] = [
|
||
{
|
||
key: 'new-year',
|
||
label: "New Year's",
|
||
emoji: '🥂',
|
||
blurb: "Ring in the new year in style — balloons for every party and celebration.",
|
||
squareCategorySlug: 'new-years',
|
||
ranges: (y) => [
|
||
toRange(new Date(y - 1, 11, 26), new Date(y - 1, 11, 31)), // Dec 26-31 prior year
|
||
toRange(new Date(y, 0, 1), new Date(y, 0, 3)), // Jan 1-3
|
||
],
|
||
},
|
||
{
|
||
key: 'valentine',
|
||
label: "Valentine's Day",
|
||
emoji: '❤️',
|
||
blurb: "Say it with balloons — romantic bouquets, arches, and custom arrangements.",
|
||
squareCategorySlug: 'valentines-day',
|
||
ranges: (y) => [toRange(new Date(y, 1, 1), new Date(y, 1, 14))],
|
||
},
|
||
{
|
||
key: 'st-patricks',
|
||
label: "St. Patrick's Day",
|
||
emoji: '🍀',
|
||
blurb: "Go green! Festive balloon décor for your St. Patrick's Day celebration.",
|
||
squareCategorySlug: 'st-patricks-day',
|
||
ranges: (y) => [toRange(new Date(y, 2, 10), new Date(y, 2, 17))],
|
||
},
|
||
{
|
||
key: 'easter',
|
||
label: 'Easter',
|
||
emoji: '🐣',
|
||
blurb: "Pastel bouquets, organic arrangements, and cheerful décor for Easter.",
|
||
squareCategorySlug: 'easter',
|
||
// Palm Sunday (−7) through Easter Monday (+1)
|
||
ranges: (y) => {
|
||
const e = easterDate(y)
|
||
return [toRange(addDays(e, -7), addDays(e, 1))]
|
||
},
|
||
},
|
||
{
|
||
key: 'mothers-day',
|
||
label: "Mother's Day",
|
||
emoji: '💐',
|
||
blurb: "Make mom feel extra special with a beautiful custom balloon arrangement.",
|
||
squareCategorySlug: 'mothers-day',
|
||
ranges: (y) => [toRange(new Date(y, 3, 28), new Date(y, 4, 12))],
|
||
},
|
||
{
|
||
key: 'graduation',
|
||
label: 'Graduation',
|
||
emoji: '🎓',
|
||
blurb: "Celebrate the big milestone — arches, columns, and bouquets for grads.",
|
||
squareCategorySlug: 'graduation',
|
||
ranges: (y) => [toRange(new Date(y, 4, 1), new Date(y, 5, 30))],
|
||
},
|
||
{
|
||
key: 'fathers-day',
|
||
label: "Father's Day",
|
||
emoji: '👔',
|
||
blurb: "Show dad some love — balloons and arrangements for the best dad around.",
|
||
squareCategorySlug: 'fathers-day',
|
||
// Third Sunday in June; window opens 2 weeks prior
|
||
ranges: (y) => {
|
||
const fd = nthWeekday(y, 5, 0, 3) // 3rd Sunday of June (month index 5)
|
||
return [toRange(addDays(fd, -14), fd)]
|
||
},
|
||
},
|
||
{
|
||
key: 'fourth-july',
|
||
label: '4th of July',
|
||
emoji: '🇺🇸',
|
||
blurb: "Red, white & blue — festive balloon décor for your Independence Day party.",
|
||
squareCategorySlug: '4th-of-july',
|
||
ranges: (y) => [toRange(new Date(y, 5, 25), new Date(y, 6, 4))],
|
||
},
|
||
{
|
||
key: 'back-to-school',
|
||
label: 'Back to School',
|
||
emoji: '🎒',
|
||
blurb: "Kick off the school year with fun balloon bouquets and decorations.",
|
||
squareCategorySlug: 'back-to-school',
|
||
ranges: (y) => [toRange(new Date(y, 7, 15), new Date(y, 8, 10))],
|
||
},
|
||
{
|
||
key: 'halloween',
|
||
label: 'Halloween',
|
||
emoji: '🎃',
|
||
blurb: "Spooky, fun, and festive — balloon décor for your Halloween bash.",
|
||
squareCategorySlug: 'halloween',
|
||
ranges: (y) => [toRange(new Date(y, 9, 1), new Date(y, 9, 31))],
|
||
},
|
||
{
|
||
key: 'thanksgiving',
|
||
label: 'Thanksgiving',
|
||
emoji: '🦃',
|
||
blurb: "Warm autumn arrangements and centerpieces for your Thanksgiving gathering.",
|
||
squareCategorySlug: 'thanksgiving',
|
||
ranges: (y) => [toRange(new Date(y, 10, 15), new Date(y, 10, 28))],
|
||
},
|
||
{
|
||
key: 'christmas',
|
||
label: 'Christmas',
|
||
emoji: '🎄',
|
||
blurb: "Deck the halls — festive balloon arches, columns, and centerpieces.",
|
||
squareCategorySlug: 'christmas',
|
||
ranges: (y) => [toRange(new Date(y, 11, 1), new Date(y, 11, 25))],
|
||
},
|
||
]
|
||
|
||
// ── Override config ───────────────────────────────────────────────────────────
|
||
|
||
export interface OccasionOverride {
|
||
enabled?: boolean // omit = true
|
||
squareCategorySlug?: string // omit = use occasion default
|
||
windowStart?: string // "MM-DD" — override computed start date
|
||
windowEnd?: string // "MM-DD" — override computed end date
|
||
}
|
||
|
||
/** A fully user-defined holiday (not in the built-in OCCASIONS list). */
|
||
export interface CustomOccasionDef {
|
||
custom: true
|
||
enabled?: boolean
|
||
label: string
|
||
emoji: string
|
||
blurb: string
|
||
squareCategorySlug?: string
|
||
windowStart: string // "MM-DD" required
|
||
windowEnd: string // "MM-DD" required
|
||
}
|
||
|
||
export type OccasionsConfig = Record<string, OccasionOverride | CustomOccasionDef>
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function mmddNum(mmdd: string): number {
|
||
const [mm, dd] = mmdd.split('-').map(Number)
|
||
return mm * 100 + dd
|
||
}
|
||
|
||
function mmddToDate(year: number, mmdd: string): Date {
|
||
const [mm, dd] = mmdd.split('-').map(Number)
|
||
return new Date(year, mm - 1, dd)
|
||
}
|
||
|
||
/**
|
||
* Returns the primary display window for an occasion in a given year,
|
||
* applying any windowStart / windowEnd overrides.
|
||
*/
|
||
export function getOccasionWindow(
|
||
occ: Occasion,
|
||
year: number,
|
||
ov?: Pick<OccasionOverride, 'windowStart' | 'windowEnd'>,
|
||
): { start: Date; end: Date } | null {
|
||
const ranges = occ.ranges(year)
|
||
if (!ranges.length) return null
|
||
// Use the last range as primary (handles New Year's which has two ranges)
|
||
const r = ranges[ranges.length - 1]
|
||
const start = ov?.windowStart
|
||
? mmddToDate(year, ov.windowStart)
|
||
: new Date(year, r.startMonth - 1, r.startDay)
|
||
const end = ov?.windowEnd
|
||
? mmddToDate(year, ov.windowEnd)
|
||
: new Date(year, r.endMonth - 1, r.endDay)
|
||
return { start, end }
|
||
}
|
||
|
||
/** Returns all occasions currently active for the given date, with optional config overrides. */
|
||
export function getActiveOccasions(date: Date = new Date(), config: OccasionsConfig = {}): Occasion[] {
|
||
const y = date.getFullYear()
|
||
const mmdd = (date.getMonth() + 1) * 100 + date.getDate()
|
||
|
||
const results: Occasion[] = []
|
||
|
||
// Built-in occasions with optional overrides
|
||
for (const occ of OCCASIONS) {
|
||
const ov = config[occ.key]
|
||
if (ov?.enabled === false) continue
|
||
|
||
let active = false
|
||
if (ov && !('custom' in ov) && (ov.windowStart || ov.windowEnd)) {
|
||
const win = getOccasionWindow(occ, y, ov)
|
||
if (win) {
|
||
const s = (win.start.getMonth() + 1) * 100 + win.start.getDate()
|
||
const e = (win.end.getMonth() + 1) * 100 + win.end.getDate()
|
||
active = mmdd >= s && mmdd <= e
|
||
}
|
||
} else {
|
||
active = occ.ranges(y).some(({ startMonth, startDay, endMonth, endDay }) =>
|
||
mmdd >= startMonth * 100 + startDay && mmdd <= endMonth * 100 + endDay
|
||
)
|
||
}
|
||
|
||
if (active) {
|
||
const slug = ov && !('custom' in ov) ? ov.squareCategorySlug : undefined
|
||
results.push(slug ? { ...occ, squareCategorySlug: slug } : occ)
|
||
}
|
||
}
|
||
|
||
// User-defined custom occasions
|
||
for (const [key, ov] of Object.entries(config)) {
|
||
if (!('custom' in ov)) continue
|
||
const def = ov as CustomOccasionDef
|
||
if (def.enabled === false) continue
|
||
const s = mmddNum(def.windowStart)
|
||
const e = mmddNum(def.windowEnd)
|
||
if (mmdd >= s && mmdd <= e) {
|
||
results.push({
|
||
key,
|
||
label: def.label,
|
||
emoji: def.emoji,
|
||
blurb: def.blurb,
|
||
squareCategorySlug: def.squareCategorySlug,
|
||
ranges: () => [],
|
||
})
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|