breakout into monorepo
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noUncheckedIndexedAccess": false,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@blade-and-brawn/commerce",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./printful"
|
||||
export * from "./sync"
|
||||
export * from "./webflow"
|
||||
export * from "./webflow"
|
||||
export * from "./util/misc"
|
||||
export * from "./util/types"
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}` },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
}
|
||||
Reference in New Issue
Block a user