breakout into monorepo

This commit is contained in:
Dominic Ferrando
2026-03-02 13:39:47 -05:00
parent 6421f6527f
commit 45acec2cfd
90 changed files with 12179 additions and 2987 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@blade-and-brawn/calculator",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"ml-levenberg-marquardt": "^5.0.0"
},
"exports": {
".": "./src/index.ts"
}
}
+79
View File
@@ -0,0 +1,79 @@
{
"global": {
"maxLevel": 100
},
"activity": {
"BackSquat": {
"enableGeneration": true,
"weightModifier": 0,
"weightSkew": 0,
"ageModifier": 0,
"difficultyModifier": 0.3,
"peakAge": 0,
"stretch": {
"upper": 0,
"lower": 1
}
},
"BenchPress": {
"enableGeneration": true,
"weightModifier": 0,
"weightSkew": 0,
"ageModifier": 0,
"difficultyModifier": 0.05,
"peakAge": 0,
"stretch": {
"upper": 1,
"lower": 0
}
},
"Deadlift": {
"enableGeneration": true,
"weightModifier": 0,
"weightSkew": 0,
"ageModifier": 0,
"difficultyModifier": -0.05,
"peakAge": 0,
"stretch": {
"upper": 1,
"lower": 0
}
},
"Run": {
"enableGeneration": true,
"weightModifier": 0.1,
"weightSkew": 0,
"ageModifier": 0,
"difficultyModifier": 0.35,
"peakAge": 0,
"stretch": {
"upper": 1,
"lower": 1
}
},
"BroadJump": {
"enableGeneration": true,
"weightModifier": -0.1,
"weightSkew": 0,
"ageModifier": -0.25,
"difficultyModifier": -0.15,
"peakAge": 23,
"stretch": {
"upper": 3,
"lower": 1
}
},
"ConeDrill": {
"enableGeneration": true,
"weightModifier": -0.1,
"weightSkew": 0,
"ageModifier": -0.25,
"difficultyModifier": 0.15,
"peakAge": 23,
"stretch": {
"upper": 0,
"lower": 2
}
}
}
}
@@ -0,0 +1,107 @@
{
"metadata": {
"unit": "kg"
},
"weights": [
{
"gender": "Male",
"age": 5,
"weight": 21.1
},
{
"gender": "Male",
"age": 10,
"weight": 38.7
},
{
"gender": "Male",
"age": 15,
"weight": 66.1
},
{
"gender": "Male",
"age": 20,
"weight": 81.3
},
{
"gender": "Male",
"age": 30,
"weight": 89.7
},
{
"gender": "Male",
"age": 40,
"weight": 90.5
},
{
"gender": "Male",
"age": 50,
"weight": 89.6
},
{
"gender": "Male",
"age": 60,
"weight": 89.5
},
{
"gender": "Male",
"age": 70,
"weight": 89.3
},
{
"gender": "Male",
"age": 80,
"weight": 79.6
},
{
"gender": "Female",
"age": 5,
"weight": 21.0
},
{
"gender": "Female",
"age": 10,
"weight": 39.5
},
{
"gender": "Female",
"age": 15,
"weight": 58.1
},
{
"gender": "Female",
"age": 20,
"weight": 69.5
},
{
"gender": "Female",
"age": 30,
"weight": 73.3
},
{
"gender": "Female",
"age": 40,
"weight": 75.2
},
{
"gender": "Female",
"age": 50,
"weight": 74.6
},
{
"gender": "Female",
"age": 60,
"weight": 75.1
},
{
"gender": "Female",
"age": 70,
"weight": 73.2
},
{
"gender": "Female",
"age": 80,
"weight": 66.3
}
]
}
File diff suppressed because it is too large Load Diff
+902
View File
@@ -0,0 +1,902 @@
import {
Activity,
Attribute,
Gender,
getAvgWeight,
kgToLb,
lbToKg,
range,
type ActivityPerformance,
type Player,
type StandardUnit,
} from "../src/util";
import { levenbergMarquardt as LM } from "ml-levenberg-marquardt";
import defaultConfig from "./config.json" assert { type: "json" };
import defaultStandardsJson from "./data/default-standards.json" assert { type: "json" };
// SOURCES
// Squat, Bench, Dead Lift:
// http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf
// 1 mile run:
// https://runninglevel.com/running-times/1-mile-times
// Broad Jump:
// https://nrpt.co.uk/training/tests/power/broad.htm
// 3 Cone drill:
// https://nflsavant.com/combine.php?utm_source=chatgpt.com
export * from "./util"
export type LevelCalculatorOutput = {
player: number;
attributes: Record<Attribute, number>;
};
export type Levels = Record<string, number>;
export interface Metrics {
age: number;
weight: number;
gender: Gender;
}
type NumberMetric = Extract<keyof Metrics, "age" | "weight">;
export interface Standard {
metrics: Metrics;
levels: Levels;
}
type Generator = {
metric: NumberMetric;
};
export type StandardsData = Record<
Activity,
{
metadata: {
attribute: Attribute;
generators: Generator[];
unit: StandardUnit;
name: string;
};
standards: Standard[];
}
>;
const metricPriority = (m: NumberMetric) => {
if (m === "age") return 0;
if (m === "weight") return 1;
return 2;
};
export const DEFAULT_STANDARDS_DATA = defaultStandardsJson as StandardsData;
export class LevelCalculator {
standards: Standards;
public constructor(standards: Standards) {
this.standards = standards;
}
// -------------------------------------------------------------------------------------------------
// Level calculations
// -------------------------------------------------------------------------------------------------
public calculate(
player: Player,
activityPerformances: ActivityPerformance[],
): LevelCalculatorOutput {
const levels: LevelCalculatorOutput = {
player: 0,
attributes: {
[Attribute.Strength]: 0,
[Attribute.Power]: 0,
[Attribute.Endurance]: 0,
[Attribute.Agility]: 0,
} as const,
};
for (const value of Object.values(player)) {
if (!value) return levels;
}
levels.attributes = this.calculateAllAttributeLevels(
player,
activityPerformances,
);
for (const level of Object.values(levels.attributes)) {
if (!level) return levels;
}
const attrLevelsSum = Object.values(levels.attributes).reduce(
(sum, lvl) => sum + lvl,
0,
);
levels.player = Math.round(
attrLevelsSum / Object.keys(Attribute).length,
);
return levels;
}
private calculateAllAttributeLevels(
player: Player,
activityPerformances: ActivityPerformance[],
): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[Attribute.Strength]: 0,
[Attribute.Power]: 0,
[Attribute.Endurance]: 0,
[Attribute.Agility]: 0,
} as const;
for (const value of Object.values(player)) {
if (!value) return attrLevels;
}
(Object.values(Attribute) as Attribute[]).forEach((attribute) => {
const attrActivityPerformances = activityPerformances.filter(
(p) =>
this.standards.byActivity(p.activity).getMetadata()
.attribute === attribute,
);
attrLevels[attribute] = this.calculateAttributeLevel(
attribute,
player,
attrActivityPerformances,
);
});
return attrLevels;
}
private calculateAttributeLevel(
attribute: Attribute,
player: Player,
activityPerformances: ActivityPerformance[],
): number {
// must be activities of the given attribute
if (
activityPerformances.some(
(p) =>
this.standards.byActivity(p.activity).getMetadata()
.attribute !== attribute,
)
) {
throw new Error("Wrong activity performance attribute");
}
// must perform all of an attributes activities
for (const activity of this.standards.getAttributeActivities(
attribute,
)) {
const filteredActivites = activityPerformances.map(
(p) => p.activity,
);
if (!filteredActivites.includes(activity)) return 0;
}
// must have all activties using a valid performance value (> 0)
for (const activityPerformance of activityPerformances) {
if (activityPerformance.performance <= 0) return 0;
}
const activityLevels = activityPerformances.map((p) => {
const interpolatedStandard = this.standards
.byActivity(p.activity)
.byMetrics(player.metrics)
.getOneInterpolated();
return this.findLevel(interpolatedStandard, p.performance);
});
const activityLevelsAvg = Math.round(
activityLevels.reduce((sum, curr) => sum + curr, 0) /
activityLevels.length,
);
return activityLevelsAvg;
}
// -------------------------------------------------------------------------------------------------
// Level utilities (helpers)
// -------------------------------------------------------------------------------------------------
private findLevel(standard: Standard, performance: number): number {
let resultLevel = 1;
let resultLevelDiff = Infinity;
for (const level in standard.levels) {
if (!standard.levels[level]) continue;
const diff = Math.abs(performance - standard.levels[level]);
if (diff < resultLevelDiff) {
resultLevel = +level;
resultLevelDiff = diff;
}
}
return resultLevel;
}
}
export type StandardsConfig = {
global: {
maxLevel: number;
};
activity: Record<
Activity,
{
weightModifier: number;
weightSkew: number;
ageModifier: number;
enableGeneration: boolean;
difficultyModifier: number;
peakAge: number;
stretch: {
upper: number;
lower: number;
};
}
>;
};
export class Standards {
readonly cfg: StandardsConfig;
private standardsData: StandardsData;
constructor(
standardsData: StandardsData,
cfg?: Partial<StandardsConfig>,
) {
this.cfg = Object.assign(defaultConfig, cfg);
// prepare data
this.standardsData = structuredClone(standardsData);
// STRETCH
for (const activity of Object.values(Activity)) {
const BASE_MAX_LEVEL = 5;
function expDecayModel([A, B, C]: number[]) {
return (i: number) => A * Math.exp(-B * i) + C;
}
if (!this.cfg.activity[activity].enableGeneration) {
continue;
}
for (const gender of Object.values(Gender)) {
const standardsByGender = this.standardsData[
activity
].standards.filter((s) => s["metrics"].gender === gender);
const ages = [
...new Set(standardsByGender.map((s) => s.metrics.age)),
];
for (const age of ages) {
const standards = standardsByGender.filter(
(s) => s.metrics.age === age,
);
const data = {
x: [] as number[],
y: [] as number[],
};
const isIncreasing =
standards[0].levels["2"] > standards[0].levels["1"];
for (const i of range(BASE_MAX_LEVEL).slice(0, -1)) {
const level = i + 1;
const progressionRatios: number[] = [];
for (const standard of standards) {
const curr = standard.levels[level];
const next = standard.levels[level + 1];
progressionRatios.push(
isIncreasing ? next / curr : curr / next,
);
}
const sum = progressionRatios.reduce(
(p, c) => p + c,
0,
);
const progressionRatioAvg =
sum / progressionRatios.length;
data.x.push(i);
data.y.push(progressionRatioAvg);
}
const fittedParams = LM(data, expDecayModel, {
initialValues: [0.4, 0.5, 1.1],
minValues: [0.0, 0.0, 1.02],
maxValues: [1.0, 2.0, 1.2],
maxIterations: 200,
});
const getDecayRatio = (i: number) =>
expDecayModel(fittedParams.parameterValues)(i);
// UPDATE THE DATA
for (const standard of standards) {
const newLevels: Levels = {};
const newLowerLevelCount = Math.max(
Math.round(
this.cfg.activity[activity].stretch.lower,
),
0,
);
const newUpperLevelCount = Math.max(
Math.round(
this.cfg.activity[activity].stretch.upper,
),
0,
);
const oldLevels = standard.levels as Levels;
let prev = oldLevels["1"];
for (const i of range(newLowerLevelCount).reverse()) {
const level = i + 1;
const ratio = getDecayRatio(-i - 1);
prev = isIncreasing ? prev / ratio : prev * ratio;
newLevels[level] = Math.round(prev);
}
for (const i of range(BASE_MAX_LEVEL)) {
const level = i + 1;
newLevels[level + newLowerLevelCount] =
oldLevels[level];
}
prev = oldLevels[BASE_MAX_LEVEL];
for (const i of range(newUpperLevelCount)) {
const level =
BASE_MAX_LEVEL + newLowerLevelCount + (i + 1);
const ratio = getDecayRatio(level - 1);
prev = isIncreasing ? prev * ratio : prev / ratio;
newLevels[level] = Math.round(prev);
}
standard.levels = newLevels;
}
}
}
}
// EXPAND, COMPRESS, SKEW
for (const activity of Object.values(Activity)) {
for (const standard of this.standardsData[activity].standards) {
// expand
let i = 1;
while (
Object.keys(standard.levels).length <
this.cfg.global.maxLevel
) {
standard.levels = this.expandLevels(standard.levels, i++);
}
// compress
if (
Object.keys(standard.levels).length >
this.cfg.global.maxLevel
) {
standard.levels = this.compressLevels(
standard.levels,
this.cfg.global.maxLevel,
);
}
// apply skew config
for (const level in standard.levels) {
standard.levels[level] =
standard.levels[level] *
(1 + this.cfg.activity[activity].difficultyModifier);
}
}
// generate data
const allGenerators = this.cfg.activity[activity].enableGeneration
? this.standardsData[activity].metadata.generators.sort(
(a, b) =>
metricPriority(a.metric) - metricPriority(b.metric),
)
: [];
for (const generator of allGenerators) {
if (generator.metric === "age") {
for (const gender of Object.values(Gender)) {
const peakAge = this.cfg.activity[activity].peakAge;
const ageStep = 10;
const minAge = 0;
const maxAge = 100;
const referenceStandard = this.byActivity(activity)
.byMetrics({
weight: getAvgWeight(gender, peakAge),
gender,
age: peakAge,
})
.getOneInterpolated();
let currAge = minAge;
while (currAge <= maxAge) {
const newStandard: Standard = {
metrics: {
weight: getAvgWeight(gender, currAge),
age: currAge,
gender,
},
levels: {},
};
for (const lvl in referenceStandard.levels) {
const base = referenceStandard.levels[lvl];
if (base == null) continue;
const ageModifier =
this.cfg.activity[activity].ageModifier;
const leftSpan = peakAge - minAge;
const rightSpan = maxAge - peakAge;
// avoid divide-by-zero
if (leftSpan <= 0 || rightSpan <= 0) {
newStandard.levels[lvl] = base;
continue;
}
const cy =
(ageModifier * 0.5) / (leftSpan * leftSpan);
const co =
(ageModifier * 0.5) /
(rightSpan * rightSpan);
const d =
currAge <= peakAge
? peakAge - currAge
: currAge - peakAge;
const c = currAge <= peakAge ? cy : co;
let f = 1 - c * d * d;
const minFactor = 0.2;
const maxFactor = 10.0;
f = Math.min(maxFactor, Math.max(minFactor, f));
newStandard.levels[lvl] = base * f;
}
// prefer real data over generated data
const overlappingStandard = this.byActivity(
activity,
)
.byMetrics(newStandard.metrics)
.getOne();
if (!overlappingStandard)
this.standardsData[activity].standards.push(
newStandard,
);
currAge += ageStep;
}
}
// now that we generated metric data, remove the old data
this.standardsData[activity].standards =
this.standardsData[activity].standards.filter(
(s) => s.metrics.age,
);
} else if (generator.metric === "weight") {
for (const gender of Object.values(Gender)) {
for (const age of this.agesFor(activity, gender)) {
const weightStep = 12;
const referenceWeight = getAvgWeight(gender, age);
const minWeight = Math.max(
referenceWeight - 3 * weightStep,
1,
);
const maxWeight = referenceWeight + 3 * weightStep;
const referenceStandard = this.byActivity(activity)
.byMetrics({
weight: referenceWeight,
gender,
age,
})
.getOneInterpolated();
let currWeight = minWeight;
while (currWeight <= maxWeight) {
const newStandard: Standard = {
metrics: {
weight: currWeight,
age,
gender,
},
levels: {},
};
// apply allometric scaling to generate levels
for (const lvl in referenceStandard.levels) {
if (!referenceStandard.levels[lvl])
continue;
const scalingExponent =
this.cfg.activity[activity]
.weightModifier;
const coefficient =
currWeight / referenceWeight;
newStandard.levels[lvl] =
referenceStandard.levels[lvl] *
coefficient ** scalingExponent;
}
// prefer real data over generated data
const overlappingStandard = this.byActivity(
activity,
)
.byMetrics(newStandard.metrics)
.getOne();
if (!overlappingStandard)
this.standardsData[
activity
].standards.push(newStandard);
currWeight += weightStep;
}
}
}
// now that we generated metric data, remove the old data
this.standardsData[activity].standards =
this.standardsData[activity].standards.filter(
(s) => s.metrics.weight,
);
}
}
this.standardsData[activity].standards = this.standardsData[
activity
].standards.sort((a, b) => {
const genderOrder: Record<Gender, number> = {
Male: 0,
Female: 1,
};
const gA = genderOrder[b.metrics.gender] ?? 99;
const gB = genderOrder[b.metrics.gender] ?? 99;
if (gA !== gB) return gA - gB;
if (a.metrics.age !== b.metrics.age)
return a.metrics.age - b.metrics.age;
return a.metrics.weight - b.metrics.weight;
});
}
}
public byActivity(activity: Activity) {
const self = this;
const state: Partial<Metrics> = {};
// EXEC METHODS
const execMethods = {
exact: {
// these will get EXACT matches
getAll(): Standard[] {
const src = self.standardsData[activity].standards;
const out: Standard[] = [];
for (let i = 0; i < src.length; i++) {
const s = src[i];
if (!s) continue;
if (
state.gender != null &&
s.metrics.gender !== state.gender
)
continue;
if (state.age != null && s.metrics.age !== state.age)
continue;
if (
state.weight != null &&
s.metrics.weight !== state.weight
)
continue;
out.push(s);
}
return out;
},
getOne: function (): Standard | undefined {
return execMethods.exact.getAll()[0];
},
getNearest(
metric: NumberMetric,
target: number,
): { lower: Standard; upper: Standard } {
const standards = execMethods.exact.getAll();
if (standards.length === 0) throw new Error("no standards");
let lower = standards[0]; // best <= target
let upper = standards[0]; // best >= target
let hasLower = false,
hasUpper = false;
for (const standard of standards) {
const standardMetricValue = standard.metrics[metric];
if (standardMetricValue <= target) {
if (
!hasLower ||
standardMetricValue > lower.metrics[metric]
) {
lower = standard;
hasLower = true;
}
}
if (standardMetricValue >= target) {
if (
!hasUpper ||
standardMetricValue < upper.metrics[metric]
) {
upper = standard;
hasUpper = true;
}
}
}
// clamp to ends if one side missing
if (!hasLower) lower = standards[0];
if (!hasUpper) upper = standards[standards.length - 1];
return { lower, upper };
},
},
};
const interpolateByAgeAndWeight = (metrics: Metrics): Standard => {
const interpolateByWeight = (targetAge: number): Levels => {
const {
lower: lowerAgeWeightStandard,
upper: upperAgeWeightStandard,
} = this.byActivity(activity)
.byGender(metrics.gender)
.byAge(targetAge)
.getNearest("weight", metrics.weight);
const weightLower = lowerAgeWeightStandard.metrics.weight;
const weightUpper = upperAgeWeightStandard.metrics.weight;
let weightRatio =
weightUpper === weightLower
? 1
: (metrics.weight - weightLower) /
(weightUpper - weightLower);
weightRatio = Math.max(0, Math.min(1, weightRatio));
// metrics.weight = lowerAgeWeightStandard.metrics.weight + (upperAgeWeightStandard.metrics.weight - lowerAgeWeightStandard.metrics.weight) * weightRatio;
return this.interpolateLevels(
lowerAgeWeightStandard.levels,
upperAgeWeightStandard.levels,
weightRatio,
);
};
// Find nearest age standards
const { lower: lowerAgeStandard, upper: upperAgeStandard } =
this.byActivity(activity)
.byGender(metrics.gender)
.getNearest("age", metrics.age);
const ageLower = lowerAgeStandard.metrics.age;
const ageUpper = upperAgeStandard.metrics.age;
// Interpolate by age and weight
let ageRatio =
ageUpper === ageLower
? 1
: (metrics.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
const interpolatedLevels = this.interpolateLevels(
interpolateByWeight(ageLower),
interpolateByWeight(ageUpper),
ageRatio,
);
return {
metrics: metrics,
levels: interpolatedLevels,
};
};
const getAllInterpolated = (opt?: {
normalizeForLb?: boolean;
}): Standard[] => {
const metrics: Metrics = state as Metrics;
let weights = this.weightsFor(
activity,
metrics.gender,
metrics.age,
);
if (opt?.normalizeForLb) {
weights = weights.map((w) => lbToKg(Math.round(kgToLb(w))));
}
const interpolatedStandards: Standard[] = weights.map((weight) =>
interpolateByAgeAndWeight({ ...metrics, weight }),
);
return interpolatedStandards;
};
const getOneInterpolated = (): Standard => {
const metrics: Metrics = state as Metrics;
return interpolateByAgeAndWeight(metrics);
};
const getMetadata = () => this.standardsData[activity].metadata;
// QUERY METHODS
const byGender = (gender: Gender) => {
state.gender = gender;
return { byAge, ...execMethods.exact };
};
const byAge = (age: number) => {
state.age = age;
return { byWeight, ...execMethods.exact, getAllInterpolated };
};
const byWeight = (weight: number) => {
state.weight = weight;
return { ...execMethods.exact, getOneInterpolated };
};
const byMetrics = (metrics: Metrics) => {
Object.assign(state, metrics);
return { ...execMethods.exact, getOneInterpolated };
};
const initialMethods = {
byGender,
byMetrics,
getMetadata,
...execMethods.exact,
};
return initialMethods;
}
public getAttributeActivities(attribute: Attribute): Activity[] {
const activities: Activity[] = [];
for (const activity of Object.keys(
this.standardsData,
) as Activity[]) {
const standardData = this.standardsData[
activity
] as StandardsData[Activity];
if (standardData.metadata.attribute === attribute) {
activities.push(activity as Activity);
}
}
return activities;
}
// TODO: turn these into queries?
public weightsFor(activity: Activity, gender: Gender, age: number) {
const { lower, upper } = this.byActivity(activity)
.byGender(gender)
.getNearest("age", age);
const lowerDiff = Math.abs(lower.metrics.age - age);
const upperDiff = Math.abs(upper.metrics.age - age);
const closestAge =
lowerDiff < upperDiff ? lower.metrics.age : upper.metrics.age;
const weights = this.byActivity(activity)
.byGender(gender)
.byAge(closestAge)
.getAll()
.map((s) => s.metrics.weight);
return weights;
}
public agesFor(activity: Activity, gender: Gender) {
const standardsByGender = this.byActivity(activity)
.byGender(gender)
.getAll();
const ages = [...new Set(standardsByGender.map((s) => s.metrics.age))];
return ages;
}
private expandLevels(levels: Levels, i: number): Levels {
if (i == 0) {
return levels;
}
const levelKeys = Object.keys(levels)
.map(Number)
.sort((a, b) => a - b);
const newLevels: Levels = {};
let j = 1;
for (let k = 0; k < levelKeys.length; ++k) {
const currLevel = levelKeys[k];
const nextLevel = levelKeys[k + 1];
newLevels[j++] = levels[currLevel];
if (nextLevel) {
newLevels[j++] = (levels[currLevel] + levels[nextLevel]) / 2;
}
}
return this.expandLevels(newLevels, i - 1);
}
private compressLevels(levels: Levels, targetLvl: number): Levels {
const keys = Object.keys(levels)
.map(Number)
.sort((a, b) => a - b);
const lvlCnt = keys.length;
if (targetLvl <= 0) {
throw new Error("Target levels amount must be a positive integer");
}
if (lvlCnt === 0) {
return {};
}
if (targetLvl === 1) {
return { 1: levels[keys[0]] };
}
if (lvlCnt < targetLvl) {
throw new Error(
"Target levels amount must be less than or equal to the current levels amount",
);
}
// Linear resample
const compressedLevels: Levels = {};
for (let targetIndex = 0; targetIndex < targetLvl; ++targetIndex) {
const sourcePos = (targetIndex * (lvlCnt - 1)) / (targetLvl - 1);
const sourceIndexLower = Math.floor(sourcePos);
const sourceIndexUpper = Math.min(sourceIndexLower + 1, lvlCnt - 1);
const interpolationWeight = sourcePos - sourceIndexLower;
const lowerValue = levels[keys[sourceIndexLower]];
const upperValue = levels[keys[sourceIndexUpper]];
compressedLevels[targetIndex + 1] =
lowerValue + (upperValue - lowerValue) * interpolationWeight;
}
return compressedLevels;
}
private interpolateLevels(
lower: Levels,
upper: Levels,
ratio: number,
): Levels {
if (Object.keys(lower).length !== Object.keys(upper).length)
throw new Error(
"Cannot interpolate between varying number of levels",
);
const lerp = (lvl: string) =>
(lower[lvl] as number) +
((upper[lvl] as number) - (lower[lvl] as number)) * ratio;
const interpolatedLevels: Levels = {};
for (const lvl in lower) {
interpolatedLevels[lvl] = lerp(lvl);
}
return interpolatedLevels;
}
}
+100
View File
@@ -0,0 +1,100 @@
import { type Metrics } from ".";
import avgWeightDataRaw from "./data/avg-weights.json";
// -------------------------------------------------------------------------------------------------
// DATA MODELS
// -------------------------------------------------------------------------------------------------
export type Player = {
name?: string;
metrics: Metrics;
};
export enum Attribute {
Strength = "Strength",
Power = "Power",
Endurance = "Endurance",
Agility = "Agility",
}
export enum Activity {
BackSquat = "BackSquat",
Deadlift = "Deadlift",
BenchPress = "BenchPress",
Run = "Run",
BroadJump = "BroadJump",
ConeDrill = "ConeDrill",
}
export interface ActivityPerformance {
activity: Activity;
performance: number;
}
export enum Gender {
Male = "Male",
Female = "Female",
}
// -------------------------------------------------------------------------------------------------
// Conversion utilities
// -------------------------------------------------------------------------------------------------
export type StandardUnit = "ms" | "cm" | "kg";
export const lbToKg = (lb: number | string): number => +lb * 0.453592;
export const kgToLb = (kg: number | string): number => +kg * 2.20462;
export const minToMs = (min: number) => min * 60000;
export const secToMs = (sec: number) => sec * 1000;
export const msToMin = (ms: number) => ms / 60000;
export const ftToCm = (ft: number) => ft * 30.48;
export const inToCm = (inches: number) => inches * 2.54;
export const cmToIn = (cm: number) => cm / 2.54;
export const msToTime = (ms: number, includeMs = false): string => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const milliseconds = Math.floor(ms % 1000);
let formattedTime = `${minutes}:${seconds.toString().padStart(2, "0")}`;
if (includeMs) {
formattedTime += `.${milliseconds.toString().padStart(3, "0")}`;
}
return formattedTime;
};
export const range = (length: number) =>
Array.from({ length: length }, (_, i) => i);
export const clamp = (x: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, x));
export const getAvgWeight = (gender: Gender, age: number) => {
type AvgWeightData = {
metadata: {
unit: StandardUnit;
};
weights: Metrics[];
};
let avgWeightData = avgWeightDataRaw as AvgWeightData;
const avgWeights = avgWeightData.weights.filter((a) => a.gender === gender);
const firstAvgWeight = avgWeights[0];
if (!firstAvgWeight) throw new Error("No average weights");
const closest = {
diff: Math.abs(firstAvgWeight.age - age),
avg: firstAvgWeight,
};
for (const avg of avgWeights) {
const diff = Math.abs(avg.age - age);
if (diff < closest.diff) {
closest.diff = diff;
closest.avg = avg;
}
}
return closest.avg.weight;
};
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
},
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@blade-and-brawn/commerce",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./printful"
export * from "./sync"
export * from "./webflow"
export * from "./webflow"
export * from "./util/misc"
export * from "./util/types"
+207
View File
@@ -0,0 +1,207 @@
import type { Printful } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc";
export class PrintfulService {
static Products = class {
static Variants = class {
static async get(
variantId: number | string,
): Promise<Printful.Products.SyncVariant | undefined> {
const res = await fetch(
`${env().API_URL}/store/variants/${variantId}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Printful variant", res);
}
const payload =
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.SyncVariant>;
return payload.result;
}
};
static async getAll(
offset: number = 0,
): Promise<Printful.Products.SyncProduct[]> {
const allSyncProducts: Printful.Products.SyncProduct[] = [];
while (true) {
try {
const res = await fetch(
`${env().API_URL}/store/products?offset=${offset}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (!res.ok) {
throw new FetchError(
"Failed to get all Printful products",
res,
);
}
const payload =
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Products.SyncProduct>;
allSyncProducts.push(...payload.result);
offset = allSyncProducts.length;
if (allSyncProducts.length >= payload.paging.total) break;
} catch (error) {
throw error;
}
}
return allSyncProducts;
}
static async get(
productId: number | string,
): Promise<Printful.Products.Product | undefined> {
const res = await fetch(
`${env().API_URL}/store/products/${productId}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Printful product", res);
}
const payload =
(await res.json()) as Printful.Products.MetaDataSingle<Printful.Products.Product>;
return payload.result;
}
static async update(
printfulProductId: number,
printfulProduct: DeepPartial<Printful.Products.Product>,
) {
const res = await fetch(
`${env().API_URL}/store/products/${printfulProductId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADERS,
},
body: JSON.stringify(printfulProduct),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Printful product", res);
}
}
};
static Orders = class {
static async create(printfulOrder: Printful.Orders.Order) {
const res = await fetch(`${env().API_URL}/orders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADERS,
},
body: JSON.stringify(printfulOrder),
});
if (!res.ok) {
throw new FetchError("Failed to create printful order", res);
}
}
static async getAll(
offset: number = 0,
): Promise<Printful.Orders.Order[]> {
const allPrintfulOrders: Printful.Orders.Order[] = [];
while (true) {
try {
const res = await fetch(
`${env().API_URL}/orders?offset=${offset}`,
{
method: "GET",
headers: { ...env().AUTH_HEADERS },
},
);
if (!res.ok) {
throw new FetchError(
"Failed to get all Printful orders",
res,
);
}
const payload =
(await res.json()) as Printful.Products.MetaDataMulti<Printful.Orders.Order>;
allPrintfulOrders.push(...payload.result);
offset = allPrintfulOrders.length;
if (allPrintfulOrders.length >= payload.paging.total) break;
} catch (error) {
throw error;
}
}
return allPrintfulOrders;
}
};
static Util = class {
static getVariantMainImage(
syncVariant: Printful.Products.SyncVariant,
): string {
const previewFile = syncVariant.files.find(
(f) => f.type === "preview",
);
return previewFile?.preview_url ?? syncVariant.product.image;
}
};
}
export const env = () => {
if (typeof Bun === "undefined") {
throw new Error(
"Must be in a server context. Make sure to run using --bun.",
);
}
const vars = {
AUTH_TOKEN: Bun.env.PRINTFUL_AUTH,
STORE_ID: Bun.env.PRINTFUL_STORE_ID,
} as Record<string, string>;
for (const varKey in vars) {
if (!vars[varKey]) {
console.log(`Missing ${varKey} environment variable`);
}
}
return {
...vars,
API_URL: "https://api.printful.com",
AUTH_HEADERS: {
Authorization: `Bearer ${vars.AUTH_TOKEN}`,
"X-PF-Store-Id": `${vars.STORE_ID}`,
},
};
};
+359
View File
@@ -0,0 +1,359 @@
import type { Printful, Webflow } from "./util/types";
import { FetchError, formatSlug, type DeepPartial } from "./util/misc";
import { WebflowService } from "./webflow";
import { PrintfulService } from "./printful";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const R2_WORKER_URL = "https://r2-worker.xominus.workers.dev";
export const WEBSITE_MEDIA_URL = "https://website-media.bladeandbrawn.com";
type MainProduct = {
name: string;
externalId: string;
colors: Set<string>;
variants: {
color: string;
product: Printful.Products.Product;
}[];
};
export class SyncService {
static state = {
isSyncing: false,
syncingIds: [] as number[],
};
static async sync(printfulProductId: number | undefined) {
this.state.isSyncing = true;
const mainPrintfulProducts: MainProduct[] = [];
const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
try {
let printfulProducts = await PrintfulService.Products.getAll();
if (printfulProductId) {
const printfulProduct = printfulProducts.find(
(p) => p.id === printfulProductId,
);
if (printfulProduct) {
const mainProductName = this.getMainProductName(
printfulProduct.name,
);
printfulProducts = printfulProducts.filter((p) =>
p.name.includes(mainProductName),
);
}
}
webflowProducts.push(...(await WebflowService.Products.getAll()));
// Generate main printful products
console.log("Generating main products...");
for (const printfulProduct of printfulProducts) {
const mainProductName = this.getMainProductName(
printfulProduct.name,
);
let mainPrintfulProduct = mainPrintfulProducts.find((mp) =>
mp.name.includes(mainProductName),
);
if (!mainPrintfulProduct) {
mainPrintfulProduct = {
name: mainProductName,
variants: [],
externalId: printfulProduct.external_id,
colors: new Set(),
};
mainPrintfulProducts.push(mainPrintfulProduct);
}
const productColor = this.findColorInProductName(
printfulProduct.name,
);
mainPrintfulProduct.variants.push({
color: productColor,
product: (await PrintfulService.Products.get(
printfulProduct.id,
))!,
});
mainPrintfulProduct.colors.add(productColor);
}
} catch (error) {
throw error;
} finally {
this.state.isSyncing = false;
this.state.syncingIds = [];
}
// TODO: Validate main printful products
// Sync the main printful products
console.log("Syncing main products...");
for (const mainPrintfulProduct of mainPrintfulProducts) {
try {
this.state.isSyncing = true;
this.state.syncingIds = mainPrintfulProduct.variants.map(
(v) => v.product.sync_product.id,
);
const foundColors = mainPrintfulProduct.colors;
const foundSizes: Set<string> = new Set();
const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] =
[];
for (const specialVariant of mainPrintfulProduct.variants) {
for (const printfulVariant of specialVariant.product
.sync_variants) {
// check if this variant size is in all other special variants
let sizeIsInAllVariants = true;
for (const specialVariant of mainPrintfulProduct.variants) {
const sizes =
specialVariant.product.sync_variants.map(
(v) => v.size,
);
if (!sizes.includes(printfulVariant.size))
sizeIsInAllVariants = false;
}
if (sizeIsInAllVariants) {
foundSizes.add(printfulVariant.size);
webflowSkus.push({
id: printfulVariant.external_id,
fieldData: {
name: printfulVariant.name,
slug: formatSlug(printfulVariant.name),
"sku-values": {
color: specialVariant.color,
size: printfulVariant.size,
},
price: {
value: Math.floor(
+printfulVariant.retail_price * 100,
),
unit: printfulVariant.currency,
currency: printfulVariant.currency,
},
"main-image":
PrintfulService.Util.getVariantMainImage(
printfulVariant,
),
},
});
}
}
}
// SYNC PRODUCT DATA
let existingWebflowProduct = webflowProducts.find(
(webflowProduct) =>
webflowProduct.product.id ===
mainPrintfulProduct.externalId.split("-")[0],
);
if (existingWebflowProduct) {
// SYNC PRODUCT UPDATE
// do not update images
for (const webflowSku of webflowSkus) {
delete webflowSku.fieldData?.["main-image"];
}
const webflowProductId =
mainPrintfulProduct.externalId.split("-")[0];
if (!webflowProductId) {
throw new Error("Malformed printful product ID");
}
WebflowService.Products.update(webflowProductId, {
product: {
fieldData: {
name: mainPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name),
shippable: true,
"tax-category": "standard-taxable",
"sku-properties": [
{
id: "color",
name: "Color",
enum: Array.from(foundColors).map(
(color) => ({
id: color,
slug: formatSlug(color),
name: color,
}),
),
},
{
id: "size",
name: "Size",
enum: Array.from(foundSizes).map(
(size) => ({
id: size,
slug: formatSlug(size),
name: size,
}),
),
},
],
},
},
sku: webflowSkus[0],
});
for (const webflowSku of webflowSkus.slice(1)) {
const existingWebflowSku =
existingWebflowProduct.skus.find(
(sku) => sku.id === webflowSku.id,
);
if (webflowSku.id && existingWebflowSku) {
await WebflowService.Products.Skus.update(
webflowProductId,
webflowSku.id,
webflowSku,
);
} else {
await WebflowService.Products.Skus.create(
webflowProductId,
[webflowSku],
);
}
}
} else {
// SYNC PRODUCT CREATE
const webflowProductId =
await WebflowService.Products.create({
product: {
fieldData: {
name: mainPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name),
shippable: true,
"tax-category": "standard-taxable",
"sku-properties": [
{
id: "color",
name: "Color",
enum: Array.from(foundColors).map(
(color) => ({
id: color,
slug: formatSlug(color),
name: color,
}),
),
},
{
id: "size",
name: "Size",
enum: Array.from(foundSizes).map(
(size) => ({
id: size,
slug: formatSlug(size),
name: size,
}),
),
},
],
},
},
sku: webflowSkus[0],
});
// create webflow product SKUs
await WebflowService.Products.Skus.create(
webflowProductId,
webflowSkus.slice(1),
);
existingWebflowProduct =
await WebflowService.Products.get(webflowProductId);
if (!existingWebflowProduct) {
console.error("Missing webflow product");
throw new Error("Failed to create Webflow product");
}
for (const specialVariant of mainPrintfulProduct.variants) {
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] =
[];
for (const printfulVariant of specialVariant.product
.sync_variants) {
const associatedWebflowSku =
existingWebflowProduct.skus.find(
(sku) =>
sku.fieldData["sku-values"]?.[
"color"
] === printfulVariant.color &&
sku.fieldData["sku-values"]?.[
"size"
] === printfulVariant.size,
);
if (associatedWebflowSku) {
newPrintfulVariants.push({
id: printfulVariant.id, // printful variant id
external_id: String(
associatedWebflowSku.id,
), // webflow variant id
});
}
}
await sleep(10000);
await PrintfulService.Products.update(
specialVariant.product.sync_product.id,
{
sync_product: {
id: specialVariant.product.sync_product.id,
external_id: `${webflowProductId}-${specialVariant.color}`,
},
sync_variants: newPrintfulVariants,
},
);
}
}
} catch (err) {
// SYNC IMAGES
// for (const sku of existingWebflowProduct.skus) {
// const skuColor = sku.fieldData["sku-values"]?.["color"]?.toLowerCase() ?? "None";
// const skuSlug = `${existingWebflowProduct.product.fieldData.slug}-${skuColor}`;
// const skuImageUrls = await this.getProductImageUrls(skuSlug);
//
// sku.fieldData["main-image"] = skuImageUrls[0] ?? sku.fieldData["main-image"];
// sku.fieldData["more-images"] = skuImageUrls.slice(1).map(url => ({ url }));
//
// await WebflowService.Products.Skus.update(existingWebflowProduct.product.id, sku.id, sku);
// }
//
if (err instanceof FetchError) {
console.error(err.message, err.payload);
}
}
}
this.state.isSyncing = false;
this.state.syncingIds = [];
console.log("Done");
}
private static getProductImageUrls = async (slug: string) => {
const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`);
if (!res.ok) {
console.error(
"Failed to get product image keys:",
await res.json(),
);
throw new Error("Failed to get product image keys");
}
const productImageKeys: string[] = (await res.json()) as string[];
return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`);
};
public static findColorInProductName = (productName: string): string => {
const m = productName.match(/\[([^\]]+)\]/);
return m?.[1] ? m[1] : "N/A";
};
public static getMainProductName = (productName: string) => {
const colorInName = this.findColorInProductName(productName);
if (colorInName) {
return productName.replace(`[${colorInName}]`, "").trimEnd();
} else {
return productName;
}
};
}
+37
View File
@@ -0,0 +1,37 @@
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export const formatSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9-_]/g, "-") // replace illegal chars with dashes
.replace(/^-+/, "") // remove leading dashes
.replace(/-+$/, "") // remove trailing dashes
.replace(/--+/g, "-") // collapse multiple dashes
.replace(/^([^a-z0-9_])/, "_$1"); // if it starts with an invalid char, prefix underscore
};
export class FetchError extends Error {
public payload: Object | undefined;
constructor(
message: string,
public response: Response,
) {
super(message);
this.name = "FetchError";
}
async parse() {
try {
this.payload = (await this.response.clone().json()) as Object;
} catch {
this.payload = await this.response.text().catch(() => undefined);
} finally {
return this.payload;
}
}
}
+431
View File
@@ -0,0 +1,431 @@
export namespace Printful {
export namespace Products {
export interface MetaDataSingle<T = any> {
code: number;
result: T;
}
export interface MetaDataMulti<T = any> {
code: number;
result: T[];
paging: {
total: number;
offset: number;
limit: number;
};
}
type Option = {
id: string;
value: string;
};
type File = {
type: string;
id: number;
url: string;
options: Option[];
hash: string;
filename: string;
mime_type: string;
size: number;
width: number;
height: number;
dpi: number;
status: string;
created: number;
thumbnail_url: string;
preview_url: string;
visible: boolean;
is_temporary: boolean;
stitch_count_tier: string;
};
export type Product = {
sync_product: SyncProduct;
sync_variants: SyncVariant[];
};
export type SyncProduct = {
id: number;
external_id: string;
name: string;
variants: number;
synced: number;
thumbnail_url: string;
is_ignored: boolean;
};
export type SyncVariant = {
id: number;
external_id: string;
sync_product_id: number;
name: string;
synced: boolean;
variant_id: number;
retail_price: string;
currency: string;
is_ignored: boolean;
sku: string;
product: {
variant_id: number;
product_id: number;
image: string;
name: string;
};
files: File[];
options: Option[];
main_category_id: number;
warehouse_product_id: number;
warehouse_product_variant_id: number;
size: string;
color: string;
availability_status: string;
};
}
export namespace Orders {
export type Order = {
external_id: string;
shipping: string;
recipient: {
name: string;
company?: string;
address1: string;
address2: string;
city: string;
state_code: string;
state_name?: string;
country_code: string;
country_name?: string;
zip: string;
phone?: string;
email?: string;
tax_number?: string;
};
items: Array<{ external_variant_id: string; quantity: number }>;
};
}
export namespace Webhook {
// meta
interface MetaData<E extends Event, D> {
type: E;
created: number;
retries: number;
store: number;
data: D;
}
export enum Event {
ProductUpdated = "product_updated",
ProductDeleted = "product_deleted",
PackageShipped = "package_shipped",
}
export interface ProductUpdated extends MetaData<
Event.ProductUpdated,
{
sync_product: {
id: number;
external_id: string;
name: string;
variants: number;
synced: number;
thumbnail_url: string;
is_ignored: boolean;
};
}
> {}
export interface ProductDeleted extends MetaData<
Event.ProductDeleted,
{
sync_product: {
id: number;
external_id: string;
name: string;
};
}
> {}
export interface PackageShipped extends MetaData<
Event.PackageShipped,
{
shipment: {
id: number;
carrier: string;
service: string;
tracking_number: string;
tracking_url: string;
created: number;
ship_date: string;
shipped_at: number;
reshipment: boolean;
};
order: {
id: number;
external_id: string;
status: string;
};
}
> {}
export type EventPayload =
| ProductUpdated
| ProductDeleted
| PackageShipped;
}
}
export namespace Webflow {
export namespace Products {
export namespace Skus {
export type Sku = {
id: string;
fieldData: {
name: string;
slug: string;
"sku-values"?: Record<string, string>;
price: {
value: number;
unit: string;
currency: string;
};
"main-image"?: string;
"more-images"?: {
fileId?: string;
url: string;
alt?: string;
}[];
};
};
}
export type Product = {
id: string;
fieldData: {
name: string;
slug: string;
description?: string;
shippable?: boolean;
"tax-category"?: string;
"sku-properties": {
id: string;
name: string;
enum: {
id: string;
name: string;
slug: string;
}[];
}[];
};
};
export interface ProductAndSkus {
product: Product;
skus: Skus.Sku[];
}
export interface ProductAndSku {
product: Product;
sku: Skus.Sku;
publishStatus?: "staging" | "live";
}
}
export namespace Orders {
export type Order = {
orderId: string;
status:
| "pending"
| "unfulfilled"
| "fulfilled"
| "disputed"
| "dispute-lost"
| "refunded";
comment: string;
orderComment: string;
acceptedOn: string;
fulfilledOn: string | null;
refundedOn: string | null;
disputedOn: string | null;
disputeUpdatedOn: string | null;
disputeLastStatus: string | null;
customerPaid: {
unit: string;
value: string;
string: string;
};
netAmount: {
unit: string;
value: string;
string: string;
};
applicationFee: {
unit: string;
value: string;
string: string;
};
allAddresses: Array<{
type: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
}>;
shippingAddress: {
type: string;
japanType: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
};
billingAddress: {
type: string;
addressee: string;
line1: string;
line2: string;
city: string;
state: string;
country: string;
postalCode: string;
};
shippingProvider: string;
shippingTracking: string;
shippingTrackingURL: string;
customerInfo: {
fullName: string;
email: string;
};
purchasedItems: Array<{
count: number;
rowTotal: {
unit: string;
value: string;
string: string;
};
productId: string;
productName: string;
productSlug: string;
variantId: string;
variantName: string;
variantSlug: string;
variantSKU: string;
variantImage: {
url: string;
file: {
size: number;
originalFileName: string;
createdOn: string;
contentType: string;
width: number;
height: number;
variants: {
size: number;
url: string;
originalFileName: string;
width: number;
height: number;
}[];
};
};
variantPrice: {
unit: string;
value: string;
string: string;
};
weight: number;
width: number;
height: number;
length: number;
}>;
purchasedItemsCount: number;
stripeDetails: {
subscriptionId: string | null;
paymentMethod: string;
paymentIntentId: string;
customerId: string;
chargeId: string;
disputeId: string | null;
refundId: string;
refundReason: string;
};
stripeCard: {
last4: string;
brand: string;
ownerName: string;
expires: {
year: number;
month: number;
};
};
paypalDetails: Object;
customData: Array<Object>;
metadata: {
isBuyNow: boolean;
hasDownloads: boolean;
paymentProcessor: string;
};
isCustomerDeleted: boolean;
isShippingRequired: boolean;
totals: {
subtotal: {
unit: string;
value: string;
string: string;
};
extras: Array<{
type: string;
name: string;
description: string;
price: {
unit: string;
value: string;
string: string;
};
}>;
total: {
unit: string;
value: string;
string: string;
};
};
downloadFiles: Array<{
id: string;
name: string;
url: string;
}>;
};
}
export namespace Webhook {
interface MetaData<E extends Event, D> {
triggerType: E;
payload: D;
}
export enum Event {
OrderCreated = "ecomm_new_order",
OrderUpdated = "ecomm_order_changed",
}
export interface OrderCreated extends MetaData<
Event.OrderCreated,
Orders.Order
> {}
export interface OrderUpdated extends MetaData<
Event.OrderUpdated,
Orders.Order
> {}
export type EventPayload = OrderCreated | OrderUpdated;
}
}
+330
View File
@@ -0,0 +1,330 @@
import type { Webflow } from "./util/types";
import { FetchError, type DeepPartial } from "./util/misc";
import crypto from "node:crypto";
export class WebflowService {
static Products = class {
static Skus = class {
static async create(
webflowProductId: string,
skus: DeepPartial<Webflow.Products.Skus.Sku>[],
): Promise<string[]> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}/skus`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({
skus: skus,
}),
},
);
if (!res.ok) {
throw new FetchError(
"Failed to create Webflow product SKU",
res,
);
}
const payload = (await res.json()) as {
skus: { id: string }[];
};
return payload.skus.map((sku) => sku.id);
}
static async update(
webflowProductId: string,
webflowSkuId: string,
webflowSku: DeepPartial<Webflow.Products.Skus.Sku>,
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({
sku: webflowSku,
}),
},
);
if (!res.ok) {
throw new FetchError(
"Failed to update Webflow product SKU",
res,
);
}
}
};
static async create(
webflowProductAndSku: DeepPartial<Webflow.Products.ProductAndSku>,
): Promise<string> {
const res = await fetch(`${env().API_SITES_URL}/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowProductAndSku),
});
if (!res.ok) {
throw new FetchError("Failed to create Webflow product", res);
}
const createdWebflowProduct = (await res.json()) as {
product: { id: string };
};
return createdWebflowProduct.product.id;
}
static async getAll(): Promise<Webflow.Products.ProductAndSkus[]> {
const res = await fetch(`${env().API_SITES_URL}/products`, {
method: "GET",
headers: {
...env().AUTH_HEADER,
},
});
if (!res.ok) {
throw new FetchError("Failed to get all Webflow products", res);
}
const payload = (await res.json()) as {
items: Webflow.Products.ProductAndSkus[];
};
return payload.items;
}
static async get(
webflowProductId: string,
): Promise<Webflow.Products.ProductAndSkus | undefined> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}`,
{
method: "GET",
headers: {
...env().AUTH_HEADER,
},
},
);
if (res.status === 404 || res.status === 400) {
return;
}
if (!res.ok) {
throw new FetchError("Failed to get Webflow product", res);
}
const payload =
(await res.json()) as Webflow.Products.ProductAndSkus;
return payload;
}
static async update(
webflowProductId: string,
webflowProduct: DeepPartial<Webflow.Products.ProductAndSku>,
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/products/${webflowProductId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowProduct),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Webflow product", res);
}
}
static async remove(webflowProductId: string): Promise<void> {
const res = await fetch(
`${env().API_COLLECTIONS_URL}/items/${webflowProductId}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify({}),
},
);
if (!res.ok) {
throw new FetchError("Failed to remove Webflow product", res);
}
}
};
static Orders = class {
static async getAll(
opt: { status?: Webflow.Orders.Order["status"] } = {},
): Promise<Webflow.Orders.Order[]> {
// TODO: pagination
const params = new URLSearchParams();
if (opt.status !== undefined) {
params.set("status", opt.status);
}
const res = await fetch(
`${env().API_SITES_URL}/orders?${params.toString()}`,
{
method: "GET",
headers: { ...env().AUTH_HEADER },
},
);
if (!res.ok) {
throw new FetchError("Failed to get all Webflow orders", res);
}
const payload = (await res.json()) as {
orders: Webflow.Orders.Order[];
};
return payload.orders;
}
static async update(
webflowOrderId: string,
webflowOrderUpdate: {
comment?: string;
shippingProvider?: string;
shippingTracking?: string;
shippingTrackingURL: string;
},
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/orders/${webflowOrderId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(webflowOrderUpdate),
},
);
if (!res.ok) {
throw new FetchError("Failed to update Webflow order", res);
}
}
static async fulfill(
webflowOrderId: string,
opt: { sendOrderFulfilledEmail?: boolean } = {},
): Promise<void> {
const res = await fetch(
`${env().API_SITES_URL}/orders/${webflowOrderId}/fulfill`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...env().AUTH_HEADER,
},
body: JSON.stringify(opt),
},
);
if (!res.ok) {
throw new FetchError("Failed to fulfill Webflow order", res);
}
}
};
static Util = class {
static verifyWebflowSignature(
request: Request,
body: unknown,
): Boolean {
try {
const timestamp = request.headers.get("x-webflow-timestamp");
if (!timestamp) {
throw new Error("No timestamp provided");
}
const providedSignature = request.headers.get(
"x-webflow-signature",
);
if (!providedSignature) {
throw new Error("No signature provided");
}
const secret = env().WEBHOOK_SECRET;
if (!secret) {
throw new Error("No secret provided");
}
if (!body) {
throw new Error("Body is empty");
}
const requestTimestamp = parseInt(timestamp, 10);
const data = `${requestTimestamp}:${JSON.stringify(body)}`;
const hash = crypto
.createHmac("sha256", secret)
.update(data)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(hash, "hex"),
Buffer.from(providedSignature, "hex"),
)
) {
throw new Error("Invalid signature");
}
const currentTime = Date.now();
if (currentTime - requestTimestamp > 300000) {
throw new Error("Request is older than 5 minutes");
}
return true;
} catch (err) {
console.error(`Error verifying signature: ${err}`);
return false;
}
}
};
}
const env = () => {
if (typeof Bun === "undefined") {
throw new Error(
"Must be in a server context. Make sure to run using --bun.",
);
}
const vars = {
SITE_ID: Bun.env.WEBFLOW_SITE_ID,
COLLECTIONS_ID: Bun.env.WEBFLOW_COLLECTION_ID,
AUTH_TOKEN: Bun.env.WEBFLOW_AUTH,
WEBHOOK_SECRET: Bun.env.WEBFLOW_WEBHOOK_SECRET,
} as Record<string, string>;
for (const varKey in vars) {
if (!vars[varKey]) {
console.log(`Missing ${varKey} environment variable`);
}
}
return {
...vars,
API_SITES_URL: `https://api.webflow.com/v2/sites/${vars.SITE_ID}`,
API_COLLECTIONS_URL: `https://api.webflow.com/v2/collections/${vars.COLLECTIONS_ID}`,
AUTH_HEADER: { Authorization: `bearer ${vars.AUTH_TOKEN}` },
};
};
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json",
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json",
}