fix bugs and compensation and add in admin tools

This commit is contained in:
Nathan 2026-02-03 18:03:33 -05:00
parent 2b3d38a5f2
commit 09b10ac900
18 changed files with 682 additions and 176 deletions

View file

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "app",
@ -161,11 +162,11 @@
"openid-client": ["openid-client@6.8.1", "", { "dependencies": { "jose": "^6.1.0", "oauth4webapi": "^3.8.2" } }, "sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
"pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],

117
backend/dist/index.js vendored
View file

@ -1819,8 +1819,8 @@ function mergeArrays(chunks) {
var import_debug, debug, syncBufferSize, ddSignatureArray, eocdSignatureBytes;
var init_ZipHandler = __esm(() => {
init_lib3();
import_debug = __toESM(require_src(), 1);
init_ZipToken();
import_debug = __toESM(require_src(), 1);
debug = import_debug.default("tokenizer:inflate");
syncBufferSize = 256 * 1024;
ddSignatureArray = signatureToArray(Signature.DataDescriptor);
@ -5848,7 +5848,6 @@ var require_type_overrides = __commonJS((exports, module) => {
// node_modules/pg-connection-string/index.js
var require_pg_connection_string = __commonJS((exports, module) => {
var { emitWarning } = __require("process");
function parse2(str, options = {}) {
if (str.charAt(0) === "/") {
const config3 = str.split(" ");
@ -6007,9 +6006,9 @@ var require_pg_connection_string = __commonJS((exports, module) => {
return toClientConfig(parse2(str));
}
function deprecatedSslModeWarning(sslmode) {
if (!deprecatedSslModeWarning.warned) {
if (!deprecatedSslModeWarning.warned && typeof process !== "undefined" && process.emitWarning) {
deprecatedSslModeWarning.warned = true;
emitWarning(`SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'.
process.emitWarning(`SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'.
In the next major version (pg-connection-string v3.0.0 and pg v9.0.0), these modes will adopt standard libpq semantics, which have weaker security guarantees.
To prepare for this change:
@ -7917,7 +7916,7 @@ var require_client = __commonJS((exports, module) => {
if (error) {
reject(error);
} else {
resolve();
resolve(this);
}
});
});
@ -8073,16 +8072,40 @@ var require_client = __commonJS((exports, module) => {
activeQuery.handleError(msg, this.connection);
}
_handleRowDescription(msg) {
this._getActiveQuery().handleRowDescription(msg);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected rowDescription message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handleRowDescription(msg);
}
_handleDataRow(msg) {
this._getActiveQuery().handleDataRow(msg);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected dataRow message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handleDataRow(msg);
}
_handlePortalSuspended(msg) {
this._getActiveQuery().handlePortalSuspended(this.connection);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected portalSuspended message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handlePortalSuspended(this.connection);
}
_handleEmptyQuery(msg) {
this._getActiveQuery().handleEmptyQuery(this.connection);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected emptyQuery message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handleEmptyQuery(this.connection);
}
_handleCommandComplete(msg) {
const activeQuery = this._getActiveQuery();
@ -8105,10 +8128,22 @@ var require_client = __commonJS((exports, module) => {
}
}
_handleCopyInResponse(msg) {
this._getActiveQuery().handleCopyInResponse(this.connection);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected copyInResponse message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handleCopyInResponse(this.connection);
}
_handleCopyData(msg) {
this._getActiveQuery().handleCopyData(msg, this.connection);
const activeQuery = this._getActiveQuery();
if (activeQuery == null) {
const error = new Error("Received unexpected copyData message from backend.");
this._handleErrorEvent(error);
return;
}
activeQuery.handleCopyData(msg, this.connection);
}
_handleNotification(msg) {
this.emit("notification", msg);
@ -8902,7 +8937,7 @@ var require_client2 = __commonJS((exports, module) => {
});
self.emit("connect");
self._pulseQueryQueue(true);
cb();
cb(null, this);
});
});
};
@ -8916,7 +8951,7 @@ var require_client2 = __commonJS((exports, module) => {
if (error) {
reject(error);
} else {
resolve();
resolve(this);
}
});
});
@ -28575,16 +28610,24 @@ projects.get("/:id", async ({ params, headers }) => {
reviewer: reviewers.find((rv) => rv.id === r.reviewerId) || null
});
}
const submissions = await db.select({
const activityEntries = await db.select({
id: activityTable.id,
action: activityTable.action,
createdAt: activityTable.createdAt
}).from(activityTable).where(and(eq(activityTable.projectId, parseInt(params.id)), eq(activityTable.action, "project_submitted")));
for (const s of submissions) {
activity.push({
type: "submitted",
createdAt: s.createdAt
});
}).from(activityTable).where(and(eq(activityTable.projectId, parseInt(params.id)), or(eq(activityTable.action, "project_submitted"), sql`${activityTable.action} LIKE 'earned % scraps'`)));
for (const entry of activityEntries) {
if (entry.action === "project_submitted") {
activity.push({
type: "submitted",
createdAt: entry.createdAt
});
} else if (entry.action.startsWith("earned ") && entry.action.endsWith(" scraps")) {
activity.push({
type: "scraps_earned",
action: entry.action,
createdAt: entry.createdAt
});
}
}
activity.push({
type: "created",
@ -28608,6 +28651,7 @@ projects.get("/:id", async ({ params, headers }) => {
hours: project[0].hoursOverride ?? project[0].hours,
hoursOverride: isOwner ? project[0].hoursOverride : undefined,
status: project[0].status,
scrapsAwarded: project[0].scrapsAwarded,
createdAt: project[0].createdAt,
updatedAt: project[0].updatedAt
},
@ -28903,7 +28947,7 @@ var shopPenaltiesTable = pgTable("shop_penalties", {
// src/lib/scraps.ts
var PHI = (1 + Math.sqrt(5)) / 2;
var MULTIPLIER = 120;
var MULTIPLIER = 10;
function calculateScrapsFromHours(hours) {
return Math.floor(hours * PHI * MULTIPLIER);
}
@ -29159,6 +29203,7 @@ shop.get("/items", async ({ headers }) => {
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
@ -29212,6 +29257,7 @@ shop.get("/items/:id", async ({ params, headers }) => {
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
@ -29272,7 +29318,9 @@ shop.post("/items/:id/heart", async ({ params, headers }) => {
SELECT EXISTS(SELECT 1 FROM ins) AS hearted
`);
const hearted = result.rows[0]?.hearted ?? false;
return { hearted };
const countResult = await db.select({ count: sql`COUNT(*)` }).from(shopHeartsTable).where(eq(shopHeartsTable.shopItemId, itemId));
const heartCount = Number(countResult[0]?.count) || 0;
return { hearted, heartCount };
});
shop.get("/categories", async () => {
const result = await db.selectDistinct({ category: shopItemsTable.category }).from(shopItemsTable);
@ -29366,6 +29414,9 @@ shop.get("/orders", async ({ headers }) => {
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
orderType: shopOrdersTable.orderType,
shippingAddress: shopOrdersTable.shippingAddress,
isFulfilled: shopOrdersTable.isFulfilled,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
@ -29451,12 +29502,23 @@ shop.post("/items/:id/try-luck", async ({ params, headers }) => {
}
return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled };
}
return { won: false, effectiveProbability, rolled };
const consolationOrder = await tx.insert(shopOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: "pending",
orderType: "consolation",
notes: `Consolation scrap paper - rolled ${rolled}, needed ${effectiveProbability} or less`
}).returning();
return { won: false, effectiveProbability, rolled, consolationOrderId: consolationOrder[0].id };
});
if (result.won) {
return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true };
}
return { success: true, won: false, effectiveProbability: result.effectiveProbability, rolled: result.rolled };
return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled };
} catch (e) {
const err = e;
if (err.type === "insufficient_funds") {
@ -29502,15 +29564,16 @@ shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => {
const { balance } = await getUserScrapsBalance(user2.id, tx);
throw { type: "insufficient_funds", balance, cost };
}
const newBoost = currentBoost + 1;
const boostAmount = item.boostAmount;
const newBoost = currentBoost + boostAmount;
await tx.insert(refineryOrdersTable).values({
userId: user2.id,
shopItemId: itemId,
cost,
boostAmount: 1
boostAmount
});
const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost));
return { boostPercent: newBoost, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) };
return { boostPercent: newBoost, boostAmount, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) };
});
return result;
} catch (e) {

View file

@ -13,7 +13,7 @@
"drizzle-orm": "^0.45.1",
"elysia": "latest",
"openid-client": "^6.8.1",
"pg": "^8.17.2"
"pg": "^8.18.0"
},
"devDependencies": {
"@types/pg": "^8.16.0",

View file

@ -6,7 +6,7 @@ import { shopOrdersTable, refineryOrdersTable } from '../schemas/shop'
import { userBonusesTable } from '../schemas/users'
export const PHI = (1 + Math.sqrt(5)) / 2
export const MULTIPLIER = 120
export const MULTIPLIER = 10
export function calculateScrapsFromHours(hours: number): number {
return Math.floor(hours * PHI * MULTIPLIER)

View file

@ -151,8 +151,8 @@ projects.get('/:id', async ({ params, headers }) => {
})
}
// Fetch submission events from activity table
const submissions = await db
// Fetch submission and scraps earned events from activity table
const activityEntries = await db
.select({
id: activityTable.id,
action: activityTable.action,
@ -161,14 +161,25 @@ projects.get('/:id', async ({ params, headers }) => {
.from(activityTable)
.where(and(
eq(activityTable.projectId, parseInt(params.id)),
eq(activityTable.action, 'project_submitted')
or(
eq(activityTable.action, 'project_submitted'),
sql`${activityTable.action} LIKE 'earned % scraps'`
)
))
for (const s of submissions) {
activity.push({
type: 'submitted',
createdAt: s.createdAt
})
for (const entry of activityEntries) {
if (entry.action === 'project_submitted') {
activity.push({
type: 'submitted',
createdAt: entry.createdAt
})
} else if (entry.action.startsWith('earned ') && entry.action.endsWith(' scraps')) {
activity.push({
type: 'scraps_earned',
action: entry.action,
createdAt: entry.createdAt
})
}
}
// Add "project created" entry
@ -197,6 +208,7 @@ projects.get('/:id', async ({ params, headers }) => {
hours: project[0].hoursOverride ?? project[0].hours,
hoursOverride: isOwner ? project[0].hoursOverride : undefined,
status: project[0].status,
scrapsAwarded: project[0].scrapsAwarded,
createdAt: project[0].createdAt,
updatedAt: project[0].updatedAt
},

View file

@ -23,6 +23,7 @@ shop.get('/items', async ({ headers }) => {
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@ -96,6 +97,7 @@ shop.get('/items/:id', async ({ params, headers }) => {
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@ -200,7 +202,16 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
`)
const hearted = (result.rows[0] as { hearted: boolean })?.hearted ?? false
return { hearted }
// Get the updated heart count
const countResult = await db
.select({ count: sql<number>`COUNT(*)` })
.from(shopHeartsTable)
.where(eq(shopHeartsTable.shopItemId, itemId))
const heartCount = Number(countResult[0]?.count) || 0
return { hearted, heartCount }
})
shop.get('/categories', async () => {
@ -336,6 +347,9 @@ shop.get('/orders', async ({ headers }) => {
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
orderType: shopOrdersTable.orderType,
shippingAddress: shopOrdersTable.shippingAddress,
isFulfilled: shopOrdersTable.isFulfilled,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
@ -500,13 +514,29 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => {
return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled }
}
return { won: false, effectiveProbability, rolled }
// Create consolation order for scrap paper when user loses
const consolationOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: 'pending',
orderType: 'consolation',
notes: `Consolation scrap paper - rolled ${rolled}, needed ${effectiveProbability} or less`
})
.returning()
return { won: false, effectiveProbability, rolled, consolationOrderId: consolationOrder[0].id }
})
if (result.won) {
return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true }
}
return { success: true, won: false, effectiveProbability: result.effectiveProbability, rolled: result.rolled }
return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled }
} catch (e) {
const err = e as { type?: string; balance?: number }
if (err.type === 'insufficient_funds') {
@ -584,21 +614,22 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => {
throw { type: 'insufficient_funds', balance, cost }
}
const newBoost = currentBoost + 1
const boostAmount = item.boostAmount
const newBoost = currentBoost + boostAmount
// Record the refinery order
await tx.insert(refineryOrdersTable).values({
userId: user.id,
shopItemId: itemId,
cost,
boostAmount: 1
boostAmount
})
const nextCost = newBoost >= maxBoost
? null
: Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost))
return { boostPercent: newBoost, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) }
return { boostPercent: newBoost, boostAmount, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) }
})
return result

View file

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "scraps",
@ -104,13 +105,13 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/compat": ["@eslint/compat@2.0.1", "", { "dependencies": { "@eslint/core": "^1.0.1" }, "peerDependencies": { "eslint": "^8.40 || 9" }, "optionalPeers": ["eslint"] }, "sha512-yl/JsgplclzuvGFNqwNYV4XNPhP3l62ZOP9w/47atNAdmDtIFCx6X7CSk/SlWUuBGkT4Et/5+UD+WyvX2iiIWA=="],
"@eslint/compat": ["@eslint/compat@2.0.2", "", { "dependencies": { "@eslint/core": "^1.1.0" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@1.0.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-r18fEAj9uCk+VjzGt2thsbOmychS+4kxI14spVNibUO2vqKX7obOG+ymZljAwuPZl+S3clPGwCwTDtrdqTiY6Q=="],
"@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
@ -204,7 +205,7 @@
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
"@sveltejs/kit": ["@sveltejs/kit@2.50.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
@ -248,29 +249,29 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/node": ["@types/node@22.19.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.53.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.53.1", "@typescript-eslint/types": "^8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1" } }, "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.53.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/utils": "8.53.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.53.1", "", {}, "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.53.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.53.1", "@typescript-eslint/tsconfig-utils": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.53.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@ -386,7 +387,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.1.0", "", {}, "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw=="],
"globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@ -552,7 +553,7 @@
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@ -572,9 +573,9 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svelte": ["svelte@5.48.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q=="],
"svelte": ["svelte@5.49.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ=="],
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-check": ["svelte-check@4.3.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
@ -594,7 +595,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.53.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.1", "@typescript-eslint/parser": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/utils": "8.53.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg=="],
"typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@ -642,10 +643,14 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/pg/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],

View file

@ -5,27 +5,27 @@
"node": "22"
},
"devDependencies": {
"@eslint/compat": "^2.0.1",
"@eslint/compat": "^2.0.2",
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22",
"@types/node": "^22.19.8",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.1.0",
"globals": "^17.3.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.48.2",
"svelte-check": "^4.3.5",
"svelte": "^5.49.1",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1"
},
"private": true,

View file

@ -17,16 +17,20 @@
primary: boolean
}
import type { Snippet } from 'svelte'
let {
orderId,
itemName,
onClose,
onComplete
onComplete,
header
}: {
orderId: number
itemName: string
onClose: () => void
onComplete: () => void
header?: Snippet
} = $props()
let addresses = $state<Address[]>([])
@ -127,13 +131,17 @@
<h2 class="text-2xl font-bold">shipping address</h2>
</div>
<div class="mb-6 p-4 border-2 border-black rounded-lg bg-gray-50">
<p class="text-lg font-bold">🎉 congratulations!</p>
<p class="text-gray-600 mt-1">
you won <span class="font-bold">{itemName}</span>! select your shipping address to receive
it.
</p>
</div>
{#if header}
{@render header()}
{:else}
<div class="mb-6 p-4 border-2 border-black rounded-lg bg-gray-50">
<p class="text-lg font-bold">🎉 congratulations!</p>
<p class="text-gray-600 mt-1">
you won <span class="font-bold">{itemName}</span>! select your shipping address to receive
it.
</p>
</div>
{/if}
{#if error}
<div class="mb-4 p-3 bg-red-100 border-2 border-red-500 rounded-lg text-red-700 text-sm">
@ -213,7 +221,7 @@
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-black transition-colors"
>
<ExternalLink size={14} />
manage addresses on hack club
manage addresses on hack club auth
</a>
{:else}
<div class="text-center py-6">

View file

@ -29,11 +29,13 @@
let {
item,
onClose,
onTryLuck
onTryLuck,
onConsolation
}: {
item: ShopItem
onClose: () => void
onTryLuck: (orderId: number) => void
onConsolation: (orderId: number, rolled: number, needed: number) => void
} = $props()
let activeTab = $state<'leaderboard' | 'wishlist' | 'buyers'>('leaderboard')
@ -118,10 +120,11 @@
credentials: 'include'
})
if (response.ok) {
localHearted = !localHearted
localHeartCount = localHearted ? localHeartCount + 1 : localHeartCount - 1
const data = await response.json()
localHearted = data.hearted
localHeartCount = data.heartCount
// Sync with the store so the shop page updates
updateShopItemHeart(item.id, localHearted)
updateShopItemHeart(item.id, localHearted, localHeartCount)
}
} catch (e) {
console.error('Failed to toggle heart:', e)
@ -143,12 +146,11 @@
return
}
await refreshUserScraps()
if (data.won) {
await refreshUserScraps()
onTryLuck(data.orderId)
} else {
alertType = 'info'
alertMessage = 'Better luck next time! You rolled ' + data.rolled + ' but needed ' + data.effectiveProbability.toFixed(0) + ' or less.'
onConsolation(data.consolationOrderId, data.rolled, Math.floor(data.effectiveProbability))
}
} catch (e) {
console.error('Failed to try luck:', e)
@ -333,19 +335,25 @@
{/if}
</div>
<button
onclick={() => (showConfirmation = true)}
disabled={item.count === 0 || tryingLuck || !canAfford}
class="w-full px-4 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer text-lg"
>
{#if item.count === 0}
out of stock
{:else if !canAfford}
not enough scraps
{:else}
try your luck
{/if}
</button>
{#if item.count === 0}
<span
class="w-full px-4 py-3 border-4 border-dashed border-gray-300 text-gray-400 rounded-full font-bold text-lg text-center cursor-not-allowed block"
>
sold out
</span>
{:else}
<button
onclick={() => (showConfirmation = true)}
disabled={tryingLuck || !canAfford}
class="w-full px-4 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer text-lg"
>
{#if !canAfford}
not enough scraps
{:else}
try your luck
{/if}
</button>
{/if}
</div>
{#if showConfirmation}

View file

@ -28,6 +28,7 @@ export interface ShopItem {
baseProbability: number
baseUpgradeCost: number
costMultiplier: number
boostAmount: number
userBoostPercent: number
effectiveProbability: number
}
@ -260,14 +261,14 @@ export function updateProject(id: number, updates: Partial<Project>) {
)
}
export function updateShopItemHeart(itemId: number, hearted: boolean) {
export function updateShopItemHeart(itemId: number, hearted: boolean, heartCount?: number) {
shopItemsStore.update((items) =>
items.map((item) => {
if (item.id === itemId) {
return {
...item,
userHearted: hearted,
heartCount: hearted ? item.heartCount + 1 : item.heartCount - 1
heartCount: heartCount ?? (hearted ? item.heartCount + 1 : item.heartCount - 1)
}
}
return item

View file

@ -42,9 +42,22 @@
let formCount = $state(0)
let formBaseProbability = $state(50)
let formBaseUpgradeCost = $state(10)
let formCostMultiplier = $state(115)
let formCostMultiplier = $state(101)
let formBoostAmount = $state(1)
let formMonetaryValue = $state(0)
let formError = $state<string | null>(null)
const PHI = (1 + Math.sqrt(5)) / 2
const SCRAPS_PER_HOUR = PHI * 10
const DOLLARS_PER_HOUR = 5
const SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR
function updateFromMonetary(value: number) {
formMonetaryValue = value
formPrice = Math.round(value * SCRAPS_PER_DOLLAR)
formBaseUpgradeCost = Math.round(formPrice * 0.1) || 1
formBaseProbability = Math.max(0.1, Math.min(100, Math.round((100 - value * 2) * 10) / 10))
}
let deleteConfirmId = $state<number | null>(null)
onMount(async () => {
@ -79,11 +92,12 @@
formImage = ''
formDescription = ''
formPrice = 0
formMonetaryValue = 0
formCategory = ''
formCount = 0
formBaseProbability = 50
formBaseUpgradeCost = 10
formCostMultiplier = 115
formCostMultiplier = 101
formBoostAmount = 1
formError = null
showModal = true
@ -95,6 +109,7 @@
formImage = item.image
formDescription = item.description
formPrice = item.price
formMonetaryValue = item.price / SCRAPS_PER_DOLLAR
formCategory = item.category
formCount = item.count
formBaseProbability = item.baseProbability
@ -198,6 +213,22 @@
</button>
</div>
<div class="border-4 border-black rounded-2xl p-4 mb-8">
<h3 class="font-bold mb-3">scraps per hour reference</h3>
<div class="grid grid-cols-4 gap-4 text-center text-sm">
{#each [0.8, 1, 1.25, 1.5] as mult}
<div class="p-3 bg-gray-100 rounded-lg">
<div class="text-gray-500 mb-1">{mult}x</div>
<div class="font-bold flex items-center justify-center gap-1">
<Spool size={14} />
{Math.round(SCRAPS_PER_HOUR * mult)}
</div>
<div class="text-xs text-gray-500">${(DOLLARS_PER_HOUR * mult).toFixed(2)}/hr</div>
</div>
{/each}
</div>
</div>
{#if loading}
<div class="text-center py-12 text-gray-500">loading...</div>
{:else if items.length === 0}
@ -215,19 +246,19 @@
<h3 class="font-bold text-xl">{item.name}</h3>
<p class="text-sm text-gray-600 truncate">{item.description}</p>
<div class="flex items-center gap-2 mt-1 text-sm flex-wrap">
<span class="font-bold">${(item.price / SCRAPS_PER_DOLLAR).toFixed(2)}</span>
<span class="text-gray-500"></span>
<span class="font-bold flex items-center gap-1"><Spool size={16} />{item.price}</span>
{#each item.category.split(',').map(c => c.trim()).filter(Boolean) as cat}
<span class="px-2 py-0.5 bg-gray-100 rounded-full">{cat}</span>
{/each}
<span class="text-gray-500">{item.count} in stock</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.baseProbability}% base chance</span>
<span class="text-gray-500">{item.baseProbability}%</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.baseUpgradeCost} upgrade cost</span>
<span class="text-gray-500">+{item.boostAmount ?? 1}%/upgrade</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.costMultiplier / 100}x multiplier</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">+{item.boostAmount ?? 1}% per upgrade</span>
<span class="text-gray-500">~{(item.price / SCRAPS_PER_HOUR).toFixed(1)} hrs</span>
</div>
</div>
<div class="flex gap-2 shrink-0">
@ -297,17 +328,23 @@
></textarea>
</div>
<div>
<label for="monetaryValue" class="block text-sm font-bold mb-1">value ($)</label>
<input
id="monetaryValue"
type="number"
value={formMonetaryValue}
oninput={(e) => updateFromMonetary(parseFloat(e.currentTarget.value) || 0)}
min="0"
step="0.01"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">
= {formPrice} scraps · {formBaseProbability}% base probability · {formBaseUpgradeCost} upgrade cost · ~{(formPrice / SCRAPS_PER_HOUR).toFixed(1)} hrs
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="price" class="block text-sm font-bold mb-1">price (scraps)</label>
<input
id="price"
type="number"
bind:value={formPrice}
min="0"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="count" class="block text-sm font-bold mb-1">stock count</label>
<input
@ -318,17 +355,16 @@
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div>
<label for="category" class="block text-sm font-bold mb-1">categories (comma separated)</label>
<input
id="category"
type="text"
bind:value={formCategory}
placeholder="stickers, hardware, misc"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<div>
<label for="category" class="block text-sm font-bold mb-1">categories</label>
<input
id="category"
type="text"
bind:value={formCategory}
placeholder="stickers, hardware"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
@ -338,8 +374,9 @@
id="baseProbability"
type="number"
bind:value={formBaseProbability}
min="0"
min="0.1"
max="100"
step="0.1"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
@ -352,13 +389,12 @@
min="1"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">probability increase per upgrade</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="baseUpgradeCost" class="block text-sm font-bold mb-1">base upgrade cost (scraps)</label>
<label for="baseUpgradeCost" class="block text-sm font-bold mb-1">base upgrade cost</label>
<input
id="baseUpgradeCost"
type="number"
@ -366,6 +402,7 @@
min="0"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">auto-set to 10% of price</p>
</div>
<div>
<label for="costMultiplier" class="block text-sm font-bold mb-1">cost multiplier (%)</label>
@ -376,7 +413,7 @@
min="100"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">115 = 1.15x cost increase per upgrade</p>
<p class="text-xs text-gray-500 mt-1">115 = 1.15x per upgrade</p>
</div>
</div>
</div>

View file

@ -0,0 +1,245 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { ArrowLeft, Package, CheckCircle, Clock, Truck, MapPin, Origami } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { getUser } from '$lib/auth-client'
interface Order {
id: number
quantity: number
pricePerItem: number
totalPrice: number
status: string
orderType: string
shippingAddress: string | null
isFulfilled: boolean
createdAt: string
itemId: number
itemName: string
itemImage: string
}
let orders = $state<Order[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
onMount(async () => {
const user = await getUser()
if (!user) {
goto('/')
return
}
try {
const response = await fetch(`${API_URL}/shop/orders`, {
credentials: 'include'
})
if (!response.ok) {
throw new Error('Failed to fetch orders')
}
const data = await response.json()
if (data.error) {
throw new Error(data.error)
}
orders = data
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load orders'
} finally {
loading = false
}
})
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getOrderTypeLabel(orderType: string): string {
switch (orderType) {
case 'luck_win':
return 'won'
case 'consolation':
return 'consolation'
case 'purchase':
return 'purchased'
default:
return orderType
}
}
function getStatusColor(status: string, isFulfilled: boolean): string {
if (isFulfilled) return 'bg-green-100 text-green-700 border-green-600'
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-700 border-yellow-600'
case 'shipped':
return 'bg-blue-100 text-blue-700 border-blue-600'
case 'delivered':
return 'bg-green-100 text-green-700 border-green-600'
default:
return 'bg-gray-100 text-gray-700 border-gray-600'
}
}
function getStatusIcon(status: string, isFulfilled: boolean) {
if (isFulfilled) return CheckCircle
switch (status) {
case 'shipped':
return Truck
case 'delivered':
return CheckCircle
default:
return Clock
}
}
function getStatusLabel(status: string, isFulfilled: boolean): string {
if (isFulfilled) return 'fulfilled'
return status
}
interface ParsedAddress {
firstName?: string
lastName?: string
address1?: string
address2?: string | null
city?: string
state?: string
postalCode?: string
country?: string
phone?: string
}
function parseAddress(addressJson: string): ParsedAddress | null {
try {
return JSON.parse(addressJson)
} catch {
return null
}
}
function formatAddress(addressJson: string): string {
const addr = parseAddress(addressJson)
if (!addr) return addressJson
const parts: string[] = []
if (addr.firstName || addr.lastName) {
parts.push([addr.firstName, addr.lastName].filter(Boolean).join(' '))
}
if (addr.address1) parts.push(addr.address1)
if (addr.address2) parts.push(addr.address2)
if (addr.city || addr.state || addr.postalCode) {
const cityLine = [addr.city, addr.state].filter(Boolean).join(', ')
parts.push([cityLine, addr.postalCode].filter(Boolean).join(' '))
}
if (addr.country) parts.push(addr.country)
return parts.join(', ')
}
</script>
<svelte:head>
<title>my orders - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">
<a
href="/shop"
class="inline-flex items-center gap-2 mb-8 font-bold hover:underline cursor-pointer"
>
<ArrowLeft size={20} />
back to shop
</a>
<h1 class="text-4xl md:text-5xl font-bold mb-8">my orders</h1>
{#if loading}
<div class="text-center py-12 text-gray-500">loading orders...</div>
{:else if error}
<div class="text-center py-12 text-red-600">{error}</div>
{:else if orders.length === 0}
<div class="border-4 border-dashed border-gray-300 rounded-2xl p-12 text-center">
<Package size={48} class="mx-auto text-gray-400 mb-4" />
<p class="text-gray-500 text-lg">no orders yet</p>
<p class="text-gray-400 text-sm mt-2">try your luck in the shop to get some goodies!</p>
<a
href="/shop"
class="inline-block mt-6 px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 cursor-pointer"
>
go to shop
</a>
</div>
{:else}
<div class="space-y-4">
{#each orders as order}
{@const StatusIcon = getStatusIcon(order.status, order.isFulfilled)}
{@const isConsolation = order.orderType === 'consolation'}
<div class="border-4 border-black rounded-2xl p-6 hover:border-dashed transition-all duration-200">
<div class="flex gap-4">
{#if isConsolation}
<div class="w-20 h-20 rounded-lg border-2 border-black bg-yellow-50 shrink-0 flex items-center justify-center">
<Origami size={40} class="text-yellow-600" />
</div>
{:else}
<img
src={order.itemImage}
alt={order.itemName}
class="w-20 h-20 object-contain rounded-lg border-2 border-black bg-gray-50 shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div>
{#if isConsolation}
<h3 class="font-bold text-xl">paper scraps</h3>
<p class="text-sm text-gray-400 line-through">{order.itemName}</p>
{:else}
<h3 class="font-bold text-xl">{order.itemName}</h3>
{/if}
<p class="text-sm text-gray-500">
{getOrderTypeLabel(order.orderType)} · {formatDate(order.createdAt)}
</p>
</div>
<span
class="shrink-0 px-3 py-1 rounded-full text-sm font-bold border-2 flex items-center gap-1 {getStatusColor(order.status, order.isFulfilled)}"
>
<StatusIcon size={14} />
{getStatusLabel(order.status, order.isFulfilled)}
</span>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-sm">
<span class="text-gray-600">
<span class="font-bold">{order.totalPrice}</span> scraps
</span>
{#if order.quantity > 1}
<span class="text-gray-600">qty: {order.quantity}</span>
{/if}
</div>
{#if order.shippingAddress}
<div class="mt-3 flex items-start gap-2 text-sm text-gray-600">
<MapPin size={16} class="shrink-0 mt-0.5" />
<span class="break-words">{formatAddress(order.shippingAddress)}</span>
</div>
{:else if !order.isFulfilled}
<p class="mt-3 text-sm text-yellow-600 font-bold">
⚠️ no shipping address provided
</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1 @@
export const ssr = false

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { ArrowLeft, Pencil, Send, Clock, CheckCircle, XCircle, AlertCircle, Github, AlertTriangle, PlaneTakeoff, Plus, Globe } from '@lucide/svelte'
import { ArrowLeft, Pencil, Send, Clock, CheckCircle, XCircle, AlertCircle, Github, AlertTriangle, PlaneTakeoff, Plus, Globe, Spool } from '@lucide/svelte'
import { getUser } from '$lib/auth-client'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
@ -21,6 +21,7 @@
hours: number
hoursOverride?: number | null
status: string
scrapsAwarded: number
createdAt: string
updatedAt: string
}
@ -32,7 +33,7 @@
}
interface ActivityEntry {
type: 'review' | 'created' | 'submitted'
type: 'review' | 'created' | 'submitted' | 'scraps_earned'
action?: string
feedbackForAuthor?: string | null
createdAt: string
@ -215,6 +216,12 @@
<Clock size={18} />
{formatHours(project.hours)}h
</span>
{#if project.scrapsAwarded > 0}
<span class="px-4 py-2 bg-green-100 text-green-700 rounded-full font-bold border-4 border-green-600 flex items-center gap-2">
<Spool size={18} />
+{project.scrapsAwarded} scraps earned
</span>
{/if}
{#if project.githubUrl}
<a
href={project.githubUrl}
@ -365,6 +372,13 @@
</div>
</div>
</div>
{:else if entry.type === 'scraps_earned'}
<div class="relative flex items-center gap-3 ml-8 py-2">
<div class="absolute left-[-26px] w-6 h-6 bg-white rounded-full flex items-center justify-center z-10">
<Spool size={16} class="text-green-600" />
</div>
<span class="text-sm text-green-600 font-bold">{entry.action} · {formatDate(entry.createdAt)}</span>
</div>
{:else if entry.type === 'submitted'}
<div class="relative flex items-center gap-3 ml-8 py-2">
<div class="absolute left-[-26px] w-6 h-6 bg-white rounded-full flex items-center justify-center z-10">

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { ArrowLeft, Github, Clock, CheckCircle, AlertTriangle, Package } from '@lucide/svelte'
import { ArrowLeft, Github, Globe, Clock, CheckCircle, AlertTriangle, Package } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
import { getUser } from '$lib/auth-client'
@ -14,6 +14,7 @@
description: string
image: string | null
githubUrl: string | null
playableUrl: string | null
hours: number
status: string
createdAt: string
@ -124,22 +125,45 @@
</span>
</div>
{#if project.githubUrl}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 mt-4 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
>
<Github size={18} />
<span>view on github</span>
</a>
{/if}
<div class="flex flex-wrap gap-3 mt-4">
{#if project.githubUrl}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
>
<Github size={18} />
<span>view on github</span>
</a>
{:else}
<span class="inline-flex items-center gap-2 px-4 py-2 border-4 border-dashed border-gray-300 text-gray-400 rounded-full font-bold cursor-not-allowed">
<Github size={18} />
<span>view on github</span>
</span>
{/if}
{#if project.playableUrl}
<a
href={project.playableUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
>
<Globe size={18} />
<span>try it out</span>
</a>
{:else}
<span class="inline-flex items-center gap-2 px-4 py-2 border-4 border-dashed border-gray-300 text-gray-400 rounded-full font-bold cursor-not-allowed">
<Globe size={18} />
<span>try it out</span>
</span>
{/if}
</div>
{#if isOwner}
<a
href="/projects/{project.id}"
class="inline-flex items-center gap-2 mt-4 ml-2 px-4 py-2 bg-black text-white border-4 border-black rounded-full font-bold hover:bg-gray-800 transition-all duration-200 cursor-pointer"
class="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-black text-white border-4 border-black rounded-full font-bold hover:bg-gray-800 transition-all duration-200 cursor-pointer"
>
edit project
</a>

View file

@ -107,7 +107,7 @@
{#if upgrading === item.id}
upgrading...
{:else}
upgrade +1% ({nextCost} scraps)
upgrade +{item.boostAmount}% ({nextCost} scraps)
{/if}
</button>
{/if}

View file

@ -5,7 +5,7 @@
import AddressSelectModal from '$lib/components/AddressSelectModal.svelte'
import { API_URL } from '$lib/config'
import { getUser } from '$lib/auth-client'
import { X, Spool } from '@lucide/svelte'
import { X, Spool, PackageCheck } from '@lucide/svelte'
import {
shopItemsStore,
shopLoading,
@ -22,6 +22,10 @@
let winningItemName = $state<string | null>(null)
let pendingOrders = $state<{ orderId: number; itemName: string }[]>([])
let consolationOrderId = $state<number | null>(null)
let consolationRolled = $state<number | null>(null)
let consolationNeeded = $state<number | null>(null)
let categories = $derived.by(() => {
const allCategories = new Set<string>()
$shopItemsStore.forEach((item) => {
@ -110,6 +114,13 @@
selectedItem = null
}
function handleConsolation(orderId: number, rolled: number, needed: number) {
consolationOrderId = orderId
consolationRolled = rolled
consolationNeeded = needed
selectedItem = null
}
function handleAddressComplete() {
fetchShopItems(true)
winningOrderId = null
@ -129,16 +140,14 @@
})
async function toggleHeart(itemId: number) {
const item = $shopItemsStore.find((i) => i.id === itemId)
if (!item) return
try {
const response = await fetch(`${API_URL}/shop/items/${itemId}/heart`, {
method: 'POST',
credentials: 'include'
})
if (response.ok) {
updateShopItemHeart(itemId, !item.userHearted)
const data = await response.json()
updateShopItemHeart(itemId, data.hearted, data.heartCount)
}
} catch (error) {
console.error('Failed to toggle heart:', error)
@ -227,9 +236,16 @@
{#each sortedItems as item (item.id)}
<button
onclick={() => (selectedItem = item)}
class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all cursor-pointer text-left"
class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all cursor-pointer text-left relative overflow-hidden {item.count === 0 ? 'bg-gray-100' : ''}"
>
<div class="relative">
{#if item.count === 0}
<div class="absolute top-0 right-0 z-20">
<div class="bg-red-600 text-white text-xs font-bold px-8 py-1 transform rotate-45 translate-x-6 translate-y-3 shadow-md">
sold out
</div>
</div>
{/if}
<div class="relative {item.count === 0 ? 'opacity-50 grayscale' : ''}">
<img src={item.image} alt={item.name} class="w-full h-32 object-contain mb-4" />
<span
class="absolute top-0 right-0 text-xs font-bold px-2 py-1 rounded-full {getProbabilityBgColor(item.effectiveProbability)} {getProbabilityColor(item.effectiveProbability)}"
@ -237,26 +253,28 @@
{item.effectiveProbability.toFixed(0)}% chance
</span>
</div>
<h3 class="font-bold text-xl mb-1">{item.name}</h3>
<p class="text-sm text-gray-600 mb-2">{item.description}</p>
<div class="mb-3">
<span class="text-lg font-bold flex items-center gap-1"><Spool size={18} />{item.price}</span>
<div class="flex gap-1 flex-wrap mt-2">
{#each item.category.split(',').map((c) => c.trim()).filter(Boolean) as cat}
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{cat}</span>
{/each}
<div class={item.count === 0 ? 'opacity-50' : ''}>
<h3 class="font-bold text-xl mb-1">{item.name}</h3>
<p class="text-sm text-gray-600 mb-2">{item.description}</p>
<div class="mb-3">
<span class="text-lg font-bold flex items-center gap-1"><Spool size={18} />{item.price}</span>
<div class="flex gap-1 flex-wrap mt-2">
{#each item.category.split(',').map((c) => c.trim()).filter(Boolean) as cat}
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{cat}</span>
{/each}
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs {item.count === 0 ? 'text-red-500 font-bold' : 'text-gray-500'}">{item.count === 0 ? 'sold out' : `${item.count} left`}</span>
<HeartButton
count={item.heartCount}
hearted={item.userHearted}
onclick={(e) => {
e.stopPropagation()
toggleHeart(item.id)
}}
/>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">{item.count} left</span>
<HeartButton
count={item.heartCount}
hearted={item.userHearted}
onclick={(e) => {
e.stopPropagation()
toggleHeart(item.id)
}}
/>
</div>
</button>
{/each}
@ -269,6 +287,7 @@
item={selectedItem}
onClose={() => (selectedItem = null)}
onTryLuck={handleTryLuck}
onConsolation={handleConsolation}
/>
{/if}
@ -283,3 +302,40 @@
onComplete={handleAddressComplete}
/>
{/if}
{#if consolationOrderId}
<AddressSelectModal
orderId={consolationOrderId}
itemName="consolation scrap paper"
onClose={() => {
consolationOrderId = null
consolationRolled = null
consolationNeeded = null
}}
onComplete={() => {
consolationOrderId = null
consolationRolled = null
consolationNeeded = null
}}
>
{#snippet header()}
<div class="mb-4 p-4 bg-yellow-50 border-2 border-yellow-400 rounded-xl">
<p class="font-bold text-yellow-800">better luck next time!</p>
<p class="text-sm text-yellow-700 mt-1">
you rolled {consolationRolled} but needed {consolationNeeded} or less.
</p>
<p class="text-sm text-yellow-700 mt-2">
as a consolation, we'll send you a random scrap of paper from hack club hq! just tell us where to ship it.
</p>
</div>
{/snippet}
</AddressSelectModal>
{/if}
<a
href="/orders"
class="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 border-4 border-black cursor-pointer"
>
<PackageCheck size={20} />
<span>my orders</span>
</a>