balloon-shop/estore/src/lib/occasions.ts
chris 21ebb9667b Add 'estore/' from commit 'e34dfc397c94025670baa2b73b482c01f3033a6a'
git-subtree-dir: estore
git-subtree-mainline: 746868d720b9be1003a2f783b7a12d526d8eea60
git-subtree-split: e34dfc397c94025670baa2b73b482c01f3033a6a
2026-04-13 19:22:23 -04:00

273 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}