restructure folders
This commit is contained in:
@@ -3,4 +3,4 @@ PRINTFUL_STORE_ID=15619272
|
|||||||
|
|
||||||
WEBFLOW_AUTH=115ded1e97162f5d4835bcb87e236fe4bce1ad602f519bca8179394092a8c6f6
|
WEBFLOW_AUTH=115ded1e97162f5d4835bcb87e236fe4bce1ad602f519bca8179394092a8c6f6
|
||||||
WEBFLOW_SITE_ID=6737d2c9511d414861f1726e
|
WEBFLOW_SITE_ID=6737d2c9511d414861f1726e
|
||||||
WEBFLOW_COLLECTION_ID=675a3fbe966e58ea9aa7110f
|
WEBFLOW_COLLECTION_ID=675a3fbe966e58ea9aa7110f
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# syntax = docker/dockerfile:1
|
|
||||||
|
|
||||||
FROM oven/bun:1.2.12-slim as base
|
|
||||||
|
|
||||||
LABEL fly_launch_runtime="Bun"
|
|
||||||
|
|
||||||
# NodeJS app lives here
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Set production environment
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
|
|
||||||
# Throw-away build stage to reduce size of final image
|
|
||||||
FROM base as build
|
|
||||||
|
|
||||||
# Install packages needed to build node modules
|
|
||||||
RUN apt-get update -qq && \
|
|
||||||
apt-get install -y python-is-python3 pkg-config build-essential
|
|
||||||
|
|
||||||
# Install node modules
|
|
||||||
COPY --link package.json .
|
|
||||||
RUN bun install --production
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY --link . .
|
|
||||||
|
|
||||||
# Final stage for app image
|
|
||||||
FROM base
|
|
||||||
|
|
||||||
# Copy built application
|
|
||||||
COPY --from=build /app /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Start the server by default, this can be overwritten at runtime
|
|
||||||
CMD [ "bun", "run", "db:seed:prod" ]
|
|
||||||
CMD [ "bun", "run", "start" ]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "^1.2.11",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@types/bun": ["@types/bun@1.2.11", "", { "dependencies": { "bun-types": "1.2.11" } }, "sha512-ZLbbI91EmmGwlWTRWuV6J19IUiUC5YQ3TCEuSHI3usIP75kuoA8/0PVF+LTrbEnVc8JIhpElWOxv1ocI1fJBbw=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.15.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-dbkp5Lo8HDrXkLrONm6bk+yiiYQSntvFUzQp0v3pzTAsXk6FtgVMjdQ+lzFNVAmQFUkPQZ3WMZqH5tTo+Dp/IA=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import { activities, Activity, Attribute, attributes, BenchmarkPerformance, findNearestPoints, Gender, getActivityAttribute, kgToLb, Levels, msToTime, StandardsItem } from "./util";
|
|
||||||
import rawStandards from "./data/standards.json" assert { type: "json" }
|
|
||||||
|
|
||||||
const standards = rawStandards as StandardsItem[];
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// Dash:
|
|
||||||
// https://marathonhandbook.com/average-100-meter-time/
|
|
||||||
// Broad Jump:
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Level calculations
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export const calcBenchmarkLevel = (performance: number, levels: Record<number, number>): number => {
|
|
||||||
let playerLevel = 1;
|
|
||||||
let playerLevelDiff = Infinity;
|
|
||||||
|
|
||||||
for (const level in levels) {
|
|
||||||
const diff = Math.abs(performance - levels[level as unknown as number]);
|
|
||||||
if (diff < playerLevelDiff) {
|
|
||||||
playerLevel = +level;
|
|
||||||
playerLevelDiff = diff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return playerLevel;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calcAttributeLevel = (
|
|
||||||
attribute: Attribute,
|
|
||||||
benchmarkPerformances: BenchmarkPerformance[],
|
|
||||||
): number => {
|
|
||||||
if (benchmarkPerformances.some((bp) => getActivityAttribute(bp.activity) !== attribute)) {
|
|
||||||
throw new Error("Wrong benchmark performance attribute");
|
|
||||||
}
|
|
||||||
|
|
||||||
const benchmarkLevels = benchmarkPerformances.map((abp) =>
|
|
||||||
calcBenchmarkLevel(abp.performance, abp.levels),
|
|
||||||
);
|
|
||||||
|
|
||||||
const benchmarkLevelsAvg = Math.round(
|
|
||||||
benchmarkLevels.reduce((sum, curr) => sum + curr, 0) / benchmarkLevels.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
return benchmarkLevelsAvg;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calcAttributeLevels = (
|
|
||||||
benchmarkPerformances: BenchmarkPerformance[],
|
|
||||||
): Record<Attribute, number> => {
|
|
||||||
const attrLevels: Record<Attribute, number> = {
|
|
||||||
[attributes.STRENGTH]: 1,
|
|
||||||
[attributes.POWER]: 1,
|
|
||||||
[attributes.ENDURANCE]: 1,
|
|
||||||
[attributes.SPEED]: 1,
|
|
||||||
[attributes.AGILITY]: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
(Object.values(attributes) as Attribute[]).forEach((attribute) => {
|
|
||||||
const attrBenchmarkPerformances = benchmarkPerformances.filter(
|
|
||||||
(bp) => getActivityAttribute(bp.activity) === attribute,
|
|
||||||
);
|
|
||||||
attrLevels[attribute] = calcAttributeLevel(attribute, attrBenchmarkPerformances);
|
|
||||||
});
|
|
||||||
|
|
||||||
return attrLevels;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calcPlayerLevel = (benchmarkPerformances: BenchmarkPerformance[]): number => {
|
|
||||||
const attrLevels = calcAttributeLevels(benchmarkPerformances);
|
|
||||||
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0);
|
|
||||||
return Math.round(attrLevelsSum / Object.keys(attributes).length);
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Presentation helpers
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export const formatLevelValues = (
|
|
||||||
levels: Record<number, number>,
|
|
||||||
activity: Activity,
|
|
||||||
): Record<number, string> => {
|
|
||||||
const timedActivities: Activity[] = [activities.RUN, activities.DASH, activities.CONE_DRILL];
|
|
||||||
|
|
||||||
const formattedLevels: Record<number, string> = {};
|
|
||||||
for (const level in levels) {
|
|
||||||
const lvl = +level as number;
|
|
||||||
let displayValue = "";
|
|
||||||
if (timedActivities.includes(activity)) {
|
|
||||||
displayValue = msToTime(levels[lvl]);
|
|
||||||
} else if (activity === activities.TREADMILL_DASH) {
|
|
||||||
displayValue = `${(levels[lvl] * 1000).toFixed(2)} m/s`;
|
|
||||||
} else if (activity === activities.BROAD_JUMP) {
|
|
||||||
displayValue = `${(levels[lvl] * 0.0328084).toFixed(2)} ft.`;
|
|
||||||
} else {
|
|
||||||
displayValue = kgToLb(levels[lvl]).toFixed(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedLevels[lvl] = displayValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedLevels;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Level utilities
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
const compressLevels = (
|
|
||||||
levels: Record<number, number>,
|
|
||||||
targetLevelsAmount: number,
|
|
||||||
): Record<number, number> => {
|
|
||||||
const levelsAmount = Object.keys(levels).length;
|
|
||||||
if (levelsAmount < targetLevelsAmount) {
|
|
||||||
throw new Error(
|
|
||||||
"Target levels amount must be greater than or equal to the current levels amount",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = levelsAmount / targetLevelsAmount;
|
|
||||||
const compressedLevels: Record<number, number> = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < targetLevelsAmount; ++i) {
|
|
||||||
const ratioIndex = i * ratio;
|
|
||||||
const lowerIndex = Math.floor(ratioIndex);
|
|
||||||
const upperIndex = Math.ceil(ratioIndex);
|
|
||||||
|
|
||||||
const lowerValue = levels[lowerIndex + 1];
|
|
||||||
const upperValue = levels[upperIndex + 1];
|
|
||||||
|
|
||||||
const weight = ratioIndex - lowerIndex;
|
|
||||||
compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return compressedLevels;
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandLevels = (
|
|
||||||
levels: Levels | Record<number, number>,
|
|
||||||
i: number
|
|
||||||
): Record<number, number> => {
|
|
||||||
if (i == 0) {
|
|
||||||
// ensure we always use number keys
|
|
||||||
const newLevels = {};
|
|
||||||
for (let k = 0; k < Object.keys(levels).length; ++k) {
|
|
||||||
newLevels[k + 1] = levels[Object.keys(levels)[k]];
|
|
||||||
}
|
|
||||||
return newLevels;
|
|
||||||
};
|
|
||||||
|
|
||||||
const newLevels = {};
|
|
||||||
let j = 1;
|
|
||||||
for (let k = 0; k < Object.keys(levels).length; ++k) {
|
|
||||||
const currLevel = Object.keys(levels)[k];
|
|
||||||
const nextLevel = Object.keys(levels)[k + 1];
|
|
||||||
|
|
||||||
newLevels[j++] = levels[currLevel];
|
|
||||||
|
|
||||||
if (nextLevel) {
|
|
||||||
newLevels[j++] = (levels[currLevel] + levels[nextLevel]) / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandLevels(newLevels, i - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Main level function
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export const computeLevels = (
|
|
||||||
age: number,
|
|
||||||
weightKG: number,
|
|
||||||
gender: Gender,
|
|
||||||
activity: Activity,
|
|
||||||
) => {
|
|
||||||
const levels = calculateLevels(age, weightKG, gender, activity, standards);
|
|
||||||
if (levels) {
|
|
||||||
const expandedLevels = expandLevels(levels, 5);
|
|
||||||
const compressedLevels = compressLevels(expandedLevels, 100);
|
|
||||||
return compressedLevels;
|
|
||||||
} else {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Standards interpolation (heavy math bits)
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
const calculateLevels = (
|
|
||||||
age: number,
|
|
||||||
weightKG: number,
|
|
||||||
gender: Gender,
|
|
||||||
activity: Activity,
|
|
||||||
standards: StandardsItem[],
|
|
||||||
): Levels | null => {
|
|
||||||
// Filter dataset
|
|
||||||
const filtered = standards.filter(
|
|
||||||
(item) => item.gender === gender && item.activityType === activity,
|
|
||||||
);
|
|
||||||
if (filtered.length === 0) return null;
|
|
||||||
|
|
||||||
// Age interpolation
|
|
||||||
const ageArray = [...new Set(filtered.map((s) => s.age))].sort((a, b) => a - b);
|
|
||||||
const { lower: ageLower, upper: ageUpper } = findNearestPoints(age, ageArray);
|
|
||||||
|
|
||||||
const lowerValues = interpolateByBodyWeight(ageLower, weightKG, filtered);
|
|
||||||
const upperValues = interpolateByBodyWeight(ageUpper, weightKG, filtered);
|
|
||||||
if (!lowerValues || !upperValues) return null;
|
|
||||||
|
|
||||||
let ageRatio = ageUpper === ageLower ? 1 : (age - ageLower) / (ageUpper - ageLower);
|
|
||||||
ageRatio = Math.max(0, Math.min(1, ageRatio));
|
|
||||||
|
|
||||||
return interpolateStandardsValues(lowerValues, upperValues, ageRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
const interpolateByBodyWeight = (
|
|
||||||
agePoint: number,
|
|
||||||
bodyWeightKG: number,
|
|
||||||
filtered: StandardsItem[],
|
|
||||||
): Levels | null => {
|
|
||||||
const ageFiltered = filtered.filter((item) => item.age === agePoint);
|
|
||||||
if (ageFiltered[0].bodyWeight === -1) return ageFiltered[0];
|
|
||||||
|
|
||||||
const weightArray = [...new Set(ageFiltered.map((obj) => obj.bodyWeight))].sort((a, b) => a - b);
|
|
||||||
const { lower, upper } = findNearestPoints(bodyWeightKG, weightArray);
|
|
||||||
|
|
||||||
const weightLower = ageFiltered.find((i) => i.bodyWeight === lower);
|
|
||||||
const weightUpper = ageFiltered.find((i) => i.bodyWeight === upper);
|
|
||||||
if (!weightLower || !weightUpper) return null;
|
|
||||||
|
|
||||||
let weightRatio = upper === lower ? 1 : (bodyWeightKG - lower) / (upper - lower);
|
|
||||||
weightRatio = Math.max(0, Math.min(1, weightRatio));
|
|
||||||
|
|
||||||
return interpolateStandardsValues(weightLower, weightUpper, weightRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
const interpolateStandardsValues = (
|
|
||||||
lower: Levels,
|
|
||||||
upper: Levels,
|
|
||||||
ratio: number,
|
|
||||||
): Levels => {
|
|
||||||
const lerp = (k: keyof Levels): number =>
|
|
||||||
(lower[k] as number) + ((upper[k] as number) - (lower[k] as number)) * ratio;
|
|
||||||
|
|
||||||
return {
|
|
||||||
physicallyActive: lerp("physicallyActive"),
|
|
||||||
beginner: lerp("beginner"),
|
|
||||||
intermediate: lerp("intermediate"),
|
|
||||||
advanced: lerp("advanced"),
|
|
||||||
elite: lerp("elite"),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Enumerations & basic value objects
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export const activities = Object.freeze({
|
|
||||||
BACK_SQUAT: "Back Squat",
|
|
||||||
DEADLIFT: "Deadlift",
|
|
||||||
BENCH_PRESS: "Bench Press",
|
|
||||||
RUN: "Run",
|
|
||||||
DASH: "Dash",
|
|
||||||
TREADMILL_DASH: "Treadmill Dash",
|
|
||||||
BROAD_JUMP: "Broad Jump",
|
|
||||||
CONE_DRILL: "Cone Drill",
|
|
||||||
} as const);
|
|
||||||
|
|
||||||
export type Activity = typeof activities[keyof typeof activities];
|
|
||||||
|
|
||||||
export const genders = Object.freeze({
|
|
||||||
MALE: "male",
|
|
||||||
FEMALE: "female",
|
|
||||||
} as const);
|
|
||||||
export type Gender = typeof genders[keyof typeof genders];
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Attributes
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const attributes = Object.freeze({
|
|
||||||
STRENGTH: "Strength",
|
|
||||||
POWER: "Power",
|
|
||||||
ENDURANCE: "Endurance",
|
|
||||||
SPEED: "Speed",
|
|
||||||
AGILITY: "Agility",
|
|
||||||
} as const);
|
|
||||||
export type Attribute = typeof attributes[keyof typeof attributes];
|
|
||||||
|
|
||||||
export const getActivityAttribute = (activity: Activity): Attribute => {
|
|
||||||
const activityAttrMap: Record<Activity, Attribute> = {
|
|
||||||
[activities.RUN]: attributes.ENDURANCE,
|
|
||||||
[activities.TREADMILL_DASH]: attributes.SPEED,
|
|
||||||
[activities.DASH]: attributes.SPEED,
|
|
||||||
[activities.DEADLIFT]: attributes.STRENGTH,
|
|
||||||
[activities.BACK_SQUAT]: attributes.STRENGTH,
|
|
||||||
[activities.BENCH_PRESS]: attributes.STRENGTH,
|
|
||||||
[activities.BROAD_JUMP]: attributes.POWER,
|
|
||||||
[activities.CONE_DRILL]: attributes.AGILITY,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const attribute = activityAttrMap[activity];
|
|
||||||
if (!attribute) throw new Error(`${activity} does not have an associated attribute`);
|
|
||||||
return attribute;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Data model interfaces
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export interface BenchmarkPerformance {
|
|
||||||
activity: Activity;
|
|
||||||
performance: number;
|
|
||||||
/**
|
|
||||||
* Key: level number, Value: performance value (kg, ms, m/s, etc.)
|
|
||||||
*/
|
|
||||||
levels: Record<number, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Levels {
|
|
||||||
/** KG or -1 for body‑weight‑agnostic entries */
|
|
||||||
physicallyActive: number;
|
|
||||||
beginner: number;
|
|
||||||
intermediate: number;
|
|
||||||
advanced: number;
|
|
||||||
elite: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StandardsItem extends Levels {
|
|
||||||
bodyWeight: number;
|
|
||||||
gender: Gender;
|
|
||||||
activityType: Activity;
|
|
||||||
age: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Conversion utilities
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
export const lbToKg = (lb: number | string): number => +lb * 0.453592;
|
|
||||||
export const kgToLb = (kg: number | string): number => +kg * 2.20462;
|
|
||||||
|
|
||||||
export const feetToCm = (feet: number): number => feet * 30.48;
|
|
||||||
|
|
||||||
export const mphToMtrsPerMs = (mph: number): number => (mph * 0.44704) / 1000;
|
|
||||||
|
|
||||||
export const timeToMs = (time: string): number => {
|
|
||||||
const [main, decimal = "0"] = time.split(".");
|
|
||||||
const milliseconds = Number(decimal.padEnd(3, "0").slice(0, 3));
|
|
||||||
|
|
||||||
const parts = main.split(":").map(Number);
|
|
||||||
let minutes = 0,
|
|
||||||
seconds = 0;
|
|
||||||
|
|
||||||
if (parts.length === 1) {
|
|
||||||
seconds = parts[0];
|
|
||||||
} else if (parts.length === 2) {
|
|
||||||
[minutes, seconds] = parts as [number, number];
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid time format");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (minutes * 60 + seconds) * 1000 + milliseconds;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
// Misc. utilities
|
|
||||||
// -------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const findNearestPoints = (value: number, arr: number[]): { lower: number; upper: number } => {
|
|
||||||
if (arr.length === 1) return { lower: arr[0], upper: arr[0] };
|
|
||||||
|
|
||||||
if (value <= arr[0]) return { lower: arr[0], upper: arr[1] };
|
|
||||||
if (value > arr[arr.length - 1])
|
|
||||||
return { lower: arr[arr.length - 2], upper: arr[arr.length - 1] };
|
|
||||||
|
|
||||||
for (let i = 1; i < arr.length; i++) {
|
|
||||||
if (arr[i] >= value) return { lower: arr[i - 1], upper: arr[i] };
|
|
||||||
}
|
|
||||||
// Fallback, though logic should always return above
|
|
||||||
return { lower: arr[0], upper: arr[0] };
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# fly.toml app configuration file generated for blade-and-brawn on 2025-05-04T14:56:24-04:00
|
|
||||||
#
|
|
||||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
|
||||||
#
|
|
||||||
|
|
||||||
app = 'blade-and-brawn'
|
|
||||||
primary_region = 'ord'
|
|
||||||
|
|
||||||
[build]
|
|
||||||
|
|
||||||
[env]
|
|
||||||
PORT = '8080'
|
|
||||||
|
|
||||||
[http_service]
|
|
||||||
internal_port = 8080
|
|
||||||
force_https = true
|
|
||||||
auto_stop_machines = 'stop'
|
|
||||||
auto_start_machines = true
|
|
||||||
min_machines_running = 0
|
|
||||||
processes = ['app']
|
|
||||||
|
|
||||||
[[vm]]
|
|
||||||
memory = '1gb'
|
|
||||||
cpu_kind = 'shared'
|
|
||||||
cpus = 1
|
|
||||||
|
|
||||||
[[mounts]]
|
|
||||||
source = "data"
|
|
||||||
destination = "/data"
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts";
|
|
||||||
import { Activity, BenchmarkPerformance, Gender } from "./calculator/util.ts";
|
|
||||||
import { productRecords } from "./commerce/data/product-records.ts";
|
|
||||||
import { Printful } from "./commerce/printful.ts"
|
|
||||||
import { Webflow } from "./commerce/webflow.ts";
|
|
||||||
|
|
||||||
interface CalcRequest {
|
|
||||||
player: {
|
|
||||||
age: number,
|
|
||||||
weightKG: number,
|
|
||||||
gender: Gender
|
|
||||||
}
|
|
||||||
performances: {
|
|
||||||
activity: Activity,
|
|
||||||
performance: number,
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClientResponse extends Response {
|
|
||||||
constructor(body?: BodyInit | null, init: ResponseInit = {}) {
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
headers.set("Access-Control-Allow-Origin", "*");
|
|
||||||
headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
||||||
headers.set("Access-Control-Allow-Headers", "Content-Type");
|
|
||||||
super(body, { ...init, headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
static json(data: unknown, init: ResponseInit = {}) {
|
|
||||||
const body = JSON.stringify(data);
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
headers.set("Content-Type", "application/json");
|
|
||||||
return new ClientResponse(body, { ...init, headers });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
routes: {
|
|
||||||
"/calc": {
|
|
||||||
OPTIONS() {
|
|
||||||
return new ClientResponse(undefined, { status: 204 });
|
|
||||||
},
|
|
||||||
async POST(req) {
|
|
||||||
const calcReq: CalcRequest = await req.json();
|
|
||||||
const { player, performances } = calcReq;
|
|
||||||
|
|
||||||
const computedPerformances: BenchmarkPerformance[] = [];
|
|
||||||
for (const p of performances) {
|
|
||||||
computedPerformances.push({
|
|
||||||
activity: p.activity,
|
|
||||||
performance: p.performance,
|
|
||||||
levels: computeLevels(
|
|
||||||
player.age,
|
|
||||||
player.weightKG,
|
|
||||||
player.gender,
|
|
||||||
p.activity
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClientResponse.json({
|
|
||||||
levels: {
|
|
||||||
attributes: calcAttributeLevels(computedPerformances),
|
|
||||||
player: calcPlayerLevel(computedPerformances)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/webhook/printful": {
|
|
||||||
OPTIONS() {
|
|
||||||
return new ClientResponse(undefined, { status: 204 });
|
|
||||||
},
|
|
||||||
async POST(req) {
|
|
||||||
const payload: Printful.Webhook.EventPayload = await req.json()
|
|
||||||
console.log(JSON.stringify(payload));
|
|
||||||
console.log("PRINTFUL WEBHOOK: " + payload.type);
|
|
||||||
try {
|
|
||||||
switch (payload.type) {
|
|
||||||
// ADD/UPDATE
|
|
||||||
case Printful.Webhook.Event.ProductUpdated: {
|
|
||||||
const printfulProduct = await Printful.getSyncProduct(payload.data.sync_product.id);
|
|
||||||
const productRecord = productRecords.findFromPrintful(payload.data.sync_product.id);
|
|
||||||
|
|
||||||
if (productRecord) {
|
|
||||||
await Webflow.updateProductFromPrintful(printfulProduct);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await Webflow.createProductFromPrintful(printfulProduct);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// DELETE
|
|
||||||
case Printful.Webhook.Event.ProductDeleted: {
|
|
||||||
await Webflow.deleteProductFromPrintful(payload.data.sync_product.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
ClientResponse.error();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ClientResponse.json("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
development: true
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Listening on ${server.url}`);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "^1.2.11"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"db:seed:dev": "sqlite3 ./data/data.db < ./data/seed.sql",
|
|
||||||
"db:seed:prod": "sqlite3 /data/data.db < ./data/seed.sql",
|
|
||||||
"start": "bun run main.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import { activities, Activity, Attribute, attributes, BenchmarkPerformance, findNearestPoints, Gender, getActivityAttribute, kgToLb, Levels, msToTime, StandardsItem } from "./util";
|
import { activities, Activity, Attribute, attributes, BenchmarkPerformance, findNearestPoints, Gender, getActivityAttribute, kgToLb, Levels, msToTime, StandardsItem } from "./util";
|
||||||
import rawStandards from "./standards.json" assert { type: "json" }
|
import rawStandards from "./data/standards.json" assert { type: "json" }
|
||||||
import { which } from "bun";
|
|
||||||
|
|
||||||
const standards = rawStandards as StandardsItem[];
|
const standards = rawStandards as StandardsItem[];
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts";
|
import { calcAttributeLevels, calcPlayerLevel, computeLevels } from "./calculator/calc.ts";
|
||||||
import { Activity, BenchmarkPerformance, Gender } from "./calculator/util.ts";
|
import { Activity, BenchmarkPerformance, Gender } from "./calculator/util.ts";
|
||||||
import { productRecords } from "./data/product-records.ts";
|
import { productRecords } from "./commerce/data/product-records.ts";
|
||||||
import { Printful } from "./printful.ts"
|
import { Printful } from "./commerce/printful.ts"
|
||||||
import { Webflow } from "./webflow.ts";
|
import { Webflow } from "./commerce/webflow.ts";
|
||||||
|
|
||||||
interface CalcRequest {
|
interface CalcRequest {
|
||||||
player: {
|
player: {
|
||||||
@@ -43,8 +43,6 @@ const server = Bun.serve({
|
|||||||
const calcReq: CalcRequest = await req.json();
|
const calcReq: CalcRequest = await req.json();
|
||||||
const { player, performances } = calcReq;
|
const { player, performances } = calcReq;
|
||||||
|
|
||||||
console.log(calcReq);
|
|
||||||
|
|
||||||
const computedPerformances: BenchmarkPerformance[] = [];
|
const computedPerformances: BenchmarkPerformance[] = [];
|
||||||
for (const p of performances) {
|
for (const p of performances) {
|
||||||
computedPerformances.push({
|
computedPerformances.push({
|
||||||
@@ -73,6 +71,8 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
async POST(req) {
|
async POST(req) {
|
||||||
const payload: Printful.Webhook.EventPayload = await req.json()
|
const payload: Printful.Webhook.EventPayload = await req.json()
|
||||||
|
console.log(JSON.stringify(payload));
|
||||||
|
console.log("PRINTFUL WEBHOOK: " + payload.type);
|
||||||
try {
|
try {
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
// ADD/UPDATE
|
// ADD/UPDATE
|
||||||
|
|||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
export namespace Printful {
|
|
||||||
export const API_URL = "https://api.printful.com";
|
|
||||||
|
|
||||||
export namespace Webhook {
|
|
||||||
// meta
|
|
||||||
interface MetaData {
|
|
||||||
created: number;
|
|
||||||
retries: number;
|
|
||||||
store: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Event {
|
|
||||||
ProductUpdated = "product_updated",
|
|
||||||
ProductDeleted = "product_deleted"
|
|
||||||
}
|
|
||||||
|
|
||||||
// product updated
|
|
||||||
export interface ProductUpdated extends MetaData {
|
|
||||||
type: Event.ProductUpdated,
|
|
||||||
data: {
|
|
||||||
sync_product: {
|
|
||||||
id: number;
|
|
||||||
external_id: string;
|
|
||||||
name: string;
|
|
||||||
variants: number;
|
|
||||||
synced: number;
|
|
||||||
thumbnail_url: string;
|
|
||||||
is_ignored: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// product deleted
|
|
||||||
export interface ProductDeleted extends MetaData {
|
|
||||||
type: Event.ProductDeleted,
|
|
||||||
data: {
|
|
||||||
sync_product: {
|
|
||||||
id: number;
|
|
||||||
external_id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventPayload = ProductUpdated | ProductDeleted
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Products {
|
|
||||||
interface MetaData<T = any> {
|
|
||||||
code: number;
|
|
||||||
result: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncProductData {
|
|
||||||
sync_product: {
|
|
||||||
id: number;
|
|
||||||
external_id: string;
|
|
||||||
name: string;
|
|
||||||
variants: number;
|
|
||||||
synced: number;
|
|
||||||
thumbnail_url: string;
|
|
||||||
is_ignored: boolean;
|
|
||||||
},
|
|
||||||
sync_variants: {
|
|
||||||
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 type SyncProduct = MetaData<SyncProductData>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSyncProduct(syncProductId: number): Promise<Products.SyncProduct> {
|
|
||||||
const payload: Products.SyncProduct = await (
|
|
||||||
await fetch(`${Printful.API_URL}/store/products/${syncProductId}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`,
|
|
||||||
"X-PF-Store-Id": `${Bun.env.PRINTFUL_STORE_ID}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
).json();
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-276
@@ -1,276 +0,0 @@
|
|||||||
import { productRecords } from "./data/product-records";
|
|
||||||
import { Printful } from "./printful"
|
|
||||||
|
|
||||||
export namespace Webflow {
|
|
||||||
export const API_SITES_URL = `https://api.webflow.com/v2/sites/${Bun.env.WEBFLOW_SITE_ID}`;
|
|
||||||
export const API_COLLECTIONS_URL = `https://api.webflow.com/v2/collections/${Bun.env.WEBFLOW_COLLECTION_ID}`;
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
namespace Products {
|
|
||||||
export interface Sku {
|
|
||||||
fieldData: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
"sku-values"?: Record<string, string>,
|
|
||||||
price: {
|
|
||||||
value: number;
|
|
||||||
unit: string;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
"main-image": string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProduct {
|
|
||||||
product: {
|
|
||||||
fieldData: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
description: string;
|
|
||||||
"sku-properties": {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
enum: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
sku: Sku;
|
|
||||||
publishStatus: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProductFromPrintful(printfulProductId: number) {
|
|
||||||
const webflowProductId = productRecords.findFromPrintful(printfulProductId)?.webflowProductId;
|
|
||||||
if (webflowProductId) {
|
|
||||||
const res = await fetch(`${Webflow.API_COLLECTIONS_URL}/items/${webflowProductId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
productRecords.deleteFromWebflow(webflowProductId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProductFromPrintful(printfulProduct: Printful.Products.SyncProduct) {
|
|
||||||
const webflowProductId = productRecords.findFromPrintful(printfulProduct.result.sync_product.id)?.webflowProductId;
|
|
||||||
if (!webflowProductId) {
|
|
||||||
throw new Error("Product is not recognized");
|
|
||||||
}
|
|
||||||
|
|
||||||
const printfulVariants = printfulProduct.result.sync_variants;
|
|
||||||
const webflowVariants: Products.Sku[] = [];
|
|
||||||
for (const printfulVariant of printfulVariants) {
|
|
||||||
webflowVariants.push({
|
|
||||||
"fieldData": {
|
|
||||||
"name": printfulVariant.name,
|
|
||||||
"slug": formatSlug(printfulVariant.name),
|
|
||||||
"sku-values": {
|
|
||||||
"color": printfulVariant.color,
|
|
||||||
"size": printfulVariant.size,
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"value": +printfulVariant.retail_price * 100,
|
|
||||||
"unit": printfulVariant.currency,
|
|
||||||
"currency": printfulVariant.currency
|
|
||||||
},
|
|
||||||
// TODO: sync image
|
|
||||||
"main-image": "https://www.example.com/image.jpg"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundColors = Array.from(new Set(printfulVariants.map(v => v.color)));
|
|
||||||
const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size)));
|
|
||||||
|
|
||||||
let updateProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"product": {
|
|
||||||
"fieldData": {
|
|
||||||
"name": printfulProduct.result.sync_product.name,
|
|
||||||
"slug": formatSlug(printfulProduct.result.sync_product.name),
|
|
||||||
"sku-properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"name": "Color",
|
|
||||||
"enum": foundColors.map(color => ({
|
|
||||||
"id": color,
|
|
||||||
"slug": formatSlug(color),
|
|
||||||
"name": color
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "size",
|
|
||||||
"name": "Size",
|
|
||||||
"enum": foundSizes.map(size => ({
|
|
||||||
"id": size,
|
|
||||||
"slug": formatSlug(size),
|
|
||||||
"name": size
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sku": webflowVariants[0]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
updateProductResponse = await updateProductResponse.json();
|
|
||||||
|
|
||||||
const getProductResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const webflowProduct = await getProductResponse.json();
|
|
||||||
|
|
||||||
for (let i = 0; i < webflowProduct["skus"].length; ++i) {
|
|
||||||
const webflowSkuId = printfulVariants[i].external_id;
|
|
||||||
|
|
||||||
const res = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus/${webflowSkuId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"sku": webflowVariants[i]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify((await res.json())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProductFromPrintful(printfulProduct: Printful.Products.SyncProduct) {
|
|
||||||
const printfulVariants = printfulProduct.result.sync_variants;
|
|
||||||
|
|
||||||
const webflowVariants: Products.Sku[] = [];
|
|
||||||
for (const printfulVariant of printfulVariants) {
|
|
||||||
webflowVariants.push({
|
|
||||||
"fieldData": {
|
|
||||||
"name": printfulVariant.name,
|
|
||||||
"slug": formatSlug(printfulVariant.name),
|
|
||||||
"sku-values": {
|
|
||||||
"color": printfulVariant.color,
|
|
||||||
"size": printfulVariant.size,
|
|
||||||
},
|
|
||||||
"price": {
|
|
||||||
"value": +printfulVariant.retail_price * 100,
|
|
||||||
"unit": printfulVariant.currency,
|
|
||||||
"currency": printfulVariant.currency
|
|
||||||
},
|
|
||||||
// TODO: sync image
|
|
||||||
"main-image": "https://www.example.com/image.jpg"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundColors = Array.from(new Set(printfulVariants.map(v => v.color)));
|
|
||||||
const foundSizes = Array.from(new Set(printfulVariants.map(v => v.size)));
|
|
||||||
|
|
||||||
// CREATE WEBFLOW PRODUCT
|
|
||||||
let addProductResponse = await (await fetch(`${Webflow.API_SITES_URL}/products`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"product": {
|
|
||||||
"fieldData": {
|
|
||||||
"name": printfulProduct.result.sync_product.name,
|
|
||||||
"slug": formatSlug(printfulProduct.result.sync_product.name),
|
|
||||||
"sku-properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"name": "Color",
|
|
||||||
"enum": foundColors.map(color => ({
|
|
||||||
"id": color,
|
|
||||||
"slug": formatSlug(color),
|
|
||||||
"name": color
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "size",
|
|
||||||
"name": "Size",
|
|
||||||
"enum": foundSizes.map(size => ({
|
|
||||||
"id": size,
|
|
||||||
"slug": formatSlug(size),
|
|
||||||
"name": size
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sku": webflowVariants[0],
|
|
||||||
})
|
|
||||||
}))?.json();
|
|
||||||
|
|
||||||
if (!addProductResponse?.product?.id) {
|
|
||||||
console.error("Webflow product creation failed:", addProductResponse);
|
|
||||||
throw new Error("Failed to create Webflow product");
|
|
||||||
}
|
|
||||||
|
|
||||||
const webflowProductId = addProductResponse["product"]["id"];
|
|
||||||
const printfulProductId = printfulProduct.result.sync_product.id;
|
|
||||||
|
|
||||||
// CREATE WEBFLOW PRODUCT SKUs
|
|
||||||
let createSkuResponse = await fetch(`${Webflow.API_SITES_URL}/products/${webflowProductId}/skus`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `bearer ${Bun.env.WEBFLOW_AUTH}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"skus": webflowVariants.slice(1)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
createSkuResponse = await createSkuResponse.json();
|
|
||||||
|
|
||||||
const responseSkus = [addProductResponse["sku"], ...createSkuResponse["skus"]];
|
|
||||||
|
|
||||||
// SYNC WEBFLOW/PRINTFUL PRODUCT AND VARIANT IDs
|
|
||||||
let modifyPrintfulProductResponse = await fetch(`${Printful.API_URL}/store/products/${printfulProductId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": `Bearer ${Bun.env.PRINTFUL_AUTH}`,
|
|
||||||
"X-PF-Store-Id": Bun.env.PRINTFUL_STORE_ID ?? ""
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"sync_product": {
|
|
||||||
"external_id": String(webflowProductId)
|
|
||||||
},
|
|
||||||
"sync_variants": responseSkus.map((sku, i) => ({
|
|
||||||
"id": Number(printfulVariants[i].id), // printful variant id
|
|
||||||
"external_id": String(sku.id), // webflow variant id
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
modifyPrintfulProductResponse = await modifyPrintfulProductResponse.json();
|
|
||||||
|
|
||||||
// CACHE WEBFLOW/PRINTFUL PRODUCT ASSOCIATION
|
|
||||||
productRecords.add({ printfulProductId, webflowProductId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title></title>
|
|
||||||
<script src="main.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<h1 id="currentClass">None</h1>
|
|
||||||
<div id="btnContainer">
|
|
||||||
<button id="btn-rogue">Rogue</button>
|
|
||||||
<button id="btn-knight">Knight</button>
|
|
||||||
<button id="btn-warrior">Warrior</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="audioContainer"></div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
let currentAudio = null;
|
|
||||||
function playAudio(newAudio) {
|
|
||||||
// --------------
|
|
||||||
// HELPERS
|
|
||||||
// --------------
|
|
||||||
const TIME_STEP = 500;
|
|
||||||
const VOL_STEP = .05;
|
|
||||||
|
|
||||||
function fadeOut(audio) {
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
audio.volume = Math.max(0, audio.volume - VOL_STEP);
|
|
||||||
if (audio.volume <= 0) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, TIME_STEP)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fadeIn(audio) {
|
|
||||||
audio.volume = 0;
|
|
||||||
audio.play();
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
audio.volume = Math.min(1, audio.volume + VOL_STEP);
|
|
||||||
if (audio.volume >= 1) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, TIME_STEP)
|
|
||||||
}
|
|
||||||
// --------------
|
|
||||||
|
|
||||||
// --------------
|
|
||||||
// MAIN LOGIC
|
|
||||||
// --------------
|
|
||||||
|
|
||||||
const prevAudio = currentAudio;
|
|
||||||
|
|
||||||
if (prevAudio === newAudio)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (prevAudio) {
|
|
||||||
fadeOut(prevAudio);
|
|
||||||
}
|
|
||||||
|
|
||||||
fadeIn(newAudio);
|
|
||||||
|
|
||||||
currentAudio = newAudio;
|
|
||||||
// --------------
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
|
||||||
const $ = (id) => document.getElementById(id);
|
|
||||||
|
|
||||||
const classNames = ["rogue", "knight", "warrior"];
|
|
||||||
const audios = {};
|
|
||||||
|
|
||||||
for (const className of classNames) {
|
|
||||||
const audio = document.createElement("audio");
|
|
||||||
audio.src = `https://bladeandbrawn.com/${className}.ogg`;
|
|
||||||
audio.loop = true;
|
|
||||||
audio.volume = 0;
|
|
||||||
audio.id = `audio-${className}`;
|
|
||||||
$("audioContainer").appendChild(audio);
|
|
||||||
audios[className] = audio;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(classNames.map(async (className) => {
|
|
||||||
const audio = audios[className];
|
|
||||||
try {
|
|
||||||
await audio.play();
|
|
||||||
audio.pause();
|
|
||||||
audio.currentTime = 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Autoplay failed for ${className}:`, e);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Start all in perfect sync
|
|
||||||
classNames.forEach(className => {
|
|
||||||
const audio = audios[className];
|
|
||||||
audio.currentTime = 0;
|
|
||||||
audio.play();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hook up buttons
|
|
||||||
classNames.forEach(className => {
|
|
||||||
$(`btn-${className}`).onclick = () => {
|
|
||||||
$("currentClass").textContent = className;
|
|
||||||
playAudio(audios[className]);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial state
|
|
||||||
$("currentClass").textContent = "rogue";
|
|
||||||
playAudio(audios["rogue"]);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user