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 // ── 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, ): { 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 }